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/.rubocop.yml b/.rubocop.yml index 16773cd4c..c0d945b70 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,3 +11,5 @@ AllCops: Exclude: - 'db/migrate/**/*' - 'db/schema*.rb' + - 'saas/db/migrate/**/*' + - 'saas/db/saas_schema.rb' diff --git a/Gemfile.lock b/Gemfile.lock index 5a1eeb3cb..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) @@ -435,10 +439,11 @@ GEM railties (>= 6.0.0) stringio (3.1.9) thor (1.4.0) - thruster (0.1.16) - thruster (0.1.16-aarch64-linux) - thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-linux) + 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) tsort (0.2.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 b/Gemfile.saas index 8681a31f6..05e6bbabd 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -3,10 +3,10 @@ eval_gemfile "Gemfile" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } -# SaaS-only functionality gem "activeresource", require: "active_resource" +gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" -gem "fizzy-saas", bc: "fizzy-saas" +gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index e4a604416..5ea235fb5 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -19,28 +19,6 @@ GIT rails (>= 7.0) rainbow -GIT - remote: https://github.com/basecamp/fizzy-saas - revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143 - specs: - fizzy-saas (0.1.0) - audits1984 - console1984 - prometheus-client-mmap (~> 1.4.0) - queenbee - rails (>= 8.1.0.beta1) - rails_structured_logging - sentry-rails - sentry-ruby - yabeda - yabeda-actioncable - yabeda-activejob - yabeda-gc - yabeda-http_requests - yabeda-prometheus-mmap - yabeda-puma-plugin - yabeda-rails (>= 0.10) - GIT remote: https://github.com/basecamp/lexxy revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 @@ -181,6 +159,27 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +PATH + remote: saas + specs: + fizzy-saas (0.1.0) + audits1984 + console1984 + prometheus-client-mmap (~> 1.4.0) + queenbee + rails (>= 8.1.0.beta1) + rails_structured_logging + sentry-rails + sentry-ruby + yabeda + yabeda-actioncable + yabeda-activejob + yabeda-gc + yabeda-http_requests + yabeda-prometheus-mmap + yabeda-puma-plugin + yabeda-rails (>= 0.10) + GEM remote: https://rubygems.org/ specs: @@ -201,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) @@ -213,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) @@ -223,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) @@ -347,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) @@ -558,11 +557,12 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.9) + stripe (18.0.1) thor (1.4.0) - thruster (0.1.16) - thruster (0.1.16-aarch64-linux) - thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-linux) + thruster (0.1.17) + thruster (0.1.17-aarch64-linux) + thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-linux) timeout (0.4.4) trilogy (2.9.0) tsort (0.2.0) @@ -684,6 +684,7 @@ DEPENDENCIES solid_queue (~> 1.2) sqlite3 (>= 2.0) stimulus-rails + stripe (~> 18.0) thruster trilogy (~> 2.9) turbo-rails diff --git a/app/assets/images/history.svg b/app/assets/images/history.svg new file mode 100644 index 000000000..20c490c97 --- /dev/null +++ b/app/assets/images/history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/jf-avatar.jpg b/app/assets/images/jf-avatar.jpg new file mode 100644 index 000000000..07f9f6592 Binary files /dev/null and b/app/assets/images/jf-avatar.jpg differ diff --git a/app/assets/images/jf-signature.svg b/app/assets/images/jf-signature.svg new file mode 100644 index 000000000..0e0653d06 --- /dev/null +++ b/app/assets/images/jf-signature.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/web/_global.css b/app/assets/stylesheets/_global.css similarity index 99% rename from app/assets/stylesheets/web/_global.css rename to app/assets/stylesheets/_global.css index a88c2c14c..0fa86deb0 100644 --- a/app/assets/stylesheets/web/_global.css +++ b/app/assets/stylesheets/_global.css @@ -1,4 +1,4 @@ -@layer reset, base, components, modules, utilities, mobile_app, android, ios; +@layer reset, base, components, modules, utilities, native, platform; :root { /* Spacing */ @@ -79,6 +79,7 @@ --z-tooltip: 50; --z-bar: 60; --z-tray: 61; + --z-welcome: 62; /* OKLCH colors: Fixed */ --lch-black: 0% 0 0; diff --git a/app/assets/stylesheets/android.css b/app/assets/stylesheets/android.css new file mode 100644 index 000000000..f30158e8e --- /dev/null +++ b/app/assets/stylesheets/android.css @@ -0,0 +1,7 @@ +@layer platform { + [data-platform~=android] { + .hide-on-android { + display: none; + } + } +} diff --git a/app/assets/stylesheets/android/utilities.css b/app/assets/stylesheets/android/utilities.css deleted file mode 100644 index e0d03f570..000000000 --- a/app/assets/stylesheets/android/utilities.css +++ /dev/null @@ -1,5 +0,0 @@ -@layer android { - .hide-in-android-app { - display: none; - } -} diff --git a/app/assets/stylesheets/web/animation.css b/app/assets/stylesheets/animation.css similarity index 91% rename from app/assets/stylesheets/web/animation.css rename to app/assets/stylesheets/animation.css index d6f05efa9..b38c4ce82 100644 --- a/app/assets/stylesheets/web/animation.css +++ b/app/assets/stylesheets/animation.css @@ -71,6 +71,15 @@ 33% { background-color: var(--color-border-darker); scale: 1; } } + @keyframes wiggle { + 0% { transform: rotate(0deg); } + 20% { transform: rotate(3deg); } + 40% { transform: rotate(-3deg); } + 60% { transform: rotate(3deg); } + 80% { transform: rotate(-3deg); } + 100% { transform: rotate(0deg); } + } + @keyframes wobble { 0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); } 15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; } diff --git a/app/assets/stylesheets/web/autoresize.css b/app/assets/stylesheets/autoresize.css similarity index 100% rename from app/assets/stylesheets/web/autoresize.css rename to app/assets/stylesheets/autoresize.css diff --git a/app/assets/stylesheets/web/avatars.css b/app/assets/stylesheets/avatars.css similarity index 100% rename from app/assets/stylesheets/web/avatars.css rename to app/assets/stylesheets/avatars.css diff --git a/app/assets/stylesheets/web/bar.css b/app/assets/stylesheets/bar.css similarity index 100% rename from app/assets/stylesheets/web/bar.css rename to app/assets/stylesheets/bar.css diff --git a/app/assets/stylesheets/web/base.css b/app/assets/stylesheets/base.css similarity index 100% rename from app/assets/stylesheets/web/base.css rename to app/assets/stylesheets/base.css diff --git a/app/assets/stylesheets/web/bubble.css b/app/assets/stylesheets/bubble.css similarity index 100% rename from app/assets/stylesheets/web/bubble.css rename to app/assets/stylesheets/bubble.css diff --git a/app/assets/stylesheets/web/buttons.css b/app/assets/stylesheets/buttons.css similarity index 100% rename from app/assets/stylesheets/web/buttons.css rename to app/assets/stylesheets/buttons.css diff --git a/app/assets/stylesheets/web/card-columns.css b/app/assets/stylesheets/card-columns.css similarity index 98% rename from app/assets/stylesheets/web/card-columns.css rename to app/assets/stylesheets/card-columns.css index c239e1b9d..aed15d90f 100644 --- a/app/assets/stylesheets/web/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/web/card-perma.css b/app/assets/stylesheets/card-perma.css similarity index 95% rename from app/assets/stylesheets/web/card-perma.css rename to app/assets/stylesheets/card-perma.css index 3132d886f..59a80a32a 100644 --- a/app/assets/stylesheets/web/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -277,7 +277,7 @@ border-radius: 99rem; } - .btn:not(.popup__btn, .btn--plain, .btn--reversed) { + .btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) { --btn-background: var(--card-color); --btn-color: var(--color-ink-inverted); } @@ -299,6 +299,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); } diff --git a/app/assets/stylesheets/web/cards.css b/app/assets/stylesheets/cards.css similarity index 100% rename from app/assets/stylesheets/web/cards.css rename to app/assets/stylesheets/cards.css diff --git a/app/assets/stylesheets/web/circled-text.css b/app/assets/stylesheets/circled-text.css similarity index 100% rename from app/assets/stylesheets/web/circled-text.css rename to app/assets/stylesheets/circled-text.css diff --git a/app/assets/stylesheets/web/color-picker.css b/app/assets/stylesheets/color-picker.css similarity index 100% rename from app/assets/stylesheets/web/color-picker.css rename to app/assets/stylesheets/color-picker.css diff --git a/app/assets/stylesheets/web/comments.css b/app/assets/stylesheets/comments.css similarity index 51% rename from app/assets/stylesheets/web/comments.css rename to app/assets/stylesheets/comments.css index 587749fab..b6a9da6d3 100644 --- a/app/assets/stylesheets/web/comments.css +++ b/app/assets/stylesheets/comments.css @@ -23,6 +23,7 @@ } .comment { + display: flex; margin-inline: auto; max-inline-size: var(--comment-max); position: relative; @@ -34,6 +35,56 @@ .house-md-content { padding: var(--comment-padding-block) 0 0; } + + .comment-by-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-by-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 { @@ -73,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) @@ -85,53 +137,36 @@ background-color: var(--color-ink-lightest); } - .comment--system { - --comment-padding-block: var(--block-space-half); + .comment__history { + background-color: var(--color-ink-lightest); + display: none; + inset: calc(var(--comment-padding-inline) / 2.5) calc(var(--comment-padding-inline) / 1.5) auto auto; margin-inline-end: calc(var(--comment-padding-inline) / -2.5); + position: absolute; + } - max-inline-size: var(--comment-max); - text-align: center; + .comment-by-system { + display: none; + transition: var(--dialog-duration) allow-discrete; + transition-property: display; - &::before { - /* Make up space for lack of avatar */ - content: ""; - display: flex; - inline-size: calc(var(--comment-padding-inline) * 0.75); + .comments--system-expanded & { + display: contents; } + } - .comment__avatar { + /* Show the last system comment */ + :nth-last-child(1 of .comment-by-system) { + display: contents; + + .comment__history { + display: grid; + } + } + + /* Hide the "Show history" button if there's only one system comment */ + :nth-child(1 of .comment-by-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 { - background-image: repeating-linear-gradient( - 45deg in srgb, - var(--color-canvas) 0 1px, - var(--color-ink-lightest) 1px 10px - ); - padding-inline: var(--comment-padding-inline); - } - - .reactions { - display: none !important; - } } } diff --git a/app/assets/stylesheets/web/dialog.css b/app/assets/stylesheets/dialog.css similarity index 100% rename from app/assets/stylesheets/web/dialog.css rename to app/assets/stylesheets/dialog.css diff --git a/app/assets/stylesheets/web/dividers.css b/app/assets/stylesheets/dividers.css similarity index 100% rename from app/assets/stylesheets/web/dividers.css rename to app/assets/stylesheets/dividers.css diff --git a/app/assets/stylesheets/web/drag_and_drop.css b/app/assets/stylesheets/drag_and_drop.css similarity index 100% rename from app/assets/stylesheets/web/drag_and_drop.css rename to app/assets/stylesheets/drag_and_drop.css diff --git a/app/assets/stylesheets/web/events.css b/app/assets/stylesheets/events.css similarity index 99% rename from app/assets/stylesheets/web/events.css rename to app/assets/stylesheets/events.css index c6bd0ff2a..1dbdf0997 100644 --- a/app/assets/stylesheets/web/events.css +++ b/app/assets/stylesheets/events.css @@ -221,7 +221,6 @@ background-color: var(--color-canvas); grid-row-start: 1; inset-block-start: calc(var(--block-space) * -1); - margin-inline: calc(var(--main-padding) * -1); margin-block-end: var(--events-gap); padding-block: calc(var(--events-gap) * 3) var(--events-gap); position: sticky; diff --git a/app/assets/stylesheets/web/filters.css b/app/assets/stylesheets/filters.css similarity index 100% rename from app/assets/stylesheets/web/filters.css rename to app/assets/stylesheets/filters.css diff --git a/app/assets/stylesheets/web/flash.css b/app/assets/stylesheets/flash.css similarity index 100% rename from app/assets/stylesheets/web/flash.css rename to app/assets/stylesheets/flash.css diff --git a/app/assets/stylesheets/web/golden-effect.css b/app/assets/stylesheets/golden-effect.css similarity index 100% rename from app/assets/stylesheets/web/golden-effect.css rename to app/assets/stylesheets/golden-effect.css diff --git a/app/assets/stylesheets/web/header.css b/app/assets/stylesheets/header.css similarity index 95% rename from app/assets/stylesheets/web/header.css rename to app/assets/stylesheets/header.css index c2471e522..21553e358 100644 --- a/app/assets/stylesheets/web/header.css +++ b/app/assets/stylesheets/header.css @@ -39,7 +39,6 @@ .header__actions { display: flex; - align-items: center; font-size: var(--text-x-small); gap: var(--header-gap); inline-size: var(--header-actions-width); @@ -117,15 +116,17 @@ /* Optional class to stack header actions on small screens /* ------------------------------------------------------------------------ */ - .header--mobile-actions-stack { + /* .header--mobile-actions-stack { @media (max-width: 639px) { + grid-template-columns: 1fr 1fr; grid-template-areas: - "actions-start menu actions-end" - "title title title"; + "menu menu" + "actions-start actions-end" + "title title"; .header__title { margin-block-start: 0.25rem; } } - } + } */ } diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 1ca7800fa..42a3a21ec 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -59,6 +59,7 @@ .icon--fizzy { --svg: url("fizzy.svg"); } .icon--globe { --svg: url("globe.svg "); } .icon--golden-ticket { --svg: url("golden-ticket.svg "); } + .icon--history { --svg: url("history.svg "); } .icon--home { --svg: url("home.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); } diff --git a/app/assets/stylesheets/web/inputs.css b/app/assets/stylesheets/inputs.css similarity index 100% rename from app/assets/stylesheets/web/inputs.css rename to app/assets/stylesheets/inputs.css diff --git a/app/assets/stylesheets/ios.css b/app/assets/stylesheets/ios.css new file mode 100644 index 000000000..3a05e5956 --- /dev/null +++ b/app/assets/stylesheets/ios.css @@ -0,0 +1,7 @@ +@layer platform { + [data-platform~=ios] { + .hide-on-ios { + display: none; + } + } +} diff --git a/app/assets/stylesheets/ios/utilities.css b/app/assets/stylesheets/ios/utilities.css deleted file mode 100644 index 0dfeada57..000000000 --- a/app/assets/stylesheets/ios/utilities.css +++ /dev/null @@ -1,5 +0,0 @@ -@layer ios { - .hide-in-ios-app { - display: none; - } -} diff --git a/app/assets/stylesheets/web/knobs.css b/app/assets/stylesheets/knobs.css similarity index 100% rename from app/assets/stylesheets/web/knobs.css rename to app/assets/stylesheets/knobs.css diff --git a/app/assets/stylesheets/web/layout.css b/app/assets/stylesheets/layout.css similarity index 100% rename from app/assets/stylesheets/web/layout.css rename to app/assets/stylesheets/layout.css diff --git a/app/assets/stylesheets/web/lexxy.css b/app/assets/stylesheets/lexxy.css similarity index 100% rename from app/assets/stylesheets/web/lexxy.css rename to app/assets/stylesheets/lexxy.css diff --git a/app/assets/stylesheets/web/lightbox.css b/app/assets/stylesheets/lightbox.css similarity index 100% rename from app/assets/stylesheets/web/lightbox.css rename to app/assets/stylesheets/lightbox.css diff --git a/app/assets/stylesheets/web/markdown.css b/app/assets/stylesheets/markdown.css similarity index 91% rename from app/assets/stylesheets/web/markdown.css rename to app/assets/stylesheets/markdown.css index fe26141c2..f6aef0e39 100644 --- a/app/assets/stylesheets/web/markdown.css +++ b/app/assets/stylesheets/markdown.css @@ -3,7 +3,7 @@ --opacity: 0.5; --size: 0.8em; - background: url("/link.svg") no-repeat center bottom 0.2em; + background: url(link.svg) no-repeat center bottom 0.2em; background-size: var(--size); block-size: 1em; color: var(--color-link); diff --git a/app/assets/stylesheets/mobile_app/utilities.css b/app/assets/stylesheets/mobile_app/utilities.css deleted file mode 100644 index 85e9a9848..000000000 --- a/app/assets/stylesheets/mobile_app/utilities.css +++ /dev/null @@ -1,9 +0,0 @@ -@layer mobile_app { - .hide-in-mobile-app { - display: none; - } - - .show-in-mobile-app { - display: unset; - } -} diff --git a/app/assets/stylesheets/mobile_app/_global.css b/app/assets/stylesheets/native.css similarity index 64% rename from app/assets/stylesheets/mobile_app/_global.css rename to app/assets/stylesheets/native.css index 5d0648df5..f7b059c0c 100644 --- a/app/assets/stylesheets/mobile_app/_global.css +++ b/app/assets/stylesheets/native.css @@ -1,10 +1,13 @@ -@layer mobile_app { - /* Use custom insets to account for floating navigation elements. - The mobile apps can inject their own inset value based on native elements on screen. */ - :root { +@layer native { + [data-platform~=native] { + /* The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */ --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); + + .hide-on-native { + display: none; + } } } diff --git a/app/assets/stylesheets/web/nav.css b/app/assets/stylesheets/nav.css similarity index 100% rename from app/assets/stylesheets/web/nav.css rename to app/assets/stylesheets/nav.css diff --git a/app/assets/stylesheets/web/notifications.css b/app/assets/stylesheets/notifications.css similarity index 100% rename from app/assets/stylesheets/web/notifications.css rename to app/assets/stylesheets/notifications.css diff --git a/app/assets/stylesheets/web/pagination.css b/app/assets/stylesheets/pagination.css similarity index 100% rename from app/assets/stylesheets/web/pagination.css rename to app/assets/stylesheets/pagination.css diff --git a/app/assets/stylesheets/web/panels.css b/app/assets/stylesheets/panels.css similarity index 100% rename from app/assets/stylesheets/web/panels.css rename to app/assets/stylesheets/panels.css diff --git a/app/assets/stylesheets/web/popup.css b/app/assets/stylesheets/popup.css similarity index 93% rename from app/assets/stylesheets/web/popup.css rename to app/assets/stylesheets/popup.css index 0213d04cb..189cfef5b 100644 --- a/app/assets/stylesheets/web/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/web/print.css b/app/assets/stylesheets/print.css similarity index 100% rename from app/assets/stylesheets/web/print.css rename to app/assets/stylesheets/print.css diff --git a/app/assets/stylesheets/web/profile-layout.css b/app/assets/stylesheets/profile-layout.css similarity index 100% rename from app/assets/stylesheets/web/profile-layout.css rename to app/assets/stylesheets/profile-layout.css diff --git a/app/assets/stylesheets/web/pwa.css b/app/assets/stylesheets/pwa.css similarity index 100% rename from app/assets/stylesheets/web/pwa.css rename to app/assets/stylesheets/pwa.css diff --git a/app/assets/stylesheets/web/qr-codes.css b/app/assets/stylesheets/qr-codes.css similarity index 100% rename from app/assets/stylesheets/web/qr-codes.css rename to app/assets/stylesheets/qr-codes.css diff --git a/app/assets/stylesheets/web/reactions.css b/app/assets/stylesheets/reactions.css similarity index 97% rename from app/assets/stylesheets/web/reactions.css rename to app/assets/stylesheets/reactions.css index 797e9a7cb..5dea448cc 100644 --- a/app/assets/stylesheets/web/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/web/reset.css b/app/assets/stylesheets/reset.css similarity index 100% rename from app/assets/stylesheets/web/reset.css rename to app/assets/stylesheets/reset.css diff --git a/app/assets/stylesheets/web/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css similarity index 99% rename from app/assets/stylesheets/web/rich-text-content.css rename to app/assets/stylesheets/rich-text-content.css index 5a8ba3fd7..88e4d2ae2 100644 --- a/app/assets/stylesheets/web/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/web/search.css b/app/assets/stylesheets/search.css similarity index 100% rename from app/assets/stylesheets/web/search.css rename to app/assets/stylesheets/search.css diff --git a/app/assets/stylesheets/web/separators.css b/app/assets/stylesheets/separators.css similarity index 100% rename from app/assets/stylesheets/web/separators.css rename to app/assets/stylesheets/separators.css diff --git a/app/assets/stylesheets/web/settings.css b/app/assets/stylesheets/settings.css similarity index 100% rename from app/assets/stylesheets/web/settings.css rename to app/assets/stylesheets/settings.css diff --git a/app/assets/stylesheets/web/spinners.css b/app/assets/stylesheets/spinners.css similarity index 100% rename from app/assets/stylesheets/web/spinners.css rename to app/assets/stylesheets/spinners.css diff --git a/app/assets/stylesheets/web/steps.css b/app/assets/stylesheets/steps.css similarity index 94% rename from app/assets/stylesheets/web/steps.css rename to app/assets/stylesheets/steps.css index f106859ac..b9b40c8fa 100644 --- a/app/assets/stylesheets/web/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/web/syntax.css b/app/assets/stylesheets/syntax.css similarity index 100% rename from app/assets/stylesheets/web/syntax.css rename to app/assets/stylesheets/syntax.css diff --git a/app/assets/stylesheets/web/tags.css b/app/assets/stylesheets/tags.css similarity index 100% rename from app/assets/stylesheets/web/tags.css rename to app/assets/stylesheets/tags.css diff --git a/app/assets/stylesheets/web/toggles.css b/app/assets/stylesheets/toggles.css similarity index 100% rename from app/assets/stylesheets/web/toggles.css rename to app/assets/stylesheets/toggles.css diff --git a/app/assets/stylesheets/web/tooltips.css b/app/assets/stylesheets/tooltips.css similarity index 100% rename from app/assets/stylesheets/web/tooltips.css rename to app/assets/stylesheets/tooltips.css diff --git a/app/assets/stylesheets/web/trays.css b/app/assets/stylesheets/trays.css similarity index 99% rename from app/assets/stylesheets/web/trays.css rename to app/assets/stylesheets/trays.css index 3cefc1ecc..7cc90f92d 100644 --- a/app/assets/stylesheets/web/trays.css +++ b/app/assets/stylesheets/trays.css @@ -424,6 +424,7 @@ .card__meta-text:not(.card__meta-text--updated), .card__stages, .card__steps, + .card__comments, .card__closed { display: none; } diff --git a/app/assets/stylesheets/web/user.css b/app/assets/stylesheets/user.css similarity index 100% rename from app/assets/stylesheets/web/user.css rename to app/assets/stylesheets/user.css diff --git a/app/assets/stylesheets/web/utilities.css b/app/assets/stylesheets/utilities.css similarity index 95% rename from app/assets/stylesheets/web/utilities.css rename to app/assets/stylesheets/utilities.css index 9add37264..e252d8981 100644 --- a/app/assets/stylesheets/web/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -184,6 +184,23 @@ .fill-highlight { background-color: var(--color-highlight); } .fill-transparent { background-color: transparent; } + .fill-highlighter { + display: inline-block; + position: relative; + z-index: 1; + + &::before { + background-color: var(--color-highlight); + border-radius: 0.2em; + content: ""; + inset-block: 0; + inset-inline: -0.1em; + position: absolute; + transform: skewX(-10deg) rotate(1deg); + z-index: -1; + } + } + .translucent { opacity: var(--opacity, 0.66); } /* Borders */ @@ -252,8 +269,10 @@ } } - .show-in-mobile-app { - display: none; + .show-on-native { + body:not([data-platform~=native]) & { + display: none; + } } .hide-scrollbar { diff --git a/app/assets/stylesheets/web/icons.css b/app/assets/stylesheets/web/icons.css deleted file mode 100644 index 6d1697bf9..000000000 --- a/app/assets/stylesheets/web/icons.css +++ /dev/null @@ -1,108 +0,0 @@ -@layer components { - .icon { - -webkit-touch-callout: none; - background-color: currentColor; - block-size: var(--icon-size, 1em); - display: inline-block; - flex-shrink: 0; - inline-size: var(--icon-size, 1em); - mask-image: var(--svg); - mask-position: center; - mask-repeat: no-repeat; - mask-size: var(--icon-size, 1em); - pointer-events: none; - user-select: none; - } - - img.icon { - background: none; - } - - .icon--37signals { --svg: url("/37signals.svg"); } - .icon--add { --svg: url("/add.svg"); } - .icon--add--meta { --svg: url("/add--meta.svg"); } - .icon--arrow-left { --svg: url("/arrow-left.svg"); } - .icon--arrow-right { --svg: url("/arrow-right.svg"); } - .icon--arrow-up { --svg: url("/arrow-up.svg"); } - .icon--art { --svg: url("/art.svg"); } - .icon--assigned { --svg: url("/assigned.svg"); } - .icon--attachment { --svg: url("/attachment.svg"); } - .icon--bell-alert { --svg: url("/bell-alert.svg"); } - .icon--bell-off { --svg: url("/bell-off.svg"); } - .icon--bell { --svg: url("/bell.svg"); } - .icon--bolt { --svg: url("/bolt.svg"); } - .icon--bookmark-outline { --svg: url("/bookmark-outline.svg"); } - .icon--bookmark { --svg: url("/bookmark.svg"); } - .icon--boost { --svg: url("/boost.svg"); } - .icon--camera { --svg: url("/camera.svg"); } - .icon--caret-down { --svg: url("/caret-down.svg"); } - .icon--check { --svg: url("/check.svg"); } - .icon--check-circle { --svg: url("/check-circle.svg"); } - .icon--check-all { --svg: url("/check-all.svg"); } - .icon--clipboard { --svg: url("/clipboard.svg"); } - .icon--close { --svg: url("/close.svg"); } - .icon--close-circle { --svg: url("/close-circle.svg"); } - .icon--collapse { --svg: url("/collapse.svg"); } - .icon--board { --svg: url("/board.svg"); } - .icon--board-add { --svg: url("/board-add.svg"); } - .icon--column-left { --svg: url("/column-left.svg"); } - .icon--column-right { --svg: url("/column-right.svg"); } - .icon--comment { --svg: url("/comment.svg"); } - .icon--copy-paste { --svg: url("/copy-paste.svg"); } - .icon--crown { --svg: url("/crown.svg"); } - .icon--email { --svg: url("/email.svg"); } - .icon--everyone { --svg: url("/everyone.svg"); } - .icon--expand { --svg: url("/expand.svg"); } - .icon--gear { --svg: url("/gear.svg"); } - .icon--grid { --svg: url("/grid.svg"); } - .icon--filter { --svg: url("/filter.svg"); } - .icon--fizzy { --svg: url("/fizzy.svg"); } - .icon--globe { --svg: url("/globe.svg"); } - .icon--golden-ticket { --svg: url("/golden-ticket.svg"); } - .icon--home { --svg: url("/home.svg"); } - .icon--install { --svg: url("/install.svg"); } - .icon--install { --svg: url("/install.svg"); } - .icon--install-edge { --svg: url("/install-edge.svg"); } - .icon--lifebuoy { --svg: url("/lifebuoy.svg"); } - .icon--lock { --svg: url("/lock.svg"); } - .icon--logout { --svg: url("/logout.svg"); } - .icon--marker { --svg: url("/marker.svg"); } - .icon--maximize { --svg: url("/maximize.svg"); } - .icon--menu { --svg: url("/menu.svg"); } - .icon--menu-dots-horizontal { --svg: url("/menu-dots-horizontal.svg"); } - .icon--menu-dots-vertical { --svg: url("/menu-dots-vertical.svg"); } - .icon--minus { --svg: url("/minus.svg"); } - .icon--move { --svg: url("/move.svg"); } - .icon--notification-bell-access-only { --svg: url("/bell.svg"); } - .icon--notification-bell-watching { --svg: url("/bell-off.svg"); } - .icon--notification-bell-reverse-access-only { --svg: url("/bell-off.svg"); } - .icon--notification-bell-reverse-watching { --svg: url("/bell.svg"); } - .icon--password { --svg: url("/password.svg"); } - .icon--pencil { --svg: url("/pencil.svg"); } - .icon--person { --svg: url("/person.svg"); } - .icon--person-add { --svg: url("/person-add.svg"); } - .icon--picture-add { --svg: url("/picture-add.svg"); } - .icon--picture-double { --svg: url("/picture-double.svg"); } - .icon--picture-remove { --svg: url("/picture-remove.svg"); } - .icon--picture-zoom { --svg: url("/picture-zoom.svg"); } - .icon--pinned { --svg: url("/pinned.svg"); } - .icon--qr-code { --svg: url("/qr-code.svg"); } - .icon--reaction { --svg: url("/reaction.svg"); } - .icon--refresh { --svg: url("/refresh.svg"); } - .icon--refresh--meta { --svg: url("/refresh--meta.svg"); } - .icon--remove { --svg: url("/remove.svg"); } - .icon--rename { --svg: url("/rename.svg"); } - .icon--search { --svg: url("/search.svg"); } - .icon--settings { --svg: url("/settings.svg"); } - .icon--share { --svg: url("/share.svg"); } - .icon--sliders { --svg: url("/sliders.svg"); } - .icon--switch { --svg: url("/switch.svg"); } - .icon--tag { --svg: url("/tag.svg"); } - .icon--tag-outline { --svg: url("/tag-outline.svg"); } - .icon--thumb-up { --svg: url("/thumb-up.svg"); } - .icon--trash { --svg: url("/trash.svg"); } - .icon--unpinned { --svg: url("/unpinned.svg"); } - .icon--unseen { --svg: url("/unseen.svg"); } - .icon--world { --svg: url("/world.svg"); } - .icon--youtube { --svg: url("/youtube.svg"); } -} diff --git a/app/assets/stylesheets/welcome-letter.css b/app/assets/stylesheets/welcome-letter.css new file mode 100644 index 000000000..80424ef66 --- /dev/null +++ b/app/assets/stylesheets/welcome-letter.css @@ -0,0 +1,27 @@ +@layer components { + .welcome-letter { + position: relative; + view-transition-name: welcome-letter; + z-index: var(--z-welcome); + + h2, p { + text-wrap: pretty; + } + } + + .welcome-letter__close { + inset: var(--block-space) var(--block-space) auto auto; + position: absolute; + } + + .welcome-letter__signature { + background-color: currentColor; + block-size: 3em; + display: inline-block; + inline-size: 8em; + mask-image: url("jf-signature.svg"); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 8em 3em; + } +} 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..daf22a5bc 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: [ :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/landings_controller.rb b/app/controllers/landings_controller.rb index b3b4c9162..e1c206c7b 100644 --- a/app/controllers/landings_controller.rb +++ b/app/controllers/landings_controller.rb @@ -1,5 +1,7 @@ class LandingsController < ApplicationController def show + flash.keep(:welcome_letter) + if Current.user.boards.one? redirect_to board_path(Current.user.boards.first) else 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/controllers/signups/completions_controller.rb b/app/controllers/signups/completions_controller.rb index f0ea56ff9..4fcaaabd3 100644 --- a/app/controllers/signups/completions_controller.rb +++ b/app/controllers/signups/completions_controller.rb @@ -11,6 +11,7 @@ class Signups::CompletionsController < ApplicationController @signup = Signup.new(signup_params) if @signup.complete + flash[:welcome_letter] = true redirect_to landing_url(script_name: @signup.account.slug) else render :new, status: :unprocessable_entity diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9733a7eec..946a0900e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,8 +1,4 @@ module ApplicationHelper - def stylesheet_link_tags - stylesheet_link_tag *platform.stylesheet_paths, "data-turbo-track": "reload" - end - def page_title_tag account_name = if Current.account && Current.session&.identity&.users&.many? Current.account&.name @@ -14,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 222d41357..f48b5d557 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/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index f0e774882..6eb70f2cc 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -3,6 +3,8 @@ module MessagesHelper turbo_frame_tag dom_id(card, :messages), class: "comments gap center", style: "--card-color: #{card.color}", - role: "group", aria: { label: "Messages" }, & + role: "group", + aria: { label: "Messages" }, + data: { controller: "toggle-class", toggle_class_toggle_class: "comments--system-expanded" }, & end end 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/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index cdc8ea7cd..f6449cea7 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -5,11 +5,13 @@ export default class extends Controller { static targets = [ "dialog" ] static values = { modal: { type: Boolean, default: false }, - sizing: { type: Boolean, default: true } + sizing: { type: Boolean, default: true }, + autoOpen: { type: Boolean, default: false } } connect() { this.dialogTarget.setAttribute("aria-hidden", "true") + if (this.autoOpenValue) this.open() } open() { diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb index be0c0748c..cd3bf3803 100644 --- a/app/jobs/storage/reconcile_job.rb +++ b/app/jobs/storage/reconcile_job.rb @@ -1,9 +1,13 @@ class Storage::ReconcileJob < ApplicationJob + class ReconcileAborted < StandardError; end + queue_as :backend + limits_concurrency to: 1, key: ->(owner) { owner } discard_on ActiveJob::DeserializationError + retry_on ReconcileAborted, wait: 1.minute, attempts: 3 def perform(owner) - owner.reconcile_storage + raise ReconcileAborted, "Could not get stable snapshot for #{owner.class}##{owner.id}" unless owner.reconcile_storage end end 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/admin.rb b/app/models/admin.rb new file mode 100644 index 000000000..53608e3eb --- /dev/null +++ b/app/models/admin.rb @@ -0,0 +1,2 @@ +module Admin +end diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index c484bf335..9b2695cd5 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -1,6 +1,4 @@ class ApplicationPlatform < PlatformAgent - SCOPED_STYLESHEET_PATHS = {} - def ios? match? /iPhone|iPad/ end @@ -37,20 +35,24 @@ class ApplicationPlatform < PlatformAgent !mobile? end + def native? + match? /Hotwire Native/ + end + def windows? operating_system == "Windows" end - def ios_app? - match? /Fizzy iOS/ - end - - def android_app? - match? /Fizzy Android/ - end - - def mobile_app? - ios_app? || android_app? + def type + if native? && android? + "native android" + elsif native? && ios? + "native ios" + elsif mobile? + "mobile web" + else + "desktop web" + end end def operating_system @@ -65,31 +67,4 @@ class ApplicationPlatform < PlatformAgent os =~ /Linux/ ? "Linux" : os end end - - def stylesheet_paths - scoped_stylesheet_paths("web") + - (mobile_app? ? scoped_stylesheet_paths("mobile_app") : []) + - scoped_stylesheet_paths(stylesheet_asset_name) - end - - private - def stylesheet_asset_name - case - when android_app? then "android" - when ios_app? then "ios" - else "desktop" - end - end - - def scoped_stylesheet_paths(scope = css_asset_name) - # Allow new stylesheets to be added in dev/test without restarting server - SCOPED_STYLESHEET_PATHS.clear if Rails.env.development? - - SCOPED_STYLESHEET_PATHS[scope] ||= - Rails.root.join("app/assets/stylesheets").then do |stylesheet_root| - stylesheet_root.glob("#{scope}/**/*.css").collect do |path| - path.to_s.remove(stylesheet_root.to_s + "/", ".css") - end - end - end end 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/board/storage.rb b/app/models/board/storage.rb index 64dde9985..24028e714 100644 --- a/app/models/board/storage.rb +++ b/app/models/board/storage.rb @@ -12,25 +12,32 @@ module Board::Storage # Calculate actual storage by summing blob sizes. # - # Uses batched pluck queries to avoid loading huge ID arrays, and avoids - # ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool - # issues when ActiveStorage uses separate connection pools (e.g., with replicas). + # Storage tracking is a business abstraction - we count what users upload. + # Original upload bytes only; variants/previews/derivatives excluded. + # Physical storage optimizations (deduplication, compression) don't affect quotas. def calculate_real_storage_bytes + @card_ids = nil # Clear memoization for fresh calculation card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes end + def card_ids + @card_ids ||= cards.pluck(:id) + end + def card_image_bytes sum_blob_bytes_in_batches \ ActiveStorage::Attachment.where(record_type: "Card", name: "image"), - cards.pluck(:id) + card_ids end def card_embed_bytes - sum_embed_bytes_for "Card", cards.pluck(:id) + sum_embed_bytes_for "Card", card_ids end def comment_embed_bytes - sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id) + card_ids.each_slice(BATCH_SIZE).sum do |batch| + sum_embed_bytes_for "Comment", Comment.where(card_id: batch).pluck(:id) + end end def board_embed_bytes @@ -48,9 +55,18 @@ module Board::Storage end def sum_blob_bytes_in_batches(base_scope, record_ids) + # Count per-attachment to match ledger model. + # Same blob attached 3 times = 3x bytes (business abstraction, not physical storage). + # + # Do NOT remove the join thinking it's a performance optimization - it's required + # for correct per-attachment counting. We keep ActiveStorage/ActionText in the same + # database (realm/geo partitioning, not functionality partitioning), so cross-table + # joins are fine. record_ids.each_slice(BATCH_SIZE).sum do |batch_ids| - blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id) - ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size) + base_scope + .where(record_id: batch_ids) + .joins(:blob) + .sum("active_storage_blobs.byte_size") end end end diff --git a/app/models/card.rb b/app/models/card.rb index 33c96e502..dbb45f184 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -68,6 +68,55 @@ class Card < ApplicationRecord end private + STORAGE_BATCH_SIZE = 1000 + + # Override to include comments, but only load comments that have attachments. + # Cards can have thousands of comments; most won't have attachments. + # Streams in batches to avoid loading all IDs into memory at once. + def storage_transfer_records + comment_ids_with_attachments = storage_comment_ids_with_attachments + + if comment_ids_with_attachments.any? + [ self, *comments.where(id: comment_ids_with_attachments).to_a ] + else + [ self ] + end + end + + def storage_comment_ids_with_attachments + direct = [] + rich_text_map = {} + + # Stream comment IDs in batches to avoid loading all into memory + comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch| + batch_ids = batch.pluck(:id) + + direct.concat \ + ActiveStorage::Attachment + .where(record_type: "Comment", record_id: batch_ids) + .distinct + .pluck(:record_id) + + ActionText::RichText + .where(record_type: "Comment", record_id: batch_ids) + .pluck(:id, :record_id) + .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id } + end + + embed_comment_ids = if rich_text_map.any? + rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids| + ActiveStorage::Attachment + .where(record_type: "ActionText::RichText", record_id: batch_ids) + .distinct + .pluck(:record_id) + end.filter_map { |rt_id| rich_text_map[rt_id] } + else + [] + end + + (direct + embed_comment_ids).uniq + end + def set_default_title self.title = "Untitled" if title.blank? 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/concerns/storage/totaled.rb b/app/models/concerns/storage/totaled.rb index 3f220f55f..b54a6ef22 100644 --- a/app/models/concerns/storage/totaled.rb +++ b/app/models/concerns/storage/totaled.rb @@ -19,7 +19,7 @@ module Storage::Totaled # Exact: snapshot + pending entries def bytes_used_exact - (storage_total || create_storage_total!).current_usage + create_or_find_storage_total.current_usage end def materialize_storage_later @@ -28,7 +28,7 @@ module Storage::Totaled # Materialize all pending entries into snapshot def materialize_storage - total = storage_total || create_storage_total! + total = create_or_find_storage_total total.with_lock do latest_entry_id = storage_entries.maximum(:id) @@ -44,26 +44,44 @@ module Storage::Totaled end # Reconcile ledger against actual attachment storage. - # Uses cursor to ensure consistency: captures max entry ID first, then calculates - # real bytes, then sums only entries up to that cursor. Concurrent uploads during - # calculation will have entries with IDs beyond the cursor, avoiding double-count. + # + # Uses two-cursor approach for consistency: capture cursor before AND after the + # scan. If they differ, entries were added during the scan and we can't get an + # accurate diff without risking double-counting or undercounting. + # + # Returns true if reconciled successfully, false if aborted due to concurrent + # writes. Caller (ReconcileJob) handles retries to avoid amplification. def reconcile_storage - max_entry_id = storage_entries.maximum(:id) + cursor_before = storage_entries.maximum(:id) real_bytes = calculate_real_storage_bytes - ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0 - diff = real_bytes - ledger_bytes + cursor_after = storage_entries.maximum(:id) - if diff.nonzero? - Storage::Entry.record \ - account: is_a?(Account) ? self : account, - board: is_a?(Board) ? self : nil, - recordable: nil, - delta: diff, - operation: "reconcile" + if cursor_before != cursor_after + Rails.logger.warn "[Storage] Reconcile aborted for #{self.class}##{id}: cursor moved during scan" + false + else + ledger_bytes = cursor_after ? storage_entries.where(id: ..cursor_after).sum(:delta) : 0 + diff = real_bytes - ledger_bytes + + if diff.nonzero? + Rails.logger.info "[Storage] Reconcile #{self.class}##{id}: adjusting by #{diff} bytes" + Storage::Entry.record \ + account: is_a?(Account) ? self : account, + board: is_a?(Board) ? self : nil, + recordable: nil, + delta: diff, + operation: "reconcile" + end + + true end end private + def create_or_find_storage_total + self.storage_total ||= Storage::Total.create_or_find_by!(owner: self) + end + def calculate_real_storage_bytes raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes" end diff --git a/app/models/concerns/storage/tracked.rb b/app/models/concerns/storage/tracked.rb index 7b96e95c0..745ef6692 100644 --- a/app/models/concerns/storage/tracked.rb +++ b/app/models/concerns/storage/tracked.rb @@ -1,3 +1,6 @@ +# Storage tracking is a business abstraction - we count what users upload. +# Original upload bytes only; variants/previews/derivatives excluded. +# Physical storage optimizations (deduplication, compression) don't affect quotas. module Storage::Tracked extend ActiveSupport::Concern @@ -22,21 +25,27 @@ module Storage::Tracked private def board_transfer? - respond_to?(:board_id_changed?) && board_id_changed? + respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id? end def track_board_transfer - old_board_id = board_id_was - current_bytes = storage_bytes + old_board = Board.find_by(id: attribute_in_database(:board_id)) + records = storage_transfer_records.compact + return if records.empty? + + attachments_by_record = storage_attachments_for_records(records) + + attachments_by_record.each do |recordable, attachments| + bytes = attachments.sum { |attachment| attachment.blob.byte_size } + next if bytes.zero? - if current_bytes.positive? # Debit old board - if old_board_id + if old_board Storage::Entry.record \ account: account, - board_id: old_board_id, - recordable: self, - delta: -current_bytes, + board: old_board, + recordable: recordable, + delta: -bytes, operation: "transfer_out" end @@ -44,14 +53,45 @@ module Storage::Tracked Storage::Entry.record \ account: account, board: board, - recordable: self, - delta: current_bytes, + recordable: recordable, + delta: bytes, operation: "transfer_in" end end + def storage_transfer_records + [ self ] + end + # Override if needed. Default = all direct attachments - def attachments_for_storage - ActiveStorage::Attachment.where(record: self) + def attachments_for_storage(recordable = self) + storage_attachments_for_records([ recordable ]).fetch(recordable, []) + end + + def storage_attachments_for_records(recordables) + records = Array(recordables).compact + return {} if records.empty? + + # Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents + records_by_key = records.index_by { |r| [ r.class.name, r.id ] } + + rich_texts = ActionText::RichText.where(record: records) + rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] } + + attachments = ActiveStorage::Attachment + .where(record: records + rich_texts) + .includes(:blob) + .to_a + + attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped| + # Resolve parent without N+1: use lookup for RichText, direct for others + recordable = if attachment.record_type == "ActionText::RichText" + rich_text_to_parent[attachment.record_id] + else + records_by_key[[ attachment.record_type, attachment.record_id ]] + end + + grouped[recordable] << attachment if recordable + end end end diff --git a/app/models/event/description.rb b/app/models/event/description.rb index 672784ddb..f44233011 100644 --- a/app/models/event/description.rb +++ b/app/models/event/description.rb @@ -14,7 +14,7 @@ class Event::Description end def to_plain_text - to_sentence(creator_name, card.title) + to_sentence(creator_name, h(card.title)) end private diff --git a/app/models/storage.rb b/app/models/storage.rb index 9c17b7cd9..445362b34 100644 --- a/app/models/storage.rb +++ b/app/models/storage.rb @@ -2,4 +2,17 @@ module Storage def self.table_name_prefix "storage_" end + + # Record types that participate in storage tracking (ledger entries created on attach). + # The no-reuse validation uses this to scope its check. + # + # IMPORTANT: Update this constant when adding tracked attachments to new models. + # If you add a direct attachment (not via RichText embeds) to Comment, Board, or + # another model with Storage::Tracked, you must add its record_type here or the + # no-reuse validation won't protect it. + TRACKED_RECORD_TYPES = %w[Card ActionText::RichText].freeze + + # Account ID for template/demo blobs that can be reused cross-tenant. + # Set to nil to disable the whitelist (no exemptions). + TEMPLATE_ACCOUNT_ID = nil end diff --git a/app/models/storage/attachment_tracking.rb b/app/models/storage/attachment_tracking.rb index d68f5845d..1c3c7151d 100644 --- a/app/models/storage/attachment_tracking.rb +++ b/app/models/storage/attachment_tracking.rb @@ -26,24 +26,23 @@ module Storage::AttachmentTracking return unless @storage_snapshot Storage::Entry.record \ - account_id: @storage_snapshot[:account_id], - board_id: @storage_snapshot[:board_id], - recordable_type: @storage_snapshot[:recordable_type], - recordable_id: @storage_snapshot[:recordable_id], - blob_id: @storage_snapshot[:blob_id], + account: @storage_snapshot[:account], + board: @storage_snapshot[:board], + recordable: @storage_snapshot[:recordable], + blob: blob, delta: -blob.byte_size, operation: "detach" end + # Snapshot records in before_destroy since parent may be deleted by the time + # after_destroy_commit runs. The records may be destroyed but .id still works. def snapshot_storage_context return unless storage_tracked_record @storage_snapshot = { - account_id: storage_tracked_record.account.id, - board_id: storage_tracked_record.board_for_storage_tracking&.id, - recordable_type: storage_tracked_record.class.name, - recordable_id: storage_tracked_record.id, - blob_id: blob.id + account: storage_tracked_record.account, + board: storage_tracked_record.board_for_storage_tracking, + recordable: storage_tracked_record } end diff --git a/app/models/storage/entry.rb b/app/models/storage/entry.rb index a2f6e726b..768a4c6f8 100644 --- a/app/models/storage/entry.rb +++ b/app/models/storage/entry.rb @@ -5,30 +5,26 @@ class Storage::Entry < ApplicationRecord scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id } - # Accepts either objects or _id params (for after_destroy_commit snapshots) - def self.record(delta:, operation:, account: nil, account_id: nil, board: nil, board_id: nil, - recordable: nil, recordable_type: nil, recordable_id: nil, blob: nil, blob_id: nil) + # Records may be destroyed (during cascading deletes) but .id still works. + # Skip entirely if account is destroyed - no need to track storage for deleted accounts. + # Skip materialize jobs for destroyed boards since there's nothing to update. + def self.record(delta:, operation:, account:, board: nil, recordable: nil, blob: nil) return if delta.zero? - - account_id ||= account&.id - board_id ||= board&.id - blob_id ||= blob&.id + return if account.destroyed? entry = create! \ - account_id: account_id, - board_id: board_id, - recordable_type: recordable_type || recordable&.class&.name, - recordable_id: recordable_id || recordable&.id, - blob_id: blob_id, + account_id: account.id, + board_id: board&.id, + recordable_type: recordable&.class&.name, + recordable_id: recordable&.id, + blob_id: blob&.id, delta: delta, operation: operation, user_id: Current.user&.id, request_id: Current.request_id - # Enqueue materialization - use find_by to handle cascading deletes - # (Account/Board may be destroyed while attachments are still being cleaned up) - Account.find_by(id: account_id)&.materialize_storage_later - Board.find_by(id: board_id)&.materialize_storage_later if board_id + account.materialize_storage_later + board&.materialize_storage_later unless board&.destroyed? entry end diff --git a/app/models/user.rb b/app/models/user.rb index 6503881a3..e16d70670 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,8 @@ class User < ApplicationRecord belongs_to :account belongs_to :identity, optional: true + validates :name, presence: true + has_many :comments, inverse_of: :creator, dependent: :destroy has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy 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/models/user/named.rb b/app/models/user/named.rb index aa4f9b585..d052a79ad 100644 --- a/app/models/user/named.rb +++ b/app/models/user/named.rb @@ -19,7 +19,7 @@ module User::Named def familiar_name names = name.split - return name if names.length == 1 + return name if names.length <= 1 "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}" end end 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/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 273a07b9a..fc5e799b3 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -20,3 +20,5 @@ <%= render "account/settings/export" %>
    + +<%= render "account/settings/subscription_panel" if Fizzy.saas? %> 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/_container.html.erb b/app/views/cards/_container.html.erb index 983f359ed..c85236683 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -1,5 +1,5 @@ -<% cache card do %> -
    +
    + <% cache card do %>
    <%= render "cards/container/gild", card: card if card.published? && !card.closed? %> <%= render "cards/container/image", card: card %> @@ -33,11 +33,15 @@ <%= render "cards/display/preview/bubble", card: card %> <% end %>
    + <% end %> - <% if card.published? %> - <%= render "cards/container/footer/published", card: card %> - <% elsif card.drafted? %> - <%= render "cards/container/footer/draft", card: card %> + <% if card.published? %> + <%= render "cards/container/footer/published", card: card %> + <% elsif card.drafted? %> + <% if Fizzy.saas? %> + <%= render "cards/container/footer/saas/create", card: card %> + <% else %> + <%= render "cards/container/footer/create", card: card %> <% end %> -
    -<% end %> + <% end %> +
    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 6a825e9ec..d693761ea 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-by-system": comment.creator.system? } do %> <%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%> -
    "> +
    @@ -20,6 +20,11 @@ <% end %> + + <%= link_to edit_card_comment_path(comment.card, comment), class: "comment__edit btn btn--circle borderless translucent", data: { only_visible_to_you: true } do %> <%= icon_tag "menu-dots-horizontal" %> diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb new file mode 100644 index 000000000..8b5062fdd --- /dev/null +++ b/app/views/cards/container/footer/_create.html.erb @@ -0,0 +1,19 @@ +
    +
    + <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", + title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })", + form: { data: { controller: "form" } }, + data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> + Create card + <% end %> + + <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", + title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } }, + data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> + Create and add another + <% end %> +
    + + <%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %> +
    + diff --git a/app/views/cards/container/footer/_draft.html.erb b/app/views/cards/container/footer/_draft.html.erb deleted file mode 100644 index 0dc6061a5..000000000 --- a/app/views/cards/container/footer/_draft.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -
    - <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", - title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })", - form: { data: { controller: "form" } }, - data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> - Create card - <% end %> - - <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", - title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } }, - data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> - Create and add another - <% end %> -
    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/application.html.erb b/app/views/layouts/application.html.erb index 6aef1f1bc..b8cd2c70d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -2,7 +2,7 @@ <%= render "layouts/shared/head" %> - +
    <% end %> + + <%= render "layouts/shared/welcome_letter" if flash[:welcome_letter] %> 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/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 0c8dbdd73..4be536454 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -16,7 +16,8 @@ <% turbo_refreshes_with method: :morph, scroll: :preserve %> <%= render "layouts/theme_preference" %> - <%= stylesheet_link_tags %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %> <%= javascript_importmap_tags %> <%= tenanted_action_cable_meta_tag %> diff --git a/app/views/layouts/shared/_welcome_letter.html.erb b/app/views/layouts/shared/_welcome_letter.html.erb new file mode 100644 index 000000000..04073d518 --- /dev/null +++ b/app/views/layouts/shared/_welcome_letter.html.erb @@ -0,0 +1,28 @@ +
    + + + +
    + <%= image_tag "jf-avatar.jpg", size: 36%> +
    + +

    Welcome, and thanks for signing up for Fizzy.

    + +

    + +

    To get you started, we set you up with a Fizzy board called Playground. It’s got a few cards designed to help you learn Fizzy itself. Open each card, go through the simple steps, and you’ll be an expert in Fizzy in no time. You’ll see the Playground when you close this message.

    + +

    If you ever need a hand, please contact me directly at jason@37signals.com. I'm here for you, we’re all here for you.

    + +

    Thanks again and all the best,

    + + + +

    Jason Fried, jason@37signals.com
    + CEO & co-founder of 37signals, makers of Fizzy, Basecamp, and HEY +

    +
    +
    diff --git a/app/views/my/_menu.html.erb b/app/views/my/_menu.html.erb index 31d3d2551..b58f95058 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 %>