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
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.
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
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 };