diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dbb544dc1..1efb99ec1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ registries: type: git url: https://github.com username: x-access-token - password: ${{secrets.GH_TOKEN}} + password: ${{secrets.FIZZY_GH_TOKEN}} updates: - package-ecosystem: bundler diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml index aa98a8e93..afb91c04d 100644 --- a/.github/workflows/ci-saas.yml +++ b/.github/workflows/ci-saas.yml @@ -18,4 +18,4 @@ jobs: with: saas: true secrets: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index beee16f15..777471774 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: type: boolean required: true secrets: - GH_TOKEN: + FIZZY_GH_TOKEN: required: false permissions: @@ -50,7 +50,7 @@ jobs: MYSQL_USER: root FIZZY_DB_HOST: 127.0.0.1 FIZZY_DB_PORT: 3306 - BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }} + BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} steps: - name: Install system packages 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 3a9430a61..dd1cbc16b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://github.com/basecamp/lexxy - revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 specs: - lexxy (0.1.23.beta) + lexxy (0.1.24.beta) rails (>= 8.0.2) GIT @@ -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 0a40c4ecd..5ea235fb5 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -19,38 +19,16 @@ GIT rails (>= 7.0) rainbow -GIT - remote: https://github.com/basecamp/fizzy-saas - revision: 9c356dfeedbf5309f1a36ee3371f49604c9be188 - 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: d292280b6d2c5d8a924327adf8bb9f0b25953539 + revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 specs: - lexxy (0.1.23.beta) + lexxy (0.1.24.beta) rails (>= 8.0.2) GIT remote: https://github.com/basecamp/queenbee-plugin - revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2 + revision: 14312a940471e20617b38cdec7c092a01567d18b specs: queenbee (3.2.0) activeresource @@ -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/install.svg b/app/assets/images/install.svg deleted file mode 100644 index 82608c765..000000000 --- a/app/assets/images/install.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 213d921e4..d475d0628 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -1,4 +1,4 @@ -@layer reset, base, components, modules, utilities; +@layer reset, base, components, modules, utilities, native, platform; :root { /* Spacing */ 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/animation.css b/app/assets/stylesheets/animation.css index d6f05efa9..b38c4ce82 100644 --- a/app/assets/stylesheets/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/card-columns.css b/app/assets/stylesheets/card-columns.css index e08b5748d..38f567444 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -481,7 +481,23 @@ font-size: clamp(0.6rem, 0.85cqi, 100px); } + .card__comments { + --column-gap: 0.8ch; + + display: flex; + margin-block-end: calc(var(--block-space) * -1.7); + margin-inline-end: calc(var(--card-padding-inline) * -0.5); + + .icon { + --icon-size: 1.6em; + + color: var(--card-color); + } + } + .card__steps { + --column-gap: 0.8ch; + display: flex; } diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 3132d886f..59a80a32a 100644 --- a/app/assets/stylesheets/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/comments.css b/app/assets/stylesheets/comments.css index 80968184e..c3dbb7d74 100644 --- a/app/assets/stylesheets/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--system & { + --comment-padding-block: var(--block-space-half); + + text-align: center; + + &::before { + /* Make up space for lack of avatar */ + content: ""; + display: flex; + inline-size: calc(var(--comment-padding-inline) * 0.9); + } + + .comment__avatar { + display: none; + } + + .comment__author { + a { margin: 0 auto; } + h3 { margin-inline: auto; } + strong { display: none; } + } + + .comment__body { + padding: 0; + text-align: center; + } + + .comment__content { + --stripe-color: var(--color-ink-lightest); + + background-image: repeating-linear-gradient( + 45deg in srgb, + var(--color-canvas) 0 1px, + var(--stripe-color) 1px 10px); + padding-inline: var(--comment-padding-inline); + + .comments--system-expanded .comment--system & { + --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); + } + + .comment__history { + background-color: var(--stripe-color); + } + } + + .reactions { + display: none !important; + } + } } .comment__author { @@ -48,7 +99,6 @@ } .comment__body { - padding-inline-end: var(--reaction-size); text-align: start; .action-text-content { @@ -60,6 +110,10 @@ margin-block-end: 0; } } + + &:not:has(lexxy-editor) { + padding-inline-end: var(--reaction-size); + } } .comment__content { @@ -70,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) @@ -82,53 +137,36 @@ background-color: var(--color-ink-lightest); } + .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; + } + .comment--system { - --comment-padding-block: var(--block-space-half); + display: none; + transition: var(--dialog-duration) allow-discrete; + transition-property: display; - max-inline-size: var(--comment-max); - text-align: center; - - &::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--system) { + display: contents; + + .comment__history { + display: grid; + } + } + + /* Hide the "Show history" button if there's only one system comment */ + :nth-child(1 of .comment--system) { + .comment__history { display: none; } - - .comment__author { - a { - margin: 0 auto; - } - - h3 { - margin-inline: auto; - } - - strong { - display: none; - } - } - - .comment__body { - padding: 0; - text-align: center; - } - - .comment__content { - 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/icons.css b/app/assets/stylesheets/icons.css index 2e269b9d0..42a3a21ec 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -59,9 +59,8 @@ .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 { --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 "); } 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/lexxy.css b/app/assets/stylesheets/lexxy.css index c90d5f6b9..21ee18301 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -38,7 +38,7 @@ } .lexxy-editor__content { - margin: var(--block-space-half) 0; + margin-block-start: var(--block-space-half); min-block-size: calc(7lh + var(--block-space)); outline: 0; @@ -46,6 +46,10 @@ ::selection { background: oklch(var(--lch-blue-light) / 0.5); } + + > :last-child { + margin-block-end: 0; + } } .lexxy-editor--drag-over { @@ -227,12 +231,15 @@ [data-button-group] { display: flex; flex-direction: row; - flex-wrap: wrap; gap: var(--gap); + & { margin-block-start: var(--gap); } + + @media (max-width: 639px) { + flex-wrap: wrap; + } } } @@ -297,17 +304,12 @@ font-size: var(--text-small); list-style: none; margin: 0; + max-height: 200px; min-inline-size: var(--lexxy-prompt-min-width); + overflow: auto; padding: var(--lexxy-prompt-padding); visibility: hidden; z-index: var(--z-popup); - - max-height: 200px; - overflow: scroll; - - &:is(.lexxy-prompt-menu--visible) { - padding: var(--lexxy-prompt-padding); - } } .lexxy-prompt-menu--visible { diff --git a/app/assets/stylesheets/lightbox.css b/app/assets/stylesheets/lightbox.css index 4a9cb3d8b..65144a22b 100644 --- a/app/assets/stylesheets/lightbox.css +++ b/app/assets/stylesheets/lightbox.css @@ -67,7 +67,7 @@ } .lightbox__caption { - color: var(--color-ink-inverted); + color: var(--color-white); &:empty { display: none; diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css new file mode 100644 index 000000000..f7b059c0c --- /dev/null +++ b/app/assets/stylesheets/native.css @@ -0,0 +1,13 @@ +@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/popup.css b/app/assets/stylesheets/popup.css index 0213d04cb..189cfef5b 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -36,6 +36,15 @@ } } + .popup__footer { + border-block-start: 1px solid var(--color-ink-lightest); + color: var(--card-color); + margin-block-start: var(--popup-item-padding-inline); + padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0; + text-align: center; + text-transform: initial; + } + .popup__title { font-weight: 800; white-space: nowrap; diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index 797e9a7cb..5dea448cc 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -24,6 +24,10 @@ margin: 0; position: absolute; + @media (max-width: 640px) { + inset-inline-end: calc(var(--comment-padding-inline) / 3); + } + .reactions__list { display: none; } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 70f095bca..88e4d2ae2 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -1,8 +1,8 @@ @layer components { - .rich-text-content { + :where(.rich-text-content) { --block-margin: 0.5lh; - :where(h1, h2, h3, h4, h5, h6) { + h1, h2, h3, h4, h5, h6 { display: block; font-weight: 800; letter-spacing: -0.02ch; @@ -12,14 +12,14 @@ text-wrap: balance; } - :where(h1) { font-size: 2em; } - :where(h2) { font-size: 1.5em; } - :where(h3) { font-size: 1.17em; } - :where(h4) { font-size: 1em; } - :where(h5) { font-size: 0.83em; } - :where(h6) { font-size: 0.67em; } + h1 { font-size: 2em; } + h2 { font-size: 1.5em; } + h3 { font-size: 1.17em; } + h4 { font-size: 1em; } + h5 { font-size: 0.83em; } + h6 { font-size: 0.67em; } - :where(p, ul, ol, dl, blockquote, figure, .attachment) { + p, ul, ol, dl, blockquote, figure, .attachment { margin-block: 0 var(--block-margin); &:not(lexxy-editor &) { @@ -28,49 +28,65 @@ } } - :where(ol, ul) { - list-style: disc; + p:has(+ p) { + margin: 0; + } + + ol, ul { padding-inline-start: 3ch; } - :where(li:has(li)) { + ol { + list-style: decimal; + } + + ul { + list-style: disc; + } + + li:has(li) { list-style: none; - :where(ol, ul) { + ol, ul { margin: 0; padding-inline-start: 2ch; } } - :where(b, strong, .lexxy-content__bold) { + b, strong, .lexxy-content__bold { font-weight: 700; } - :where(i, em, .lexxy-content__italic) { + i, em, .lexxy-content__italic { font-style: italic; } - :where(s, .lexxy-content__strikethrough) { + s, .lexxy-content__strikethrough { text-decoration: line-through; } - :where(mark, .lexxy-content__highlight) { + mark, .lexxy-content__highlight { background-color: transparent; color: inherit; } - :where(p, blockquote) { + p, blockquote { letter-spacing: -0.005ch; } - :where(blockquote) { + /* Avoid extra space due to empty paragraphs */ + p:empty { + display: none; + } + + blockquote { border-inline-start: 0.25em solid var(--color-ink-lighter); font-style: italic; margin: var(--block-margin) 0; padding-inline-start: 2ch; } - :where(img, video, embed, object) { + img, video, embed, object { inline-size: auto; margin-inline: auto; max-block-size: 32rem; @@ -81,14 +97,10 @@ a:has(img), a:has(video) { inline-size: fit-content; + margin-inline: auto; } - /* Avoid extra space due to empty paragraphs */ - p:empty { - display: none; - } - - :where(hr) { + hr { color: currentColor; block-size: 0; border: none; @@ -103,7 +115,7 @@ } /* Code */ - :where(code, pre) { + code, pre { background-color: var(--color-canvas); border: 1px solid var(--color-ink-lighter); border-radius: 0.3em; @@ -209,7 +221,7 @@ position: relative; max-inline-size: 100%; - :where(progress) { + progress { inline-size: 100%; margin: auto; } diff --git a/app/assets/stylesheets/steps.css b/app/assets/stylesheets/steps.css index f106859ac..b9b40c8fa 100644 --- a/app/assets/stylesheets/steps.css +++ b/app/assets/stylesheets/steps.css @@ -93,17 +93,14 @@ } .steps__icon { + --icon-size: 0.875em; + background-color: var(--card-color); block-size: 1.3em; border-radius: 50%; + color: var(--color-ink-inverted); display: grid; inline-size: 1.3em; place-content: center; - - .icon { - background-color: var(--color-ink-inverted); - block-size: 0.8em; - inline-size: 0.8em; - } } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 3cefc1ecc..7cc90f92d 100644 --- a/app/assets/stylesheets/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/utilities.css b/app/assets/stylesheets/utilities.css index 80cbfcdca..0d00d9977 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -178,6 +178,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 */ @@ -246,6 +263,12 @@ } } + .show-on-native { + body:not([data-platform~=native]) & { + display: none; + } + } + .hide-scrollbar { -ms-overflow-style: none; /* Edge */ scrollbar-width: none; /* FF */ diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index 9e14eb937..4b71e7f3e 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -35,7 +35,7 @@ class Boards::ColumnsController < ApplicationController @column.destroy respond_to do |format| - format.turbo_stream + format.html { redirect_back_or_to @board } format.json { head :no_content } end end diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 6c8a17575..721e69b0d 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -83,7 +83,7 @@ class BoardsController < ApplicationController def show_columns cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded set_page_and_extract_portion_from cards - fresh_when etag: [ @board, @page.records, @user_filtering ] + fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ] end def board_params diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb index 396fd3a5f..3e9fe1d48 100644 --- a/app/controllers/cards/assignments_controller.rb +++ b/app/controllers/cards/assignments_controller.rb @@ -8,11 +8,16 @@ class Cards::AssignmentsController < ApplicationController end def create - @card.toggle_assignment @board.users.active.find(params[:assignee_id]) - - respond_to do |format| - format.turbo_stream - format.json { head :no_content } + if @card.toggle_assignment @board.users.active.find(params[:assignee_id]) + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end + else + respond_to do |format| + format.turbo_stream + format.json { head :unprocessable_entity } + end end end end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 83706ddf4..bfdc1ee2e 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -61,6 +61,6 @@ class CardsController < ApplicationController end def card_params - params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ]) + params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ]) end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 08129d9c7..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 @@ -38,7 +37,7 @@ module Authentication def require_account unless Current.account.present? - redirect_to main_app.session_menu_url(script_name: nil) + redirect_to main_app.session_menu_path(script_name: nil) end end @@ -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_url(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/authorization.rb b/app/controllers/concerns/authorization.rb index 81bd0c781..1afb878ef 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -3,7 +3,6 @@ module Authorization included do before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } - before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated? end class_methods do @@ -27,11 +26,7 @@ module Authorization end def ensure_can_access_account - redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? - end - - def ensure_only_staff_can_access_non_production_remote_environments - head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff? + redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active? end def redirect_existing_user 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/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 8bd67c9dc..f2ec2588c 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -1,5 +1,6 @@ class JoinCodesController < ApplicationController allow_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { head :too_many_requests } before_action :set_join_code before_action :ensure_join_code_is_valid 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/public/base_controller.rb b/app/controllers/public/base_controller.rb index b4c33e95b..018b1ab36 100644 --- a/app/controllers/public/base_controller.rb +++ b/app/controllers/public/base_controller.rb @@ -11,7 +11,7 @@ class Public::BaseController < ApplicationController end def set_card - @card = @board.cards.find_by!(number: params[:id]) if params[:board_id] && params[:id] + @card = @board.cards.published.find_by!(number: params[:id]) if params[:board_id] && params[:id] end def set_public_cache_expiration diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index c0632407a..32257d941 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -1,7 +1,7 @@ class Sessions::MagicLinksController < ApplicationController disallow_account_scope require_unauthenticated_access - rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" } + rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded before_action :ensure_that_email_address_pending_authentication_exists layout "public" @@ -11,25 +11,20 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - authenticate_with magic_link + authenticate magic_link else - redirect_to session_magic_link_path, flash: { shake: true } + invalid_code end end private def ensure_that_email_address_pending_authentication_exists unless email_address_pending_authentication.present? - redirect_to new_session_path, alert: "Enter your email address to sign in." - end - end - - def authenticate_with(magic_link) - if email_address_pending_authentication_matches?(magic_link.identity.email_address) - start_new_session_for magic_link.identity - redirect_to after_sign_in_url(magic_link) - else - redirect_to new_session_path, alert: "Authentication failed. Please try again." + alert_message = "Enter your email address to sign in." + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end end end @@ -37,6 +32,41 @@ class Sessions::MagicLinksController < ApplicationController params.expect(:code) end + def authenticate(magic_link) + if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) + sign_in magic_link + else + email_address_mismatch + end + end + + def sign_in(magic_link) + clear_pending_authentication_token + start_new_session_for magic_link.identity + + respond_to do |format| + format.html { redirect_to after_sign_in_url(magic_link) } + format.json { render json: { session_token: session_token } } + end + end + + def email_address_mismatch + clear_pending_authentication_token + alert_message = "Something went wrong. Please try again." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end + end + + def invalid_code + respond_to do |format| + format.html { redirect_to session_magic_link_path, flash: { shake: true } } + format.json { render json: { message: "Try another code." }, status: :unauthorized } + end + end + def after_sign_in_url(magic_link) if magic_link.for_sign_up? new_signup_completion_path @@ -44,4 +74,12 @@ class Sessions::MagicLinksController < ApplicationController after_authentication_url end end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again in 15 minutes." + respond_to do |format| + format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f5044bd2a..d0de9e84c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,7 @@ class SessionsController < ApplicationController disallow_account_scope require_unauthenticated_access except: :destroy - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded layout "public" @@ -9,16 +9,12 @@ class SessionsController < ApplicationController end def create - if identity = Identity.find_by_email_address(email_address) - redirect_to_session_magic_link identity.send_magic_link + if identity = Identity.find_by(email_address: email_address) + sign_in identity + elsif Account.accepting_signups? + sign_up else - signup = Signup.new(email_address: email_address) - if signup.valid?(:identity_creation) - magic_link = signup.create_identity if Account.accepting_signups? - redirect_to_session_magic_link magic_link - else - head :unprocessable_entity - end + redirect_to_fake_session_magic_link email_address end end @@ -28,7 +24,43 @@ class SessionsController < ApplicationController end private + def magic_link_from_sign_in_or_sign_up + if identity = Identity.find_by_email_address(email_address) + identity.send_magic_link + else + signup = Signup.new(email_address: email_address) + signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups? + end + end + def email_address params.expect(:email_address) end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again later." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end + + def sign_in(identity) + redirect_to_session_magic_link identity.send_magic_link + end + + def sign_up + signup = Signup.new(email_address: email_address) + + if signup.valid?(:identity_creation) + magic_link = signup.create_identity + redirect_to_session_magic_link magic_link + else + respond_to do |format| + format.html { redirect_to new_session_path, alert: "Something went wrong" } + format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } + end + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b3657c76e..946a0900e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -10,12 +10,6 @@ module ApplicationHelper tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options end - def inline_svg(name) - file_path = "#{Rails.root}/app/assets/images/#{name}.svg" - return File.read(file_path).html_safe if File.exist?(file_path) - "(not found)" - end - def back_link_to(label, url, action, **options) link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index 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/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js index e8bc48f03..fc86b18f7 100644 --- a/app/javascript/controllers/lightbox_controller.js +++ b/app/javascript/controllers/lightbox_controller.js @@ -3,9 +3,22 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "caption", "image", "dialog", "zoomedImage" ] - open(event) { + imageTargetConnected(element) { + element.addEventListener("click", this.#handleImageClick) + } + + imageTargetDisconnected(element) { + element.removeEventListener("click", this.#handleImageClick) + } + + #handleImageClick = (event) => { + event.preventDefault() + this.#open(event.currentTarget) + } + + #open(link) { this.dialogTarget.showModal() - this.#set(event.target.closest("a")) + this.#set(link) } // Wait for the transition to finish before resetting the image diff --git a/app/javascript/helpers/http_helpers.js b/app/javascript/helpers/http_helpers.js deleted file mode 100644 index 1fb986f70..000000000 --- a/app/javascript/helpers/http_helpers.js +++ /dev/null @@ -1,4 +0,0 @@ -export const HttpStatus = { - CONFLICT: 409, - UNPROCESSABLE: 422 -} 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.