Hello, World!
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
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.