diff --git a/.gitleaks.toml b/.gitleaks.toml index 4a1991496..ead929762 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -9,3 +9,11 @@ paths = [ '''docs/''', '''test/''', ] + +[[rules]] +id = "basecamp-integration-url" +description = "Basecamp Integration URL" +regex = '''https://[^\s]*?([0-9a-fA-F]{16,})''' +[rules.allowlist] +regexTarget = "match" +regexes = ['''github\.com'''] diff --git a/Gemfile.lock b/Gemfile.lock index 57315b541..dd1cbc16b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -122,8 +122,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1187.0) - aws-sdk-core (3.239.1) + aws-partitions (1.1197.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -134,7 +134,7 @@ GEM aws-sdk-kms (1.118.0) aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.205.0) + aws-sdk-s3 (1.208.0) aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -144,7 +144,7 @@ GEM bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) benchmark (0.5.0) - bigdecimal (3.3.1) + bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.19.0) msgpack (~> 1.2) @@ -190,6 +190,7 @@ GEM ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) fugit (1.12.1) @@ -267,7 +268,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.0) + mittens (0.3.1) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -298,6 +299,8 @@ GEM racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) @@ -422,6 +425,7 @@ GEM sqlite3 (2.8.0-arm-linux-gnu) sqlite3 (2.8.0-arm-linux-musl) sqlite3 (2.8.0-arm64-darwin) + sqlite3 (2.8.0-x86_64-darwin) sqlite3 (2.8.0-x86_64-linux-gnu) sqlite3 (2.8.0-x86_64-linux-musl) sshkit (1.24.0) @@ -438,6 +442,7 @@ GEM thruster (0.1.17) thruster (0.1.17-aarch64-linux) thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-darwin) thruster (0.1.17-x86_64-linux) timeout (0.4.4) trilogy (2.9.0) @@ -481,6 +486,7 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin + x86_64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 576bbc731..5ea235fb5 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -200,8 +200,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1187.0) - aws-sdk-core (3.239.1) + aws-partitions (1.1197.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -212,7 +212,7 @@ GEM aws-sdk-kms (1.118.0) aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.205.0) + aws-sdk-s3 (1.208.0) aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -222,7 +222,7 @@ GEM bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) benchmark (0.5.0) - bigdecimal (3.3.1) + bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.19.0) msgpack (~> 1.2) @@ -346,7 +346,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.0) + mittens (0.3.1) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index c239e1b9d..aed15d90f 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -461,7 +461,23 @@ font-size: clamp(0.6rem, 0.85cqi, 100px); } + .card__comments { + --column-gap: 0.8ch; + + display: flex; + margin-block-end: calc(var(--block-space) * -1.7); + margin-inline-end: calc(var(--card-padding-inline) * -0.5); + + .icon { + --icon-size: 1.6em; + + color: var(--card-color); + } + } + .card__steps { + --column-gap: 0.8ch; + display: flex; } diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index a9a774c8b..240dadb4b 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -325,6 +325,19 @@ } } + .card-perma__notch-new-card-buttons { + display: flex; + gap: var(--inline-space-half); + + @media (max-width: 479px) { + flex-direction: column; + + .btn { + inline-size: 100%; + } + } + } + .card-perma__closure-message { color: var(--card-color); grid-area: closure-message; diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 45c98fb13..c3dbb7d74 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -35,6 +35,56 @@ .house-md-content { padding: var(--comment-padding-block) 0 0; } + + .comment--system & { + --comment-padding-block: var(--block-space-half); + + text-align: center; + + &::before { + /* Make up space for lack of avatar */ + content: ""; + display: flex; + inline-size: calc(var(--comment-padding-inline) * 0.9); + } + + .comment__avatar { + display: none; + } + + .comment__author { + a { margin: 0 auto; } + h3 { margin-inline: auto; } + strong { display: none; } + } + + .comment__body { + padding: 0; + text-align: center; + } + + .comment__content { + --stripe-color: var(--color-ink-lightest); + + background-image: repeating-linear-gradient( + 45deg in srgb, + var(--color-canvas) 0 1px, + var(--stripe-color) 1px 10px); + padding-inline: var(--comment-padding-inline); + + .comments--system-expanded .comment--system & { + --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); + } + + .comment__history { + background-color: var(--stripe-color); + } + } + + .reactions { + display: none !important; + } + } } .comment__author { @@ -74,6 +124,7 @@ background-color: var(--comment-bg-color); border-radius: 0.2em; + max-inline-size: calc(100% - calc(var(--comment-padding-inline) * 0.75)); padding: var(--comment-padding-block) calc(var(--comment-padding-inline) / 2) @@ -94,86 +145,28 @@ } .comment--system { - --comment-padding-block: var(--block-space-half); - display: none; - max-inline-size: var(--comment-max); - text-align: center; transition: var(--dialog-duration) allow-discrete; transition-property: display; .comments--system-expanded & { - display: flex; + display: contents; } + } - &:nth-last-child(1 of &):not(:has(~ * &) *):not(:has(~ &) *):not(:has(~ * &)):not(:has(&)) { - /* This unholy selector targets the last system comment in the document */ - /* Hat-tip, https://css-tip.com/last-element-dom/ */ - display: flex; + /* Show the last system comment */ + :nth-last-child(1 of .comment--system) { + display: contents; - .comment__history { - display: grid; - } + .comment__history { + display: grid; } + } - &:nth-child(1 of &):not(:has(&) ~ * *):not(:has(&) ~ *):not(& ~ * *):not(& *) { - /* Targets the first system comment in the document to effectively */ - /* hide the "Show history" button if there's only one entry */ - .comment__history { - display: none; - } - } - - &::before { - /* Make up space for lack of avatar */ - content: ""; - display: flex; - inline-size: calc(var(--comment-padding-inline) * 0.75); - } - - .comment__avatar { + /* Hide the "Show history" button if there's only one system comment */ + :nth-child(1 of .comment--system) { + .comment__history { display: none; } - - .comment__author { - a { - margin: 0 auto; - } - - h3 { - margin-inline: auto; - } - - strong { - display: none; - } - } - - .comment__body { - padding: 0; - text-align: center; - } - - .comment__content { - --stripe-color: var(--color-ink-lightest); - - background-image: repeating-linear-gradient( - 45deg in srgb, - var(--color-canvas) 0 1px, - var(--stripe-color) 1px 10px); - padding-inline: var(--comment-padding-inline); - - .comments--system-expanded .comment--system & { - --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); - } - - .comment__history { - background-color: var(--stripe-color); - } - } - - .reactions { - display: none !important; - } } } diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index 0213d04cb..189cfef5b 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -36,6 +36,15 @@ } } + .popup__footer { + border-block-start: 1px solid var(--color-ink-lightest); + color: var(--card-color); + margin-block-start: var(--popup-item-padding-inline); + padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0; + text-align: center; + text-transform: initial; + } + .popup__title { font-weight: 800; white-space: nowrap; diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index 797e9a7cb..5dea448cc 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -24,6 +24,10 @@ margin: 0; position: absolute; + @media (max-width: 640px) { + inset-inline-end: calc(var(--comment-padding-inline) / 3); + } + .reactions__list { display: none; } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 5a8ba3fd7..88e4d2ae2 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -28,6 +28,10 @@ } } + p:has(+ p) { + margin: 0; + } + ol, ul { padding-inline-start: 3ch; } diff --git a/app/assets/stylesheets/steps.css b/app/assets/stylesheets/steps.css index f106859ac..b9b40c8fa 100644 --- a/app/assets/stylesheets/steps.css +++ b/app/assets/stylesheets/steps.css @@ -93,17 +93,14 @@ } .steps__icon { + --icon-size: 0.875em; + background-color: var(--card-color); block-size: 1.3em; border-radius: 50%; + color: var(--color-ink-inverted); display: grid; inline-size: 1.3em; place-content: center; - - .icon { - background-color: var(--color-ink-inverted); - block-size: 0.8em; - inline-size: 0.8em; - } } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 949208bd4..7793b6f2d 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -440,6 +440,7 @@ .card__meta-text:not(.card__meta-text--updated), .card__stages, .card__steps, + .card__comments, .card__closed { display: none; } diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 6c8a17575..721e69b0d 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -83,7 +83,7 @@ class BoardsController < ApplicationController def show_columns cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded set_page_and_extract_portion_from cards - fresh_when etag: [ @board, @page.records, @user_filtering ] + fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ] end def board_params diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb index 396fd3a5f..3e9fe1d48 100644 --- a/app/controllers/cards/assignments_controller.rb +++ b/app/controllers/cards/assignments_controller.rb @@ -8,11 +8,16 @@ class Cards::AssignmentsController < ApplicationController end def create - @card.toggle_assignment @board.users.active.find(params[:assignee_id]) - - respond_to do |format| - format.turbo_stream - format.json { head :no_content } + if @card.toggle_assignment @board.users.active.find(params[:assignee_id]) + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end + else + respond_to do |format| + format.turbo_stream + format.json { head :unprocessable_entity } + end end end end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 83706ddf4..bfdc1ee2e 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -61,6 +61,6 @@ class CardsController < ApplicationController end def card_params - params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ]) + params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ]) end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index deb8b3fa3..05efbf4fc 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -4,13 +4,12 @@ module Authentication included do before_action :require_account # Checking and setting account must happen first before_action :require_authentication - after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? helper_method :email_address_pending_authentication etag { Current.identity.id if authenticated? } - include LoginHelper + include Authentication::ViaMagicLink, LoginHelper end class_methods do @@ -102,35 +101,7 @@ module Authentication cookies.delete(:session_token) end - def ensure_development_magic_link_not_leaked - unless Rails.env.development? - raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? - end - end - - def email_address_pending_authentication_matches?(email_address) - if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "") - session.delete(:email_address_pending_authentication) - true - else - false - end - end - - def email_address_pending_authentication - session[:email_address_pending_authentication] - end - - def redirect_to_session_magic_link(magic_link, return_to: nil) - serve_development_magic_link(magic_link) - session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link - session[:return_to_after_authenticating] = return_to if return_to - redirect_to main_app.session_magic_link_path(script_name: nil) - end - - def serve_development_magic_link(magic_link) - if Rails.env.development? - flash[:magic_link_code] = magic_link&.code - end + def session_token + cookies[:session_token] end end diff --git a/app/controllers/concerns/authentication/via_magic_link.rb b/app/controllers/concerns/authentication/via_magic_link.rb new file mode 100644 index 000000000..e9c0dc415 --- /dev/null +++ b/app/controllers/concerns/authentication/via_magic_link.rb @@ -0,0 +1,67 @@ +module Authentication::ViaMagicLink + extend ActiveSupport::Concern + + included do + after_action :ensure_development_magic_link_not_leaked + end + + private + def ensure_development_magic_link_not_leaked + unless Rails.env.development? + raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? + end + end + + def redirect_to_fake_session_magic_link(email_address, **options) + fake_magic_link = MagicLink.new( + identity: Identity.new(email_address: email_address), + code: SecureRandom.base32(6), + expires_at: MagicLink::EXPIRATION_TIME.from_now + ) + + redirect_to_session_magic_link fake_magic_link, **options + end + + def redirect_to_session_magic_link(magic_link, return_to: nil) + serve_development_magic_link(magic_link) + set_pending_authentication_token(magic_link) + session[:return_to_after_authenticating] = return_to if return_to + + respond_to do |format| + format.html { redirect_to main_app.session_magic_link_url(script_name: nil) } + format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created } + end + end + + def serve_development_magic_link(magic_link) + if Rails.env.development? && magic_link.present? + flash[:magic_link_code] = magic_link.code + response.set_header("X-Magic-Link-Code", magic_link.code) + end + end + + def set_pending_authentication_token(magic_link) + cookies[:pending_authentication_token] = { + value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at), + httponly: true, + same_site: :lax, + expires: magic_link.expires_at + } + end + + def email_address_pending_authentication + pending_authentication_token_verifier.verified(pending_authentication_token) + end + + def pending_authentication_token_verifier + Rails.application.message_verifier(:pending_authentication) + end + + def pending_authentication_token + cookies[:pending_authentication_token] + end + + def clear_pending_authentication_token + cookies.delete(:pending_authentication_token) + end +end diff --git a/app/controllers/concerns/filter_scoped.rb b/app/controllers/concerns/filter_scoped.rb index a9341f604..5507c9b8d 100644 --- a/app/controllers/concerns/filter_scoped.rb +++ b/app/controllers/concerns/filter_scoped.rb @@ -16,7 +16,7 @@ module FilterScoped end def filter_params - params.reverse_merge(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS) + params.with_defaults(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS) end def set_user_filtering diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb index 7a5754593..32b3da004 100644 --- a/app/controllers/my/menus_controller.rb +++ b/app/controllers/my/menus_controller.rb @@ -4,7 +4,8 @@ class My::MenusController < ApplicationController @boards = Current.user.boards.ordered_by_recently_accessed @tags = Current.account.tags.all.alphabetically @users = Current.account.users.active.alphabetically + @accounts = Current.identity.accounts - fresh_when etag: [ @filters, @boards, @tags, @users ] + fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ] end end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index c0632407a..32257d941 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -1,7 +1,7 @@ class Sessions::MagicLinksController < ApplicationController disallow_account_scope require_unauthenticated_access - rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" } + rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded before_action :ensure_that_email_address_pending_authentication_exists layout "public" @@ -11,25 +11,20 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - authenticate_with magic_link + authenticate magic_link else - redirect_to session_magic_link_path, flash: { shake: true } + invalid_code end end private def ensure_that_email_address_pending_authentication_exists unless email_address_pending_authentication.present? - redirect_to new_session_path, alert: "Enter your email address to sign in." - end - end - - def authenticate_with(magic_link) - if email_address_pending_authentication_matches?(magic_link.identity.email_address) - start_new_session_for magic_link.identity - redirect_to after_sign_in_url(magic_link) - else - redirect_to new_session_path, alert: "Authentication failed. Please try again." + alert_message = "Enter your email address to sign in." + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end end end @@ -37,6 +32,41 @@ class Sessions::MagicLinksController < ApplicationController params.expect(:code) end + def authenticate(magic_link) + if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) + sign_in magic_link + else + email_address_mismatch + end + end + + def sign_in(magic_link) + clear_pending_authentication_token + start_new_session_for magic_link.identity + + respond_to do |format| + format.html { redirect_to after_sign_in_url(magic_link) } + format.json { render json: { session_token: session_token } } + end + end + + def email_address_mismatch + clear_pending_authentication_token + alert_message = "Something went wrong. Please try again." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end + end + + def invalid_code + respond_to do |format| + format.html { redirect_to session_magic_link_path, flash: { shake: true } } + format.json { render json: { message: "Try another code." }, status: :unauthorized } + end + end + def after_sign_in_url(magic_link) if magic_link.for_sign_up? new_signup_completion_path @@ -44,4 +74,12 @@ class Sessions::MagicLinksController < ApplicationController after_authentication_url end end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again in 15 minutes." + respond_to do |format| + format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f5044bd2a..d0de9e84c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,7 @@ class SessionsController < ApplicationController disallow_account_scope require_unauthenticated_access except: :destroy - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded layout "public" @@ -9,16 +9,12 @@ class SessionsController < ApplicationController end def create - if identity = Identity.find_by_email_address(email_address) - redirect_to_session_magic_link identity.send_magic_link + if identity = Identity.find_by(email_address: email_address) + sign_in identity + elsif Account.accepting_signups? + sign_up else - signup = Signup.new(email_address: email_address) - if signup.valid?(:identity_creation) - magic_link = signup.create_identity if Account.accepting_signups? - redirect_to_session_magic_link magic_link - else - head :unprocessable_entity - end + redirect_to_fake_session_magic_link email_address end end @@ -28,7 +24,43 @@ class SessionsController < ApplicationController end private + def magic_link_from_sign_in_or_sign_up + if identity = Identity.find_by_email_address(email_address) + identity.send_magic_link + else + signup = Signup.new(email_address: email_address) + signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups? + end + end + def email_address params.expect(:email_address) end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again later." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end + + def sign_in(identity) + redirect_to_session_magic_link identity.send_magic_link + end + + def sign_up + signup = Signup.new(email_address: email_address) + + if signup.valid?(:identity_creation) + magic_link = signup.create_identity + redirect_to_session_magic_link magic_link + else + respond_to do |format| + format.html { redirect_to new_session_path, alert: "Something went wrong" } + format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } + end + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b3657c76e..946a0900e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -10,12 +10,6 @@ module ApplicationHelper tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options end - def inline_svg(name) - file_path = "#{Rails.root}/app/assets/images/#{name}.svg" - return File.read(file_path).html_safe if File.exist?(file_path) - "(not found)" - end - def back_link_to(label, url, action, **options) link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index 8c7bc0c0e..b2dc74727 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -43,7 +43,7 @@ module ColumnsHelper end def column_frame_tag(id, src: nil, data: {}, **options, &block) - data = data.reverse_merge \ + data = data.with_defaults \ drag_and_drop_refresh: true, controller: "frame", action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload" diff --git a/app/javascript/controllers/assignment_limit_controller.js b/app/javascript/controllers/assignment_limit_controller.js new file mode 100644 index 000000000..06f630c59 --- /dev/null +++ b/app/javascript/controllers/assignment_limit_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { limit: Number, count: Number } + static targets = ["unassigned", "limitMessage"] + + connect() { + this.updateState() + } + + countValueChanged() { + this.updateState() + } + + updateState() { + const atLimit = this.countValue >= this.limitValue + + this.unassignedTargets.forEach(el => { + el.hidden = atLimit + }) + + if (this.hasLimitMessageTarget) { + this.limitMessageTarget.hidden = !atLimit + } + } +} diff --git a/app/models/account.rb b/app/models/account.rb index df0b845b0..f9b2589c0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -19,7 +19,7 @@ class Account < ApplicationRecord def create_with_owner(account:, owner:) create!(**account).tap do |account| account.users.create!(role: :system, name: "System") - account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current)) + account.users.create!(**owner.with_defaults(role: :owner, verified_at: Time.current)) end end end diff --git a/app/models/account/seeder.rb b/app/models/account/seeder.rb index 4a349b939..ff97538be 100644 --- a/app/models/account/seeder.rb +++ b/app/models/account/seeder.rb @@ -69,6 +69,7 @@ class Account::Seeder
  • Make another called "Working on"
  • Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.

    +


    After that, drag this card to “DONE” or select “DONE” in the sidebar.

    HTML diff --git a/app/models/assignment.rb b/app/models/assignment.rb index f60eb974d..75ffde959 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -1,7 +1,18 @@ class Assignment < ApplicationRecord + LIMIT = 100 + belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :assignee, class_name: "User" belongs_to :assigner, class_name: "User" + + validate :within_limit, on: :create + + private + def within_limit + if card.assignments.count >= LIMIT + errors.add(:base, "Card already has the maximum of #{LIMIT} assignees") + end + end end diff --git a/app/models/card/assignable.rb b/app/models/card/assignable.rb index be0dd257e..6320faeb3 100644 --- a/app/models/card/assignable.rb +++ b/app/models/card/assignable.rb @@ -24,10 +24,12 @@ module Card::Assignable private def assign(user) - assignments.create! assignee: user, assigner: Current.user - watch_by user + assignment = assignments.create assignee: user, assigner: Current.user - track_event :assigned, assignee_ids: [ user.id ] + if assignment.persisted? + watch_by user + track_event :assigned, assignee_ids: [ user.id ] + end rescue ActiveRecord::RecordNotUnique # Already assigned end diff --git a/app/models/card/statuses.rb b/app/models/card/statuses.rb index 5311c211e..2dc235c1e 100644 --- a/app/models/card/statuses.rb +++ b/app/models/card/statuses.rb @@ -6,8 +6,6 @@ module Card::Statuses before_save :mark_if_just_published after_create -> { track_event :published }, if: :published? - - scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) } end attr_accessor :was_just_published diff --git a/app/models/user/email_address_changeable.rb b/app/models/user/email_address_changeable.rb index 2c8de7217..79479f788 100644 --- a/app/models/user/email_address_changeable.rb +++ b/app/models/user/email_address_changeable.rb @@ -39,7 +39,7 @@ module User::EmailAddressChangeable private def generate_email_address_change_token(from: identity.email_address, to:, **options) - options = options.reverse_merge( + options = options.with_defaults( for: EMAIL_CHANGE_TOKEN_PURPOSE, old_email_address: from, new_email_address: to, diff --git a/app/views/account/settings/_user.html.erb b/app/views/account/settings/_user.html.erb index 6bc65acd8..2388c542d 100644 --- a/app/views/account/settings/_user.html.erb +++ b/app/views/account/settings/_user.html.erb @@ -1,4 +1,4 @@ -
  • +
  • <%= link_to user, class: "txt-ink flex gap-half align-center min-width" do %> <%= avatar_preview_tag user, hidden_for_screen_reader: true %>
    diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index 5e571eb67..63b5b1763 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -12,7 +12,7 @@
    -
      +
        <%= render partial: "account/settings/user", collection: users %>
    diff --git a/app/views/boards/show/_mobile_columns.html.erb b/app/views/boards/show/_mobile_columns.html.erb index 964d75220..b52e14592 100644 --- a/app/views/boards/show/_mobile_columns.html.erb +++ b/app/views/boards/show/_mobile_columns.html.erb @@ -1,11 +1,15 @@ +<% postponed_count = board.cards.postponed.size %> +<% awaiting_triage_count = board.cards.awaiting_triage.size %> +<% closed_count = board.cards.closed.size %> +
    <%= render "columns/show/add_card_button", board: board %>
    <%= link_to board_columns_not_now_path(board), class: "cards cards--on-deck is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %> -
    - <%= board.cards.postponed.count %> +
    + <%= postponed_count %>

    Not Now

    @@ -13,8 +17,8 @@ <% end %> <%= link_to board_columns_stream_path(board), class: "cards cards--considering is-collapsed", data: { turbo_frame: "_top" } do %> -
    - <%= board.cards.awaiting_triage.count %> +
    + <%= awaiting_triage_count %>

    Maybe?

    @@ -22,9 +26,10 @@ <% end %> <% board.columns.sorted.each do |column| %> + <% active_count = column.cards.active.size %> <%= link_to board_column_path(column.board, column), class: "cards cards--doing is-collapsed", style: "--card-color: #{column.color}", data: { turbo_frame: "_top" } do %> -
    - <%= column.cards.active.count %> +
    + <%= active_count %>

    <%= column.name %>

    @@ -33,8 +38,8 @@ <% end %> <%= link_to board_columns_closed_path(board), class: "cards cards--closed is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %> -
    - <%= board.cards.closed.count %> +
    + <%= closed_count %>

    Done

    diff --git a/app/views/cards/assignments/new.html.erb b/app/views/cards/assignments/new.html.erb index 6692a53f0..609478550 100644 --- a/app/views/cards/assignments/new.html.erb +++ b/app/views/cards/assignments/new.html.erb @@ -1,10 +1,12 @@ <%= turbo_frame_tag @card, :assignment do %> <%= tag.div class: "max-width full-width", data: { action: "turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset", - controller: "filter navigable-list", + controller: "filter navigable-list assignment-limit", dialog_target: "dialog", navigable_list_focus_on_selection_value: false, - navigable_list_actionable_items_value: true } do %> + navigable_list_actionable_items_value: true, + assignment_limit_limit_value: Assignment::LIMIT, + assignment_limit_count_value: @card.assignments.count } do %>
    Assign this to… @@ -17,9 +19,18 @@ + + <% end %> <% end %> diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index de2b94c0d..2f8a120a0 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -1,8 +1,8 @@ <% cache comment do %> <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> - <%= turbo_frame_tag comment, :container do %> + <%= turbo_frame_tag comment, :container, class: ["comment--system": comment.creator.system?] do %> <%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%> -
    "> +
    diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb index 3e89952df..8b5062fdd 100644 --- a/app/views/cards/container/footer/_create.html.erb +++ b/app/views/cards/container/footer/_create.html.erb @@ -1,5 +1,5 @@
    -
    +
    <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })", form: { data: { controller: "form" } }, diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index c47ea7802..655652ffe 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -30,8 +30,9 @@
    -
    +
    <%= render "cards/display/preview/meta", card: card, preview: true %> + <%= render "cards/display/preview/comments", card: card %> <%= render "cards/display/common/background", card: card %>
    diff --git a/app/views/cards/display/common/_assignees.html.erb b/app/views/cards/display/common/_assignees.html.erb index 24346a6e7..80329b9ae 100644 --- a/app/views/cards/display/common/_assignees.html.erb +++ b/app/views/cards/display/common/_assignees.html.erb @@ -12,6 +12,10 @@ + <% if Current.user %> + <%= button_to "Assign to me", card_assignments_path(card, params: { assignee_id: Current.user.id }), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %> + <% end %> + <%= yield %> diff --git a/app/views/cards/display/preview/_comments.html.erb b/app/views/cards/display/preview/_comments.html.erb new file mode 100644 index 000000000..7cca0d106 --- /dev/null +++ b/app/views/cards/display/preview/_comments.html.erb @@ -0,0 +1,7 @@ +<% comments = card.comments.by_user %> +<% if comments.any? %> +
    + <%= icon_tag "comment" %> + <%= comments.count %> +
    +<% end %> diff --git a/app/views/layouts/public.html.erb b/app/views/layouts/public.html.erb index 253b9bd31..98a634e66 100644 --- a/app/views/layouts/public.html.erb +++ b/app/views/layouts/public.html.erb @@ -7,8 +7,8 @@ Skip to main content diff --git a/app/views/my/_menu.html.erb b/app/views/my/_menu.html.erb index 053503d63..89b6cba69 100644 --- a/app/views/my/_menu.html.erb +++ b/app/views/my/_menu.html.erb @@ -4,8 +4,8 @@ <%= tag.button class:"nav__trigger input input--select center flex-inline align-center txt-normal", data: { action: "click->dialog#open keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click", controller: "hotkey" } do %> - <%= image_tag "logo.png" %> - + <%= image_tag "logo.png", alt: "" %> + J <% end %> diff --git a/app/views/my/menus/show.html.erb b/app/views/my/menus/show.html.erb index e6ee838e2..cb3e6310e 100644 --- a/app/views/my/menus/show.html.erb +++ b/app/views/my/menus/show.html.erb @@ -5,7 +5,7 @@ <%= render "my/menus/people", users: @users %> <%= render "my/menus/settings" %> <%= render "my/menus/shortcuts" %> - <%= render "my/menus/accounts", accounts: Current.identity.accounts %> + <%= render "my/menus/accounts", accounts: @accounts %> <% end %>
    diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index 26a28da9d..c4940e146 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -9,7 +9,7 @@ <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %> - <%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large", + <%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase", autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true, autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code], data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %> diff --git a/bin/broadcast_to_bc b/bin/broadcast_to_bc deleted file mode 100755 index 2fbd1e3e5..000000000 --- a/bin/broadcast_to_bc +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -curl -s -d content="[Fizzy] ${1}" https://3.basecamp.com/2914079/integrations/5c3a7bd149c975b95312253c/buckets/4896644/chats/676031034/lines diff --git a/config/deploy.yml b/config/deploy.yml index 5550c317e..5d2f52e01 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -28,6 +28,7 @@ env: - SMTP_USERNAME - SMTP_PASSWORD clear: + BASE_URL: https://fizzy.example.com # The public URL of your Fizzy instance MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email MULTI_TENANT: false # Set to true to allow multiple accounts to sign up diff --git a/config/environments/production.rb b/config/environments/production.rb index 173318fe3..1927557cf 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -21,6 +21,17 @@ Rails.application.configure do } end + # Base URL for links in emails and other external references. + # Set BASE_URL to your instance's public URL (e.g., https://fizzy.example.com) + if base_url = ENV["BASE_URL"].presence + uri = URI.parse(base_url) + url_options = { host: uri.host, protocol: uri.scheme } + url_options[:port] = uri.port if uri.port != uri.default_port + + routes.default_url_options = url_options + config.action_mailer.default_url_options = url_options + end + # Code is not reloaded between requests. config.enable_reloading = false @@ -76,9 +87,6 @@ Rails.application.configure do .tap { |logger| logger.formatter = ::Logger::Formatter.new } .then { |logger| ActiveSupport::TaggedLogging.new(logger) } - # Suppress unstructured log lines - config.log_level = :fatal - # Prepend all log lines with the following tags. config.log_tags = [ :request_id ] diff --git a/config/importmap.rb b/config/importmap.rb index 698815341..3431633f9 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -4,7 +4,7 @@ pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" -pin "@rails/request.js", to: "rails-request.js" # @0.0.11 +pin "@rails/request.js", to: "@rails--request.js" # @0.0.13 pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/helpers", under: "helpers" diff --git a/config/initializers/vips.rb b/config/initializers/vips.rb index c965f3734..fe6066727 100644 --- a/config/initializers/vips.rb +++ b/config/initializers/vips.rb @@ -1,3 +1,5 @@ +raise LoadError, "Please install libvips" unless defined?(Vips::LIBRARY_VERSION) + # Disable Openslide to prevent sqlite segfault in forked parallel workers # Requires libvips 8.13+ Vips.block "VipsForeignLoadOpenslide", true if Vips.respond_to?(:block) diff --git a/docs/API.md b/docs/API.md index 8f85eec6a..3c1d91977 100644 --- a/docs/API.md +++ b/docs/API.md @@ -5,6 +5,13 @@ a bot to perform various actions for you. ## Authentication +There are two ways to authenticate with the Fizzy API: + +1. **Personal access tokens** - Long-lived tokens for scripts and integrations +2. **Magic link authentication** - Session-based authentication for native apps + +### Personal Access Tokens + To use the API you'll need an access token. To get one, go to your profile, then, in the API section, click on "Personal access tokens" and then click on "Generate new access token". @@ -36,6 +43,81 @@ To authenticate a request using your access token, include it in the `Authorizat curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/my/identity ``` +### Magic Link Authentication + +For native apps, you can authenticate users via magic links. This is a two-step process: + +#### 1. Request a magic link + +Send the user's email address to request a magic link be sent to them: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"email_address": "user@example.com"}' \ + https://app.fizzy.do/session +``` + +__Response:__ + +``` +HTTP/1.1 201 Created +Set-Cookie: pending_authentication_token=...; HttpOnly; SameSite=Lax +``` + +```json +{ + "pending_authentication_token": "eyJfcmFpbHMi..." +} +``` + +The response includes a `pending_authentication_token` both in the JSON body and as a cookie. +Native apps should store this token and include it as a cookie when submitting the magic link code. + +__Error responses:__ + +| Status Code | Description | +|--------|-------------| +| `422 Unprocessable entity` | Invalid email address, if sign ups are enabled and the value isn't a valid email address | +| `429 Too Many Requests` | Rate limit exceeded | + +#### 2. Submit the magic link code + +Once the user receives the magic link email, they'll have a 6-character code. Submit it to complete authentication: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Cookie: pending_authentication_token=eyJfcmFpbHMi..." \ + -d '{"code": "ABC123"}' \ + https://app.fizzy.do/session/magic_link +``` + +__Response:__ + +```json +{ + "session_token": "eyJfcmFpbHMi..." +} +``` + +The `session_token` can be used to authenticate subsequent requests by including it as a cookie: + +```bash +curl -H "Cookie: session_token=eyJfcmFpbHMi..." \ + -H "Accept: application/json" \ + https://app.fizzy.do/my/identity +``` + +__Error responses:__ + +| Status Code | Description | +|--------|-------------| +| `401 Unauthorized` | Invalid `pending_authentication_token` or `code` | +| `429 Too Many Requests` | Rate limit exceeded | + ## Caching Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data. @@ -482,7 +564,60 @@ Returns a specific card by its number. __Response:__ -Same as the card object in the list response. +```json +{ + "id": "03f5vaeq985jlvwv3arl4srq2", + "number": 1, + "title": "First!", + "status": "published", + "description": "Hello, World!", + "description_html": "

    Hello, World!

    ", + "image_url": null, + "tags": ["programming"], + "golden": false, + "last_active_at": "2025-12-05T19:38:48.553Z", + "created_at": "2025-12-05T19:38:48.540Z", + "url": "http://fizzy.localhost:3006/897362094/cards/4", + "board": { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + } + }, + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + }, + "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments", + "steps": [ + { + "id": "03f8huu0sog76g3s975963b5e", + "content": "This is the first step", + "completed": false + }, + { + "id": "03f8huu0sog76g3s975969734", + "content": "This is the second step", + "completed": false + } + ] +} +``` ### `POST /:account_slug/boards/:board_id/cards` diff --git a/docs/development.md b/docs/development.md index e253e430b..8fa1e5467 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,7 +74,7 @@ Under the hood, this will create or remove `tmp/email-dev.txt`. ## SaaS gem -37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup. +37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy/tree/main/saas), a companion gem that links Fizzy with our billing system and contains our production setup. This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure. diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index f92b80ca2..03b4d9c8b 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -90,6 +90,15 @@ Less commonly, you might also need to set some of the following: You can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration). +#### Base URL + +Fizzy needs to know the public URL of your instance so it can generate correct links in certain situations (like when sending emails). +Set `BASE_URL` to the full URL where your Fizzy instance is accessible: + +```sh +docker run --environment BASE_URL=https://fizzy.example.com ... +``` + #### VAPID keys Fizzy can also send Web Push notifications. @@ -114,7 +123,7 @@ Set those in the `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` environment variable #### S3 storage (optional) -If you're rather that uploaded files were stored in an S3 bucket, rather than your mounted volume, you can set that up. +If you'd prefer that uploaded files were stored in an S3 bucket rather than in your mounted volume, you can set that up. First set `ACTIVE_STORAGE_SERVICE` to `s3`. Then set the following as appropriate for your S3 bucket: @@ -153,6 +162,7 @@ services: environment: - SECRET_KEY_BASE=abcdefabcdef - TLS_DOMAIN=fizzy.example.com + - BASE_URL=https://fizzy.example.com - MAILER_FROM_ADDRESS=fizzy@example.com - SMTP_ADDRESS=mail.example.com - SMTP_USERNAME=user diff --git a/docs/kamal-deployment.md b/docs/kamal-deployment.md index 9f90d1c20..19aa208c8 100644 --- a/docs/kamal-deployment.md +++ b/docs/kamal-deployment.md @@ -33,6 +33,7 @@ We've added comments to that file to highlight what each setting needs to be, bu - `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`. - `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here. - `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. +- `env/clear/BASE_URL`: The public URL of your Fizzy instance (e.g., `https://fizzy.example.com`). Used when generating links. - `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. - `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this. - `env/clear/MULTI_TENANT`: Set to `true` if you want to allow multiple accounts to sign up on your server (by default, Fizzy will allow you to create a single account). diff --git a/lib/rails_ext/active_record_uuid_type.rb b/lib/rails_ext/active_record_uuid_type.rb index 02bdf3ed3..f2004f249 100644 --- a/lib/rails_ext/active_record_uuid_type.rb +++ b/lib/rails_ext/active_record_uuid_type.rb @@ -4,32 +4,34 @@ module ActiveRecord class Uuid < Binary BASE36_LENGTH = 25 # 36^25 > 2^128 - def self.generate - uuid = SecureRandom.uuid_v7 - hex = uuid.delete("-") - normalize_base36(hex.to_i(16)) - end + class << self + def generate + uuid = SecureRandom.uuid_v7 + hex = uuid.delete("-") + hex_to_base36(hex) + end - def self.normalize_base36(integer) - integer.to_s(36).rjust(BASE36_LENGTH, "0") + def hex_to_base36(hex) + hex.to_i(16).to_s(36).rjust(BASE36_LENGTH, "0") + end + + def base36_to_hex(base36) + base36.to_s.to_i(36).to_s(16).rjust(32, "0") + end end def serialize(value) return unless value - binary = hex(value).scan(/../).map(&:hex).pack("C*") + binary = Uuid.base36_to_hex(value).scan(/../).map(&:hex).pack("C*") super(binary) end - def hex(value) - value.to_s.to_i(36).to_s(16).rjust(32, "0") - end - def deserialize(value) return unless value hex = value.to_s.unpack1("H*") - Uuid.normalize_base36(hex.to_i(16)) + Uuid.hex_to_base36(hex) end def cast(value) diff --git a/saas/.kamal/hooks/post-deploy b/saas/.kamal/hooks/post-deploy index 871506078..7e59f9f19 100755 --- a/saas/.kamal/hooks/post-deploy +++ b/saas/.kamal/hooks/post-deploy @@ -11,7 +11,7 @@ if [[ $CURRENT_BRANCH == "main" && $KAMAL_DESTINATION == "production" ]]; then RELEASE_URL=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .url) RELEASE_BODY=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .body) - bin/broadcast_to_bc "$MESSAGE "$'\n'"$RELEASE_URL "$'\n'"$RELEASE_BODY" + saas/bin/broadcast_to_bc "$MESSAGE "$'\n'"$RELEASE_URL "$'\n'"$RELEASE_BODY" else - bin/broadcast_to_bc "$MESSAGE" + saas/bin/broadcast_to_bc "$MESSAGE" fi diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 0fb3db660..658b75c59 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 672359a13..768abbcd0 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 7121f5e3a..e98540135 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css index 318c749e0..326b95b40 100644 --- a/saas/app/assets/stylesheets/fizzy/saas.css +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -23,30 +23,36 @@ color: var(--settings-subscription-text-color); &::before { - content: "————"; + border-block-start: 1px solid var(--settings-subscription-text-color); + content: ""; display: block; - margin: auto; - text-align: center; + inline-size: 8ch; + margin: 0 auto 1ch; } } + .settings-subscription__link { + color: var(--settings-subscription-text-color); + } + .settings-subscription__notch { animation: wiggle 500ms ease; background: var(--settings-subscription-background); - box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color); border-radius: 3em; + box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color); color: var(--settings-subscription-text-color); margin-inline: auto; padding: 0 0.3em 0 1.2em; transform: rotate(-1deg); @media (max-width: 640px) { - padding-block: 0.6em; - padding-inline-start: 0.3em; + border-radius: 1ch; + padding-block: 1ch; + padding-inline: 2ch; } .btn { - margin-block: 0.3em; + /* margin-block: 0.3em; */ } } diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb new file mode 100644 index 000000000..d06795841 --- /dev/null +++ b/saas/app/controllers/account/subscriptions/downgrades_controller.rb @@ -0,0 +1,12 @@ +class Account::Subscriptions::DowngradesController < Account::Subscriptions::UpdatePlanController + before_action :ensure_downgradeable + + private + def target_plan + Plan.paid + end + + def ensure_downgradeable + head :bad_request unless subscription.plan == Plan.paid_with_extra_storage + end +end diff --git a/saas/app/controllers/account/subscriptions/update_plan_controller.rb b/saas/app/controllers/account/subscriptions/update_plan_controller.rb new file mode 100644 index 000000000..c6a665cec --- /dev/null +++ b/saas/app/controllers/account/subscriptions/update_plan_controller.rb @@ -0,0 +1,32 @@ +class Account::Subscriptions::UpdatePlanController < ApplicationController + before_action :ensure_admin + + def create + portal_session = Stripe::BillingPortal::Session.create( + customer: subscription.stripe_customer_id, + return_url: account_settings_url(anchor: "subscription"), + flow_data: { + type: "subscription_update_confirm", + subscription_update_confirm: { + subscription: subscription.stripe_subscription_id, + items: [ { id: stripe_subscription_item_id, price: target_plan.stripe_price_id } ] + } + } + ) + + redirect_to portal_session.url, allow_other_host: true + end + + private + def target_plan + raise NotImplementedError + end + + def subscription + @subscription ||= Current.account.subscription + end + + def stripe_subscription_item_id + Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + end +end diff --git a/saas/app/controllers/account/subscriptions/upgrades_controller.rb b/saas/app/controllers/account/subscriptions/upgrades_controller.rb new file mode 100644 index 000000000..1de6d8c6c --- /dev/null +++ b/saas/app/controllers/account/subscriptions/upgrades_controller.rb @@ -0,0 +1,12 @@ +class Account::Subscriptions::UpgradesController < Account::Subscriptions::UpdatePlanController + before_action :ensure_upgradeable + + private + def target_plan + Plan.paid_with_extra_storage + end + + def ensure_upgradeable + head :bad_request unless subscription.plan == Plan.paid + end +end diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 8ddfb4b7a..bbc4ec47a 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -9,10 +9,10 @@ class Account::SubscriptionsController < ApplicationController session = Stripe::Checkout::Session.create \ customer: find_or_create_stripe_customer, mode: "subscription", - line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ], + line_items: [ { price: plan_param.stripe_price_id, quantity: 1 } ], success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", cancel_url: account_subscription_url, - metadata: { account_id: Current.account.id, plan_key: Plan.paid.key }, + metadata: { account_id: Current.account.id, plan_key: plan_param.key }, automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, billing_address_collection: "required", @@ -22,6 +22,10 @@ class Account::SubscriptionsController < ApplicationController end private + def plan_param + @plan_param ||= Plan[params[:plan_key]] || Plan.paid + end + def set_stripe_session @stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] end @@ -36,7 +40,7 @@ class Account::SubscriptionsController < ApplicationController def create_stripe_customer Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer| - Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: Plan.paid.key, status: "incomplete") + Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: plan_param.key, status: "incomplete") end end end diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb index ba6200d1b..26a2b9c82 100644 --- a/saas/app/controllers/admin/accounts_controller.rb +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -12,7 +12,7 @@ class Admin::AccountsController < AdminController end def update - @account.override_limits(card_count: overridden_card_count_param) + @account.override_limits(**overridden_limits_params.to_h.symbolize_keys) redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated" end @@ -21,7 +21,7 @@ class Admin::AccountsController < AdminController @account = Account.find_by!(external_account_id: params[:id]) end - def overridden_card_count_param - params[:account][:overridden_card_count].to_i + def overridden_limits_params + params.expect(account: [ :card_count, :bytes_used ]) end end diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb new file mode 100644 index 000000000..0576c6c4e --- /dev/null +++ b/saas/app/controllers/concerns/card/limited.rb @@ -0,0 +1,8 @@ +module Card::Limited + extend ActiveSupport::Concern + + private + def ensure_under_limits + head :forbidden if Current.account.exceeding_limits? + end +end diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb index b4c773449..0e992008a 100644 --- a/saas/app/controllers/concerns/card/limited_creation.rb +++ b/saas/app/controllers/concerns/card/limited_creation.rb @@ -2,13 +2,10 @@ module Card::LimitedCreation extend ActiveSupport::Concern included do + include Card::Limited + # Only limit API requests. We let you create drafts in the app to actually show the banner, no matter the card count. # We limit card publications separately. See +Card::LimitedPublishing+. - before_action :ensure_can_create_cards, only: %i[ create ], if: -> { request.format.json? } + before_action :ensure_under_limits, only: %i[ create ], if: -> { request.format.json? } end - - private - def ensure_can_create_cards - head :forbidden if Current.account.exceeding_card_limit? - end end diff --git a/saas/app/controllers/concerns/card/limited_publishing.rb b/saas/app/controllers/concerns/card/limited_publishing.rb index b92ab06f3..8b60a4b53 100644 --- a/saas/app/controllers/concerns/card/limited_publishing.rb +++ b/saas/app/controllers/concerns/card/limited_publishing.rb @@ -2,11 +2,8 @@ module Card::LimitedPublishing extend ActiveSupport::Concern included do - before_action :ensure_can_publish_cards, only: %i[ create ] - end + include Card::Limited - private - def ensure_can_publish_cards - head :forbidden if Current.account.exceeding_card_limit? - end + before_action :ensure_under_limits, only: %i[ create ] + end end diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index bf00fc23f..cdfb6bcec 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -49,7 +49,8 @@ class Stripe::WebhooksController < ApplicationController status: stripe_subscription.status, current_period_end: current_period_end_for(stripe_subscription), cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil, - next_amount_due_in_cents: next_amount_due_for(stripe_subscription) + next_amount_due_in_cents: next_amount_due_for(stripe_subscription), + plan_key: plan_key_for(stripe_subscription) } yield subscription_properties if block_given? @@ -76,4 +77,9 @@ class Stripe::WebhooksController < ApplicationController rescue Stripe::InvalidRequestError nil end + + def plan_key_for(stripe_subscription) + price_id = stripe_subscription.items.data.first&.price&.id + Plan.find_by_price_id(price_id)&.key + end end diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index a1c4c07d9..48ce0372f 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -1,6 +1,6 @@ module SubscriptionsHelper - def plan_storage_limit(plan) - number_to_human_size(plan.storage_limit).delete(" ") + def storage_to_human_size(bytes) + number_to_human_size(bytes).delete(" ") end def subscription_period_end_action(subscription) @@ -9,7 +9,7 @@ module SubscriptionsHelper elsif subscription.canceled? "Your Fizzy subscription ended on" else - "Your next payment of #{ number_to_currency(subscription.next_amount_due) } will be billed on".html_safe + "Your next payment is #{ number_to_currency(subscription.next_amount_due, strip_insignificant_zeros: true) } on".html_safe end end end diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb index c1968522a..a9ec35eb8 100644 --- a/saas/app/models/account/billing_waiver.rb +++ b/saas/app/models/account/billing_waiver.rb @@ -2,6 +2,6 @@ class Account::BillingWaiver < SaasRecord belongs_to :account def subscription - @subscription ||= Account::Subscription.new(plan_key: Plan.paid.key) + @subscription ||= Account::Subscription.new(plan: Plan.paid_with_extra_storage) end end diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index f7eb3cbc1..8e846a117 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -6,15 +6,20 @@ module Account::Limited end NEAR_CARD_LIMIT_THRESHOLD = 100 + NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes - def override_limits(card_count:) - (overridden_limits || build_overridden_limits).update!(card_count:) + def override_limits(card_count: nil, bytes_used: nil) + (overridden_limits || build_overridden_limits).update!(card_count:, bytes_used:) end def billed_cards_count overridden_limits&.card_count || cards_count end + def billed_bytes_used + overridden_limits&.bytes_used || bytes_used + end + def nearing_plan_cards_limit? plan.limit_cards? && remaining_cards_count < NEAR_CARD_LIMIT_THRESHOLD end @@ -23,6 +28,18 @@ module Account::Limited plan.limit_cards? && billed_cards_count > plan.card_limit end + def nearing_plan_storage_limit? + remaining_storage < NEAR_STORAGE_LIMIT_THRESHOLD + end + + def exceeding_storage_limit? + billed_bytes_used > plan.storage_limit + end + + def exceeding_limits? + exceeding_card_limit? || exceeding_storage_limit? + end + def reset_overridden_limits overridden_limits&.destroy reload_overridden_limits @@ -32,4 +49,8 @@ module Account::Limited def remaining_cards_count plan.card_limit - billed_cards_count end + + def remaining_storage + plan.storage_limit - billed_bytes_used + end end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index 0eac71b02..dd7833dfc 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -11,6 +11,10 @@ class Account::Subscription < SaasRecord @plan ||= Plan.find(plan_key) end + def plan=(plan) + self.plan_key = plan.key + end + def to_be_canceled? active? && cancel_at.present? end diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 7ef7f922e..e77ea81e8 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,8 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] } + monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] }, + monthly_extra_storage_v1: { name: "Unlimited + Extra Storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id @@ -19,11 +20,19 @@ class Plan @paid ||= find(:monthly_v1) end + def paid_with_extra_storage + @paid_with_extra_storage ||= find(:monthly_extra_storage_v1) + end + def find(key) @all_by_key ||= all.index_by(&:key).with_indifferent_access @all_by_key[key] end + def find_by_price_id(price_id) + all.find { |plan| plan.stripe_price_id == price_id } + end + alias [] find end @@ -45,6 +54,6 @@ class Plan end def limit_cards? - card_limit != Float::INFINITY + !card_limit.infinite? end end diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb new file mode 100644 index 000000000..1f59ff49f --- /dev/null +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -0,0 +1,18 @@ +

    + <% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %> + You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage + <% elsif Current.account.exceeding_card_limit? %> + You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards + <% elsif Current.account.exceeding_storage_limit? %> + You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage + <% else %> + You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %> + <% end %> +

    + +

    To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

    + +<%= button_to "Upgrade to #{Plan.paid.name} for #{ number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> + +

    Cancel anytime, no contracts, take your data with you whenever.

    + diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb new file mode 100644 index 000000000..426568eae --- /dev/null +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -0,0 +1,19 @@ +

    + <% if Current.account.exceeding_storage_limit? %> + You’ve run out of storage + <% elsif Current.account.nearing_plan_storage_limit? %> + You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> of storage + <% else %> + Thank you for buying Fizzy + <% end %> +

    + +<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %> +

    + The <%= Current.account.plan.name %> plan includes <%= storage_to_human_size(Plan.paid.storage_limit) %>. Upgrade to get <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> extra storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more. +

    +<% end %> + +<% if Current.account.subscription %> + <%= render "account/settings/subscription", subscription: Current.account.subscription %> +<% end %> diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 3aef9b853..c3e629607 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -1,7 +1,20 @@ - - <% if subscription.current_period_end %> -

    <%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

    +

    <%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

    <% end %> -<%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %> +

    + <%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %> +

    + + + diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 44949e022..5a5f0eace 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -3,26 +3,9 @@

    Subscription

    <% if Current.account.plan.free? %> - <% if Current.account.exceeding_card_limit? %> -

    You’ve used up your <%= Plan.free.card_limit %> free cards

    - <% else %> -

    You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= Plan.free.card_limit %>

    - <% end %> - -

    If you’d like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it’s only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= plan_storage_limit(Plan.paid) %> of storage.

    - - <%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> - -

    Cancel anytime, no contracts, take your data with you whenever.

    - + <%= render "account/settings/free_plan" %> <% else %> -

    Thank you for buying Fizzy

    - - <% if Current.account.subscription %> - <%= render "account/settings/subscription", subscription: Current.account.subscription %> - - <% end %> + <%= render "account/settings/paid_plan" %> <% end %> -
    <% end %> diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index d1c06e7f0..449fecf16 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,12 +1,9 @@
    <% if Current.user.admin? %> - You’ve used your <%= Plan.free.card_limit %> free cards. - <%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> - Upgrade to Unlimited - <% end %> + <%= render "account/subscriptions/notices/admin_exceeding_card_limit" if Current.account.exceeding_card_limit? %> + <%= render "account/subscriptions/notices/admin_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %> <% else %> -
    - This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. -
    + <%= render "account/subscriptions/notices/user_exceeding_card_limit" if Current.account.exceeding_card_limit? %> + <%= render "account/subscriptions/notices/user_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %> <% end %>
    diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb new file mode 100644 index 000000000..3dc759d9e --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb @@ -0,0 +1,4 @@ +You’ve used your <%= Plan.free.card_limit %> free cards. +<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> + Upgrade to Unlimited +<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb new file mode 100644 index 000000000..8770c1460 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb @@ -0,0 +1,4 @@ +You’ve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. +<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> + Upgrade to get more +<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb new file mode 100644 index 000000000..f3f65f3be --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb @@ -0,0 +1,3 @@ +
    + This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. +
    diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb new file mode 100644 index 000000000..f230443a1 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb @@ -0,0 +1,3 @@ +
    + This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more. +
    diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 49c99773d..950ecc761 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -11,14 +11,21 @@
    Actual card count:
    <%= @account.cards_count %>
    +
    +
    Actual bytes used:
    +
    <%= storage_to_human_size(@account.bytes_used) %>
    +
    <%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %> -
    - <%= form.label :overridden_card_count, "Override card count", class: "font-weight-bold" %> -
    - <%= form.number_field :overridden_card_count, value: @account.overridden_limits&.card_count, class: "input full-width" %> -
    +
    + <%= form.label :card_count, "Override card count", class: "font-weight-bold" %> + <%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %> +
    + +
    + <%= form.label :bytes_used, "Override bytes used", class: "font-weight-bold" %> + <%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
    diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb index 295c9bd19..8b0edd308 100644 --- a/saas/app/views/cards/container/footer/saas/_create.html.erb +++ b/saas/app/views/cards/container/footer/saas/_create.html.erb @@ -1,4 +1,4 @@ -<% if Current.account.exceeding_card_limit? %> +<% if Current.account.exceeding_limits? %> <%= render "account/subscriptions/upgrade" %> <% else %> <%= render "cards/container/footer/create", card: card %> diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb index e40aa9fb5..4cf480f3c 100644 --- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -1,9 +1,9 @@ -<% if Current.account.nearing_plan_cards_limit? %> -
    - <% if Current.user.admin? %> - You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. - <% else %> - This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon - <% end %> -
    -<% end %> +
    + <% if Current.user.admin? %> + <%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> + <% else %> + <%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> + <% end %> +
    diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb new file mode 100644 index 000000000..caa7bd958 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb @@ -0,0 +1 @@ +You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb new file mode 100644 index 000000000..ab1a3ca34 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb @@ -0,0 +1 @@ +You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. <%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb new file mode 100644 index 000000000..8ab80e162 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb @@ -0,0 +1 @@ +This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb new file mode 100644 index 000000000..25428980a --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb @@ -0,0 +1 @@ +This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade soon. diff --git a/saas/bin/broadcast_to_bc b/saas/bin/broadcast_to_bc new file mode 100755 index 000000000..ba45801f5 --- /dev/null +++ b/saas/bin/broadcast_to_bc @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +url=$(op read "op://Deploy/Deploy Chatbot/url" --account Basecamp) +curl -s -d content="[Fizzy] ${1}" "${url}" diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 4aefe5663..35607d58e 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -45,6 +45,7 @@ env: - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET tags: diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index 70556948e..443bc81e8 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -51,6 +51,7 @@ env: - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET tags: diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml index 2c0ae9516..bfcc5f441 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -51,6 +51,7 @@ env: - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET tags: diff --git a/saas/config/environments/production.rb b/saas/config/environments/production.rb index 3cad5c015..9cb886ed8 100644 --- a/saas/config/environments/production.rb +++ b/saas/config/environments/production.rb @@ -1,6 +1,9 @@ Rails.application.configure do config.active_storage.service = :purestorage + + # Enable structured logging, suppress unstructured log lines config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) + config.log_level = :fatal config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" } config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" } diff --git a/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb b/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb new file mode 100644 index 000000000..85adad44a --- /dev/null +++ b/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb @@ -0,0 +1,5 @@ +class AddBytesUsedToAccountOverriddenLimits < ActiveRecord::Migration[8.2] + def change + add_column :account_overridden_limits, :bytes_used, :bigint + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 59c78f4cb..7e9b80b92 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do +ActiveRecord::Schema[8.2].define(version: 2025_12_16_000000) do create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false @@ -20,6 +20,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do create_table "account_overridden_limits", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false + t.bigint "bytes_used" t.integer "card_count" t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev index 2033385ff..5f4820cc7 100755 --- a/saas/exe/stripe-dev +++ b/saas/exe/stripe-dev @@ -30,13 +30,15 @@ secrets_escaped = `kamal secrets fetch \ --account basecamp \ --from "Deploy/Fizzy" \ "Development/STRIPE_SECRET_KEY" \ - "Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null` + "Development/STRIPE_MONTHLY_V1_PRICE_ID" \ + "Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID" 2>/dev/null` secrets_json = secrets_escaped.gsub(/\\(.)/, '\1') secrets = JSON.parse(secrets_json) stripe_secret_key = secrets["Deploy/Fizzy/Development/STRIPE_SECRET_KEY"] stripe_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_V1_PRICE_ID"] +stripe_extra_storage_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] # Clear previous log file File.write(LOG_FILE, "") @@ -72,6 +74,7 @@ end # Output export statements puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}") puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}") +puts %Q(export STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID="#{stripe_extra_storage_price_id}") puts %Q(export STRIPE_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret # Informational message to stderr (won't be eval'd) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 532f0a384..757de6beb 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -10,7 +10,7 @@ module Fizzy Queenbee.host_app = Fizzy initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app| - app.config.x.content_security_policy.form_action = "https://checkout.stripe.com" + app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com" end initializer "fizzy_saas.assets" do |app| @@ -22,7 +22,12 @@ module Fizzy app.routes.prepend do namespace :account do resource :billing_portal, only: :show - resource :subscription + resource :subscription do + scope module: :subscriptions do + resource :upgrade, only: :create + resource :downgrade, only: :create + end + end end namespace :stripe do diff --git a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb new file mode 100644 index 000000000..dd5e72826 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :mike + end + + # Nearing limits - shown in card creation footer + + test "admin sees nearing card limit notice" do + accounts(:initech).update_column(:cards_count, 950) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /upgrade to unlimited/i, response.body + end + + test "admin sees nearing storage limit notice" do + Account.any_instance.stubs(:bytes_used).returns(600.megabytes) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /upgrade to get more/i, response.body + end + + # Exceeding limits - shown instead of create buttons + + test "admin sees exceeding card limit notice" do + accounts(:initech).update_column(:cards_count, 1001) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /you’ve used your.*free cards/i, response.body + end + + test "admin sees exceeding storage limit notice" do + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /you’ve run out of.*free storage/i, response.body + end + + # Paid accounts under limits - no notices + + test "paid account under limits sees no notices" do + logout_and_sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get card_path(cards(:layout), script_name: accounts(:"37s").slug) + + assert_response :success + assert_no_match /upgrade/i, response.body + assert_no_match /you’ve used your/i, response.body + end + + # Comped accounts under limits - no notices + + test "comped account under limits sees no notices" do + accounts(:initech).comp + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_no_match /upgrade/i, response.body + assert_no_match /you’ve used your/i, response.body + end +end diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb new file mode 100644 index 000000000..e4680a1f8 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb @@ -0,0 +1,35 @@ +require "test_helper" +require "ostruct" + +class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid_with_extra_storage) + end + + test "downgrade redirects to stripe billing portal" do + stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ])) + portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123") + + Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription) + Stripe::BillingPortal::Session.stubs(:create).returns(portal_session) + + post account_subscription_downgrade_path + + assert_redirected_to "https://billing.stripe.com/session/abc123" + end + + test "downgrade requires admin" do + logout_and_sign_in_as :david + + post account_subscription_downgrade_path + assert_response :forbidden + end + + test "downgrade requires downgradeable plan" do + accounts(:"37s").subscription.update!(plan: Plan.paid) + + post account_subscription_downgrade_path + assert_response :bad_request + end +end diff --git a/saas/test/controllers/accounts/subscriptions/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb new file mode 100644 index 000000000..63feac2c5 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/settings_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest + test "free users see current usage" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 3) + + get account_settings_path(script_name: accounts(:initech).slug) + + assert_response :success + assert_match /You’ve used.*3.*free cards out of 1,000/i, response.body + end + + test "paid users see thank you message" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "h3", text: "Thank you for buying Fizzy" + end + + test "regular plan users see upgrade option" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "button", text: /upgrade/i + assert_select "button", text: /downgrade/i, count: 0 + end + + test "extra storage plan users see downgrade option" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "button", text: /downgrade/i + end + + test "comped accounts see no subscription panel" do + sign_in_as :mike + + accounts(:initech).comp + + get account_settings_path(script_name: accounts(:initech).slug) + + assert_response :success + assert_no_match /thank you for buying/i, response.body + assert_no_match /free cards out of/i, response.body + assert_no_match /upgrade/i, response.body + assert_no_match /downgrade/i, response.body + end +end diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb new file mode 100644 index 000000000..65e268af1 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb @@ -0,0 +1,35 @@ +require "test_helper" +require "ostruct" + +class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid) + end + + test "upgrade redirects to stripe billing portal" do + stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ])) + portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123") + + Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription) + Stripe::BillingPortal::Session.stubs(:create).returns(portal_session) + + post account_subscription_upgrade_path + + assert_redirected_to "https://billing.stripe.com/session/abc123" + end + + test "upgrade requires admin" do + logout_and_sign_in_as :david + + post account_subscription_upgrade_path + assert_response :forbidden + end + + test "upgrade requires upgradeable plan" do + accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage) + + post account_subscription_upgrade_path + assert_response :bad_request + end +end diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb index d98ac1d91..9e086df39 100644 --- a/saas/test/controllers/accounts/subscriptions_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -43,4 +43,18 @@ class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest post account_subscription_path assert_response :forbidden end + + test "create with custom plan_key redirects to stripe checkout" do + customer = OpenStruct.new(id: "cus_test_37signals") + session = OpenStruct.new(url: "https://checkout.stripe.com/session123") + + Stripe::Customer.stubs(:retrieve).returns(customer) + Stripe::Checkout::Session.stubs(:create).with do |params| + params[:metadata][:plan_key] == :monthly_extra_storage_v1 + end.returns(session) + + post account_subscription_path(plan_key: :monthly_extra_storage_v1) + + assert_redirected_to "https://checkout.stripe.com/session123" + end end diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb index 0b830919a..3849c07a5 100644 --- a/saas/test/controllers/admin/accounts_controller_test.rb +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -34,7 +34,7 @@ class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest sign_in_as :david untenanted do - patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { overridden_card_count: 500 } } + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { card_count: 500 } } assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) end diff --git a/saas/test/controllers/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb index b3477735c..4105091be 100644 --- a/saas/test/controllers/card/limited_creation_test.rb +++ b/saas/test/controllers/card/limited_creation_test.rb @@ -40,4 +40,16 @@ class Card::LimitedCreationTest < ActionDispatch::IntegrationTest assert_response :redirect assert Card.last.drafted? end + + test "cannot create cards via JSON when storage limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug, format: :json) + end + + assert_response :forbidden + end end diff --git a/saas/test/controllers/card/limited_publishing_test.rb b/saas/test/controllers/card/limited_publishing_test.rb index 2eb57471e..90e4e5696 100644 --- a/saas/test/controllers/card/limited_publishing_test.rb +++ b/saas/test/controllers/card/limited_publishing_test.rb @@ -11,4 +11,15 @@ class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest assert_response :forbidden assert cards(:unfinished_thoughts).reload.drafted? end + + test "cannot publish cards when storage limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :forbidden + assert cards(:unfinished_thoughts).reload.drafted? + end end diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb index 335d6a37c..828386023 100644 --- a/saas/test/models/account/limited_test.rb +++ b/saas/test/models/account/limited_test.rb @@ -73,4 +73,45 @@ class Account::LimitedTest < ActiveSupport::TestCase assert account.exceeding_card_limit? end + + test "detect nearing storage limit" do + # Paid plans have large storage limits + accounts(:"37s").stubs(:bytes_used).returns(4.gigabytes) + assert_not accounts(:"37s").nearing_plan_storage_limit? + + # Free plan not near limit + accounts(:initech).stubs(:bytes_used).returns(400.megabytes) + assert_not accounts(:initech).nearing_plan_storage_limit? + + # Free plan near limit + accounts(:initech).stubs(:bytes_used).returns(600.megabytes) + assert accounts(:initech).nearing_plan_storage_limit? + end + + test "detect exceeding storage limit" do + # Free plan under limit + accounts(:initech).stubs(:bytes_used).returns(900.megabytes) + assert_not accounts(:initech).exceeding_storage_limit? + + # Free plan over limit + accounts(:initech).stubs(:bytes_used).returns(1.1.gigabytes) + assert accounts(:initech).exceeding_storage_limit? + end + + test "override bytes_used limits" do + account = accounts(:initech) + account.stubs(:bytes_used).returns(1.1.gigabytes) + + assert account.exceeding_storage_limit? + assert_equal 1.1.gigabytes, account.billed_bytes_used + + account.override_limits bytes_used: 500.megabytes + assert_not account.exceeding_storage_limit? + assert_equal 500.megabytes, account.billed_bytes_used + assert_equal 1.1.gigabytes, account.bytes_used # original unchanged + + account.reset_overridden_limits + assert account.exceeding_storage_limit? + assert_equal 1.1.gigabytes, account.billed_bytes_used + end end diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb index 6e0d70b5c..54ed279d6 100644 --- a/saas/test/models/plan_test.rb +++ b/saas/test/models/plan_test.rb @@ -8,4 +8,11 @@ class PlanTest < ActiveSupport::TestCase test "monthly plan is not free" do assert_not Plan[:monthly_v1].free? end + + test "find plan by its price id" do + Plan.paid.stubs(:stripe_price_id).returns("price_monthly_v1") + + assert_equal Plan.paid, Plan.find_by_price_id("price_monthly_v1") + assert_nil Plan.find_by_price_id("unknown_price_id") + end end diff --git a/script/create-identities.rb b/script/create-identities.rb index 2411f1b0c..ee7ddfb1c 100644 --- a/script/create-identities.rb +++ b/script/create-identities.rb @@ -11,7 +11,7 @@ ApplicationRecord.with_each_tenant do |tenant| # Use IdentityProvider to link the user's email to this tenant # This will find_or_create the identity and link it to the tenant - identity = IdentityProvider.link(email_address: user.email_address, to: tenant) + IdentityProvider.link(email_address: user.email_address, to: tenant) puts " ✅ Linked identity for user #{user.id} (#{user.email_address}) to tenant '#{tenant}'" end diff --git a/script/migrations/split-sibling-paragraphs-with-p-br.rb b/script/migrations/split-sibling-paragraphs-with-p-br.rb new file mode 100644 index 000000000..d0844a540 --- /dev/null +++ b/script/migrations/split-sibling-paragraphs-with-p-br.rb @@ -0,0 +1,120 @@ +#!/usr/bin/env ruby + +BACKFILL_TIMESTAMP = Time.parse("2025-12-19 00:07:00 UTC") +ACCOUNT_ID = nil # restrict to an account_id + +# Split sibling

    tags with content by inserting

    to replicaate previous view. +# Run for the time range before paragraphs were not spaced +# See https://app.fizzy.do/5986089/cards/3472 +# and https://github.com/basecamp/fizzy/pull/2107 +# +# MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption +# +# Run locally: +# bin/rails runner script/migrations/split-sibling-paragraphs-with-p-br.rb +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/split-sibling-paragraphs-with-p-br.rb" +# +# Safe to re-run for a time range: won't re-detect unsplit paragraphs and updated_at will be outside time window +class SeparateSiblingParagraphs + attr_reader :updated_at, :account_id + + def initialize(updated_at, account_id: nil) + @updated_at = updated_at + @account_id = account_id + end + + def run + puts "Separating non-blank sibling paragraphs" + + puts "Updated at: #{updated_at}" + puts account_id ? "Only account id: #{account_id}" : "For **ALL ACCOUNTS**" + + puts "\nPress ENTER to continue running or CTRL-C to bail..." + gets + + puts "\nRunning..." + + # Suppress SQL logs + Rails.event.debug_mode = false + + seconds = Benchmark.realtime do + suppressing_turbo_broadcasts do + separate_nonblank_paragraphs + end + end + + puts "\n\n" + puts "Finished splitting non-blank

    s in %.2f seconds." % seconds + end + + private + def suppressing_turbo_broadcasts + Board.suppressing_turbo_broadcasts do + Card.suppressing_turbo_broadcasts do + yield + end + end + end + + def separate_nonblank_paragraphs + scanned = 0 + fixed = 0 + insertions = 0 + + action_texts_scope.find_each(**batch_options) do |rich_text| + next if account_id && rich_text.record.account.external_account_id != account_id + + scanned += 1 + edited = false + + rich_text.body&.fragment.tap do |fragment| + next unless fragment + + fragment.find_all("p + p").each do |node| + unless empty_node?(node) || empty_node?(node.previous_sibling) + node.add_previous_sibling empty_node_markup + edited = true + insertions += 1 + end + end + + if edited + puts " - modifying #{rich_text.record.class.name} #{rich_text.record.to_param} (account: #{rich_text.record.account.external_account_id})" unless demo_card?(rich_text.record) + # allow implicit touching to invalidate caches + rich_text.update! body: fragment.to_html + fixed +=1 + end + end + end + + puts "\n\Separation complete!" + puts " Rich texts examined: #{scanned}" + puts " Rich texts modified: #{fixed}" + puts " Paragraphs inserted: #{insertions}" + fixed + end + + def action_texts_scope + ActionText::RichText.where(updated_at: updated_at) + end + + def batch_options + { batch_size: 20, order: :desc } + end + + def empty_node?(node) + node.to_html == empty_node_markup + end + + def empty_node_markup + "


    " + end + + def demo_card?(record) + record.is_a?(Card) && record.number <= 8 + end +end + +SeparateSiblingParagraphs.new(..BACKFILL_TIMESTAMP, account_id: ACCOUNT_ID).run diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index a48511cf8..f6bf18ca6 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -6,6 +6,22 @@ class ApiTest < ActionDispatch::IntegrationTest @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) end + test "authenticate with user credentials" do + identity = identities(:david) + + untenanted do + post session_path(format: :json), params: { email_address: identity.email_address } + assert_response :created + pending_token = @response.parsed_body["pending_authentication_token"] + assert pending_token.present? + + magic_link = MagicLink.last + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + assert_response :success + assert @response.parsed_body["session_token"].present? + end + end + test "authenticate with valid access token" do get boards_path(format: :json), env: @davids_bearer_token assert_response :success diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 7669881cb..094cca56b 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -15,6 +15,16 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "invalidates page title cache when account updates" do + get board_path(boards(:writebook)) + etag = response.headers["ETag"] + + accounts("37s").update!(name: "Renamed Account") + + get board_path(boards(:writebook)), headers: { "If-None-Match" => etag } + assert_response :success + end + test "create" do assert_difference -> { Board.count }, +1 do post boards_path, params: { board: { name: "Remodel Punch List" } } diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index c6c56fc83..d13affa96 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -50,15 +50,12 @@ class CardsControllerTest < ActionDispatch::IntegrationTest card: { title: "Logo needs to change", image: fixture_file_upload("moon.jpg", "image/jpeg"), - description: "Something more in-depth", - tag_ids: [ tags(:mobile).id ] } } + description: "Something more in-depth" } } assert_response :success card = cards(:logo).reload assert_equal "Logo needs to change", card.title assert_equal "moon.jpg", card.image.filename.to_s - assert_equal [ tags(:mobile) ], card.tags - assert_equal "Something more in-depth", card.description.to_plain_text.strip end @@ -152,7 +149,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest test "create as JSON" do assert_difference -> { Card.count }, +1 do post board_cards_path(boards(:writebook)), - params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } }, + params: { card: { title: "My new card", description: "Big if true" } }, as: :json assert_response :created end @@ -162,7 +159,6 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_equal "My new card", card.title assert_equal "Big if true", card.description.to_plain_text - assert_equal [ tags(:mobile), tags(:web) ].sort, card.tags.sort end test "create as JSON with custom created_at" do diff --git a/test/controllers/my/menus_controller_test.rb b/test/controllers/my/menus_controller_test.rb new file mode 100644 index 000000000..bbd560764 --- /dev/null +++ b/test/controllers/my/menus_controller_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +class My::MenusControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + @user = users(:kevin) + @account = accounts("37s") + end + + test "show" do + get my_menu_path + assert_response :success + end + + test "etag invalidates when filters change" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @user.filters.create!( + params_digest: Filter.digest_params({ indexed_by: :all, sorted_by: :newest }), + fields: { indexed_by: :all, sorted_by: :newest } + ) + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag invalidates when boards change" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @account.boards.create!(name: "New Board", all_access: true, creator: @user) + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag invalidates when tags change" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @account.tags.create!(title: "new-tag") + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag invalidates when users change" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @user.touch + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag invalidates when account changes" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @account.update!(name: "Renamed Account") + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag returns not modified when nothing changes" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :not_modified + end +end diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb index 5a4a48e51..83b228416 100644 --- a/test/controllers/sessions/magic_links_controller_test.rb +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -79,4 +79,66 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest assert_response :redirect, "Expired magic link should redirect" assert MagicLink.exists?(expired_link.id), "Expired magic link should not be consumed" end + + test "create via JSON" do + identity = identities(:david) + magic_link = identity.send_magic_link + + untenanted do + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: magic_link.code } + assert_response :success + assert @response.parsed_body["session_token"].present? + end + end + + test "create via JSON without pending_authentication_token" do + identity = identities(:david) + magic_link = identity.send_magic_link + + untenanted do + post session_magic_link_path(format: :json), params: { code: magic_link.code } + assert_response :unauthorized + assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] + end + end + + test "create via JSON with invalid code" do + identity = identities(:david) + + untenanted do + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: "INVALID" } + assert_response :unauthorized + assert_equal "Try another code.", @response.parsed_body["message"] + end + end + + test "create via JSON with cross-user code" do + identity = identities(:david) + other_identity = identities(:jason) + magic_link = other_identity.send_magic_link + + untenanted do + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: magic_link.code } + assert_response :unauthorized + assert_equal "Something went wrong. Please try again.", @response.parsed_body["message"] + end + end + + test "create via JSON with expired pending_authentication_token" do + identity = identities(:david) + magic_link = identity.send_magic_link + + untenanted do + travel_to 20.minutes.ago do + post session_path(format: :json), params: { email_address: identity.email_address } + end + + post session_magic_link_path(format: :json), params: { code: magic_link.code } + assert_response :unauthorized + assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] + end + end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 74b336a4f..1a0ddb061 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -70,7 +70,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest post session_path, params: { email_address: "not-a-valid-email" } end - assert_response :unprocessable_entity + assert_response :redirect + assert_redirected_to new_session_path end end end @@ -85,4 +86,35 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_not cookies[:session_token].present? end end + + test "create via JSON" do + untenanted do + post session_path(format: :json), params: { email_address: identities(:david).email_address } + assert_response :created + end + end + + test "create for a new user via JSON" do + new_email = "new-user-#{SecureRandom.hex(6)}@example.com" + + untenanted do + assert_difference -> { Identity.count }, 1 do + assert_difference -> { MagicLink.count }, 1 do + post session_path(format: :json), params: { email_address: new_email } + end + end + assert_response :created + assert @response.parsed_body["pending_authentication_token"].present? + assert MagicLink.last.for_sign_up? + end + end + + test "create with invalid email address via JSON" do + untenanted do + assert_no_difference -> { Identity.count } do + post session_path(format: :json), params: { email_address: "not-a-valid-email" } + end + assert_response :unprocessable_entity + end + end end diff --git a/test/models/assignment_test.rb b/test/models/assignment_test.rb index ca8f09a34..3ac309619 100644 --- a/test/models/assignment_test.rb +++ b/test/models/assignment_test.rb @@ -1,7 +1,38 @@ require "test_helper" class AssignmentTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "create" do + card = cards(:text) + assignment = card.assignments.create!(assignee: users(:david), assigner: users(:jason)) + + assert_equal users(:david), assignment.assignee + assert_equal users(:jason), assignment.assigner + assert_equal card, assignment.card + end + + test "create cannot exceed assignee limit" do + card = cards(:logo) + board = card.board + account = card.account + + card.assignments.delete_all + + Assignment::LIMIT.times do |i| + identity = Identity.create!(email_address: "limit_test_#{i}@example.com") + user = account.users.create!(identity: identity, name: "Limit Test User #{i}", role: :member) + user.accesses.find_or_create_by!(board: board) + card.assignments.create!(assignee: user, assigner: users(:david)) + end + + assert_equal Assignment::LIMIT, card.assignments.count + + identity = Identity.create!(email_address: "over_limit@example.com") + extra_user = account.users.create!(identity: identity, name: "Over Limit User", role: :member) + extra_user.accesses.find_or_create_by!(board: board) + + assignment = card.assignments.build(assignee: extra_user, assigner: users(:david)) + + assert_not assignment.valid? + assert_includes assignment.errors[:base], "Card already has the maximum of #{Assignment::LIMIT} assignees" + end end diff --git a/test/models/card/assignable_test.rb b/test/models/card/assignable_test.rb index 9d0c1f1c5..6ee8a164d 100644 --- a/test/models/card/assignable_test.rb +++ b/test/models/card/assignable_test.rb @@ -12,4 +12,31 @@ class Card::AssignableTest < ActiveSupport::TestCase assert cards(:layout).assigned_to?(users(:kevin)) assert cards(:layout).watched_by?(users(:kevin)) end + + test "toggle_assignment does not add assignee when at limit" do + card = cards(:logo) + board = card.board + account = card.account + + card.assignments.delete_all + + Assignment::LIMIT.times do |i| + identity = Identity.create!(email_address: "toggle_test_#{i}@example.com") + user = account.users.create!(identity: identity, name: "Toggle Test User #{i}", role: :member) + user.accesses.find_or_create_by!(board: board) + card.assignments.create!(assignee: user, assigner: users(:david)) + end + + identity = Identity.create!(email_address: "toggle_over@example.com") + extra_user = account.users.create!(identity: identity, name: "Toggle Over User", role: :member) + extra_user.accesses.find_or_create_by!(board: board) + + with_current_user(:david) do + assert_no_difference "card.assignments.count" do + card.toggle_assignment(extra_user) + end + end + + assert_not card.reload.assigned_to?(extra_user) + end end diff --git a/test/models/card/statuses_test.rb b/test/models/card/statuses_test.rb index 2f9ba95ca..ad8a2a708 100644 --- a/test/models/card/statuses_test.rb +++ b/test/models/card/statuses_test.rb @@ -11,22 +11,6 @@ class Card::StatusesTest < ActiveSupport::TestCase assert card.drafted? end - test "cards are only visible to the creator when drafted" do - card = boards(:writebook).cards.create! creator: users(:kevin), title: "Drafted Card" - card.drafted! - - assert_includes Card.published_or_drafted_by(users(:kevin)), card - assert_not_includes Card.published_or_drafted_by(users(:jz)), card - end - - test "cards are visible to everyone when published" do - card = boards(:writebook).cards.create! creator: users(:kevin), title: "Published Card" - card.published! - - assert_includes Card.published_or_drafted_by(users(:kevin)), card - assert_includes Card.published_or_drafted_by(users(:jz)), card - end - test "an event is created when a card is created in the published state" do assert_no_difference(-> { Event.count }) do boards(:writebook).cards.create! creator: users(:kevin), title: "Draft Card" diff --git a/test/test_helper.rb b/test/test_helper.rb index 12ab491ed..14893b030 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -154,8 +154,7 @@ module FixturesTestHelper # Format as UUID string and convert to base36 (25 chars) uuid = "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x" % bytes - hex = uuid.delete("-") - hex.to_i(16).to_s(36).rjust(25, "0") + ActiveRecord::Type::Uuid.hex_to_base36(uuid.delete("-")) end end end diff --git a/vendor/javascript/@rails--request.js b/vendor/javascript/@rails--request.js new file mode 100644 index 000000000..1aa2eaee6 --- /dev/null +++ b/vendor/javascript/@rails--request.js @@ -0,0 +1,4 @@ +// @rails/request.js@0.0.13 downloaded from https://ga.jspm.io/npm:@rails/request.js@0.0.13/src/index.js + +class FetchResponse{constructor(t){this.response=t}get statusCode(){return this.response.status}get redirected(){return this.response.redirected}get ok(){return this.response.ok}get unauthenticated(){return this.statusCode===401}get unprocessableEntity(){return this.statusCode===422}get authenticationURL(){return this.response.headers.get("WWW-Authenticate")}get contentType(){const t=this.response.headers.get("Content-Type")||"";return t.replace(/;.*$/,"")}get headers(){return this.response.headers}get html(){return this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)?this.text:Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`))}get json(){return this.contentType.match(/^application\/.*json$/)?this.responseJson||(this.responseJson=this.response.json()):Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`))}get text(){return this.responseText||(this.responseText=this.response.text())}get isTurboStream(){return this.contentType.match(/^text\/vnd\.turbo-stream\.html/)}get isScript(){return this.contentType.match(/\b(?:java|ecma)script\b/)}async renderTurboStream(){if(!this.isTurboStream)return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));window.Turbo?await window.Turbo.renderStreamMessage(await this.text):console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js")}async activeScript(){if(!this.isScript)return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));{const t=document.createElement("script");const e=document.querySelector("meta[name=csp-nonce]");if(e){const n=e.nonce===""?e.content:e.nonce;n&&t.setAttribute("nonce",n)}t.innerHTML=await this.text;document.body.appendChild(t)}}}class RequestInterceptor{static register(t){this.interceptor=t}static get(){return this.interceptor}static reset(){this.interceptor=void 0}}function t(t){const e=document.cookie?document.cookie.split("; "):[];const n=`${encodeURIComponent(t)}=`;const s=e.find((t=>t.startsWith(n)));if(s){const t=s.split("=").slice(1).join("=");if(t)return decodeURIComponent(t)}}function e(t){const e={};for(const n in t){const s=t[n];s!==void 0&&(e[n]=s)}return e}function n(t){const e=document.head.querySelector(`meta[name="${t}"]`);return e&&e.content}function s(t){return[...t].reduce(((t,[e,n])=>t.concat(typeof n==="string"?[[e,n]]:[])),[])}function r(t,e){for(const[n,s]of e)if(!(s instanceof window.File))if(t.has(n)&&!n.includes("[]")){t.delete(n);t.set(n,s)}else t.append(n,s)}class FetchRequest{constructor(t,e,n={}){this.method=t;this.options=n;this.originalUrl=e.toString()}async perform(){try{const t=RequestInterceptor.get();t&&await t(this)}catch(t){console.error(t)}const t=window.Turbo?window.Turbo.fetch:window.fetch;const e=new FetchResponse(await t(this.url,this.fetchOptions));if(e.unauthenticated&&e.authenticationURL)return Promise.reject(window.location.href=e.authenticationURL);e.isScript&&await e.activeScript();const n=e.ok||e.unprocessableEntity;n&&e.isTurboStream&&await e.renderTurboStream();return e}addHeader(t,e){const n=this.additionalHeaders;n[t]=e;this.options.headers=n}sameHostname(){if(!this.originalUrl.startsWith("http:")&&!this.originalUrl.startsWith("https:"))return true;try{return new URL(this.originalUrl).hostname===window.location.hostname}catch(t){return true}}get fetchOptions(){return{method:this.method.toUpperCase(),headers:this.headers,body:this.formattedBody,signal:this.signal,credentials:this.credentials,redirect:this.redirect,keepalive:this.keepalive}}get headers(){const t={"X-Requested-With":"XMLHttpRequest","Content-Type":this.contentType,Accept:this.accept};this.sameHostname()&&(t["X-CSRF-Token"]=this.csrfToken);return e(Object.assign(t,this.additionalHeaders))}get csrfToken(){return t(n("csrf-param"))||n("csrf-token")}get contentType(){return this.options.contentType?this.options.contentType:this.body==null||this.body instanceof window.FormData?void 0:this.body instanceof window.File?this.body.type:"application/json"}get accept(){switch(this.responseKind){case"html":return"text/html, application/xhtml+xml";case"turbo-stream":return"text/vnd.turbo-stream.html, text/html, application/xhtml+xml";case"json":return"application/json, application/vnd.api+json";case"script":return"text/javascript, application/javascript";default:return"*/*"}}get body(){return this.options.body}get query(){const t=(this.originalUrl.split("?")[1]||"").split("#")[0];const e=new URLSearchParams(t);let n=this.options.query;n=n instanceof window.FormData?s(n):n instanceof window.URLSearchParams?n.entries():Object.entries(n||{});r(e,n);const o=e.toString();return o.length>0?`?${o}`:""}get url(){return this.originalUrl.split("?")[0].split("#")[0]+this.query}get responseKind(){return this.options.responseKind||"html"}get signal(){return this.options.signal}get redirect(){return this.options.redirect||"follow"}get credentials(){return this.options.credentials||"same-origin"}get keepalive(){return this.options.keepalive||false}get additionalHeaders(){return this.options.headers||{}}get formattedBody(){const t=Object.prototype.toString.call(this.body)==="[object String]";const e=this.headers["Content-Type"]==="application/json";return e&&!t?JSON.stringify(this.body):this.body}}async function o(t,e){const n=new FetchRequest("get",t,e);return n.perform()}async function i(t,e){const n=new FetchRequest("post",t,e);return n.perform()}async function c(t,e){const n=new FetchRequest("put",t,e);return n.perform()}async function a(t,e){const n=new FetchRequest("patch",t,e);return n.perform()}async function h(t,e){const n=new FetchRequest("delete",t,e);return n.perform()}export{FetchRequest,FetchResponse,RequestInterceptor,h as destroy,o as get,a as patch,i as post,c as put}; + diff --git a/vendor/javascript/rails-request.js b/vendor/javascript/rails-request.js deleted file mode 100644 index 4b8d648ce..000000000 --- a/vendor/javascript/rails-request.js +++ /dev/null @@ -1,308 +0,0 @@ -// Patched to use Turbo.fetch. @TODO Upstream this. -class FetchResponse { - constructor(response) { - this.response = response; - } - get statusCode() { - return this.response.status; - } - get redirected() { - return this.response.redirected; - } - get ok() { - return this.response.ok; - } - get unauthenticated() { - return this.statusCode === 401; - } - get unprocessableEntity() { - return this.statusCode === 422; - } - get authenticationURL() { - return this.response.headers.get("WWW-Authenticate"); - } - get contentType() { - const contentType = this.response.headers.get("Content-Type") || ""; - return contentType.replace(/;.*$/, ""); - } - get headers() { - return this.response.headers; - } - get html() { - if (this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)) { - return this.text; - } - return Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`)); - } - get json() { - if (this.contentType.match(/^application\/.*json$/)) { - return this.responseJson || (this.responseJson = this.response.json()); - } - return Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`)); - } - get text() { - return this.responseText || (this.responseText = this.response.text()); - } - get isTurboStream() { - return this.contentType.match(/^text\/vnd\.turbo-stream\.html/); - } - get isScript() { - return this.contentType.match(/\b(?:java|ecma)script\b/); - } - async renderTurboStream() { - if (this.isTurboStream) { - if (window.Turbo) { - await window.Turbo.renderStreamMessage(await this.text); - } else { - console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js"); - } - } else { - return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`)); - } - } - async activeScript() { - if (this.isScript) { - const script = document.createElement("script"); - const metaTag = document.querySelector("meta[name=csp-nonce]"); - if (metaTag) { - const nonce = metaTag.nonce === "" ? metaTag.content : metaTag.nonce; - if (nonce) { - script.setAttribute("nonce", nonce); - } - } - script.innerHTML = await this.text; - document.body.appendChild(script); - } else { - return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`)); - } - } -} - -class RequestInterceptor { - static register(interceptor) { - this.interceptor = interceptor; - } - static get() { - return this.interceptor; - } - static reset() { - this.interceptor = undefined; - } -} - -function getCookie(name) { - const cookies = document.cookie ? document.cookie.split("; ") : []; - const prefix = `${encodeURIComponent(name)}=`; - const cookie = cookies.find((cookie => cookie.startsWith(prefix))); - if (cookie) { - const value = cookie.split("=").slice(1).join("="); - if (value) { - return decodeURIComponent(value); - } - } -} - -function compact(object) { - const result = {}; - for (const key in object) { - const value = object[key]; - if (value !== undefined) { - result[key] = value; - } - } - return result; -} - -function metaContent(name) { - const element = document.head.querySelector(`meta[name="${name}"]`); - return element && element.content; -} - -function stringEntriesFromFormData(formData) { - return [ ...formData ].reduce(((entries, [name, value]) => entries.concat(typeof value === "string" ? [ [ name, value ] ] : [])), []); -} - -function mergeEntries(searchParams, entries) { - for (const [name, value] of entries) { - if (value instanceof window.File) continue; - if (searchParams.has(name) && !name.includes("[]")) { - searchParams.delete(name); - searchParams.set(name, value); - } else { - searchParams.append(name, value); - } - } -} - -class FetchRequest { - constructor(method, url, options = {}) { - this.method = method; - this.options = options; - this.originalUrl = url.toString(); - } - async perform() { - try { - const requestInterceptor = RequestInterceptor.get(); - if (requestInterceptor) { - await requestInterceptor(this); - } - } catch (error) { - console.error(error); - } - const fetch = this.responseKind === "turbo-stream" && window.Turbo ? window.Turbo.fetch : window.fetch; - const response = new FetchResponse(await Turbo.fetch(this.url, this.fetchOptions)); - if (response.unauthenticated && response.authenticationURL) { - return Promise.reject(window.location.href = response.authenticationURL); - } - if (response.isScript) { - await response.activeScript(); - } - const responseStatusIsTurboStreamable = response.ok || response.unprocessableEntity; - if (responseStatusIsTurboStreamable && response.isTurboStream) { - await response.renderTurboStream(); - } - return response; - } - addHeader(key, value) { - const headers = this.additionalHeaders; - headers[key] = value; - this.options.headers = headers; - } - sameHostname() { - if (!this.originalUrl.startsWith("http:") && !this.originalUrl.startsWith("https:")) { - return true; - } - try { - return new URL(this.originalUrl).hostname === window.location.hostname; - } catch (_) { - return true; - } - } - get fetchOptions() { - return { - method: this.method.toUpperCase(), - headers: this.headers, - body: this.formattedBody, - signal: this.signal, - credentials: this.credentials, - redirect: this.redirect, - keepalive: this.keepalive - }; - } - get headers() { - const baseHeaders = { - "X-Requested-With": "XMLHttpRequest", - "Content-Type": this.contentType, - Accept: this.accept - }; - if (this.sameHostname()) { - baseHeaders["X-CSRF-Token"] = this.csrfToken; - } - return compact(Object.assign(baseHeaders, this.additionalHeaders)); - } - get csrfToken() { - return getCookie(metaContent("csrf-param")) || metaContent("csrf-token"); - } - get contentType() { - if (this.options.contentType) { - return this.options.contentType; - } else if (this.body == null || this.body instanceof window.FormData) { - return undefined; - } else if (this.body instanceof window.File) { - return this.body.type; - } - return "application/json"; - } - get accept() { - switch (this.responseKind) { - case "html": - return "text/html, application/xhtml+xml"; - - case "turbo-stream": - return "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"; - - case "json": - return "application/json, application/vnd.api+json"; - - case "script": - return "text/javascript, application/javascript"; - - default: - return "*/*"; - } - } - get body() { - return this.options.body; - } - get query() { - const originalQuery = (this.originalUrl.split("?")[1] || "").split("#")[0]; - const params = new URLSearchParams(originalQuery); - let requestQuery = this.options.query; - if (requestQuery instanceof window.FormData) { - requestQuery = stringEntriesFromFormData(requestQuery); - } else if (requestQuery instanceof window.URLSearchParams) { - requestQuery = requestQuery.entries(); - } else { - requestQuery = Object.entries(requestQuery || {}); - } - mergeEntries(params, requestQuery); - const query = params.toString(); - return query.length > 0 ? `?${query}` : ""; - } - get url() { - return this.originalUrl.split("?")[0].split("#")[0] + this.query; - } - get responseKind() { - return this.options.responseKind || "html"; - } - get signal() { - return this.options.signal; - } - get redirect() { - return this.options.redirect || "follow"; - } - get credentials() { - return this.options.credentials || "same-origin"; - } - get keepalive() { - return this.options.keepalive || false; - } - get additionalHeaders() { - return this.options.headers || {}; - } - get formattedBody() { - const bodyIsAString = Object.prototype.toString.call(this.body) === "[object String]"; - const contentTypeIsJson = this.headers["Content-Type"] === "application/json"; - if (contentTypeIsJson && !bodyIsAString) { - return JSON.stringify(this.body); - } - return this.body; - } -} - -async function get(url, options) { - const request = new FetchRequest("get", url, options); - return request.perform(); -} - -async function post(url, options) { - const request = new FetchRequest("post", url, options); - return request.perform(); -} - -async function put(url, options) { - const request = new FetchRequest("put", url, options); - return request.perform(); -} - -async function patch(url, options) { - const request = new FetchRequest("patch", url, options); - return request.perform(); -} - -async function destroy(url, options) { - const request = new FetchRequest("delete", url, options); - return request.perform(); -} - -export { FetchRequest, FetchResponse, RequestInterceptor, destroy, get, patch, post, put };