diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 86c66c646..f4f755614 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,8 +20,14 @@ updates: dependency-type: "development" schedule: interval: weekly + cooldown: + default-days: 7 + semver-major-days: 14 - package-ecosystem: github-actions directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 + cooldown: + default-days: 7 + semver-major-days: 14 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 4fdc1bcba..f3b992387 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -3,6 +3,9 @@ name: Checks on: pull_request: +permissions: + contents: read + jobs: security: name: Security diff --git a/.github/workflows/ci-oss.yml b/.github/workflows/ci-oss.yml index f6f490f34..ea007b567 100644 --- a/.github/workflows/ci-oss.yml +++ b/.github/workflows/ci-oss.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, synchronize] +permissions: + contents: read + jobs: test: if: github.event.pull_request.head.repo.full_name != github.repository diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml index f1d8875b4..aa98a8e93 100644 --- a/.github/workflows/ci-saas.yml +++ b/.github/workflows/ci-saas.yml @@ -3,6 +3,9 @@ name: CI (SaaS) on: push: +permissions: + contents: read + jobs: test_oss: name: Test (OSS) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b5925a13..e62118357 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,9 @@ on: GH_TOKEN: required: false +permissions: + contents: read + jobs: test: name: Tests (${{ matrix.mode }}) diff --git a/AGENTS.md b/AGENTS.md index 428c802a8..80bb887a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,4 +148,4 @@ Use Chrome MCP tools to interact with the running dev app for UI testing and deb ## Coding style -Please read the separate file `STYLE.md` for some guidance on coding style. +@STYLE.md diff --git a/Dockerfile b/Dockerfile index 59cb8cfef..babd3b6d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ RUN apt-get update -qq && \ ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" \ + BUNDLE_WITHOUT="development:test" \ LD_PRELOAD="/usr/local/lib/libjemalloc.so" # Throw-away build stage to reduce size of final image diff --git a/Gemfile.lock b/Gemfile.lock index e24a38a82..e4f364c1c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,7 +6,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: b22cb0c0cb39b103d816a66d560991acf0a57163 + revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0 branch: main specs: actioncable (8.2.0.alpha) @@ -216,7 +216,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.16.0) + json (2.17.1) jwt (3.1.2) base64 kamal (2.9.0) @@ -237,7 +237,7 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - lexxy (0.1.20.beta) + lexxy (0.1.23.beta) rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) @@ -267,7 +267,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) @@ -351,7 +351,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (6.16.0) + rdoc (6.16.1) erb psych (>= 4.0.0) tsort @@ -443,7 +443,7 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.8) + stringio (3.1.9) thor (1.4.0) thruster (0.1.16) thruster (0.1.16-aarch64-linux) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 5cd308155..f093ac677 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: 97d126e0a6084905aceaadf4facaa2a48b44e124 + revision: 43dbc896ce7b6a08194a92ddd1695d3f1ebf554b specs: fizzy-saas (0.1.0) audits1984 @@ -75,7 +75,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 690ec8898318b8f50714e86676353ebe1551261e + revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0 branch: main specs: actioncable (8.2.0.alpha) @@ -292,7 +292,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.16.0) + json (2.17.1) jwt (3.1.2) base64 kamal (2.9.0) @@ -313,7 +313,7 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - lexxy (0.1.20.beta) + lexxy (0.1.23.beta) rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) @@ -552,7 +552,7 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.8) + stringio (3.1.9) thor (1.4.0) thruster (0.1.16) thruster (0.1.16-aarch64-linux) diff --git a/README.md b/README.md index 8a7877326..07f55614e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,99 @@ This is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com). +## Deploying Fizzy + +If you'd like to run Fizzy on your own server, we recommend deploying it with [Kamal](https://kamal-deploy.org/). +Kamal makes it easier to set up a bare server, copy the application to it, and manage the configuration settings that it uses. + +(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).) + +This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it. + +The steps to configure your very own Fizzy are: + +1. Fork the repo +2. Edit few things in config/deploy.yml, .kamal/secrets, and config/environments/production.rb +3. Run `kamal setup` to do your first deploy. + +We'll go through each of these in turn. + +### Fork the repo + +To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo. +That allows you to commit your changes, and track them over time. +You can always re-sync your fork to pick up new changes from the main repo over time. + +Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed. + +### Editing the configuration + +The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance. +To get started, the parts you need to change are all in the "About your deployment" section. +We've added comments to that file to highlight what each setting needs to be, but the main ones are: + +- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`. +- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here. +- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. +- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. + +Fizzy also requires a few environment variables to be set up, some of which contain secrets. +The simplest way to do this is to put them in a file called `.kamal/secrets`. +Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally. + +If you use a password manager like 1Password, you can also opt to keep your secrets there instead. +Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that. + +To store your secrets, create the file `.kamal/secrets` and enter something like the following: + +```ini +SECRET_KEY_BASE=12345 +VAPID_PUBLIC_KEY=something +VAPID_PRIVATE_KEY=somethingelse +SMTP_USERNAME=email-provider-username +SMTP_PASSWORD=email-provider-password +``` + +The values you enter here will be specific to you, and you can get or create them as follows: + +- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this. +- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with: + + ```sh + bin/rails c + ``` + + And then run the following to create a new pair of keys: + + ```ruby + vapid_key = WebPush.generate_key + + puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" + puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" + ``` + +- `SMTP_USERNAME`/`SMTP_PASSWORD` are credentials you should get from your email provider. + +Lastly, you'll need to set up the rest of your email configuration in `config/environments/production.rb`. There is an example configuration in comments at the top of that file. The actual settings you use here will depend on your email provider, but in most cases will look similar to that section, so you can uncomment it and edit to suit. Note that it will use the `SMTP_USERNAME` and `SMTP_PASSWORD` values you entered in your secrets. + +Once you've made all those changes, commit them to your fork so they're saved. + +### Deploy Fizzy! + +You can now do your first deploy by running: + +```sh +bin/kamal setup +``` + +This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running. + +After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run: + +```sh +bin/kamal deploy +``` + ## Development ### Setting up @@ -23,15 +116,35 @@ You'll be able to access the app in development at http://fizzy.localhost:3006. To login, enter `david@example.com` and grab the verification code from the browser console to sign in. +### Web Push Notifications + +Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables: + +- `VAPID_PRIVATE_KEY` +- `VAPID_PUBLIC_KEY` + +Generate them with the `web-push` gem: + +```ruby +vapid_key = WebPush.generate_key + +puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" +puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" +``` + ### Running tests For fast feedback loops, unit tests can be run with: - bin/rails test +```sh +bin/rails test +``` The full continuous integration tests can be run with: - bin/ci +```sh +bin/ci +``` ### Database configuration @@ -50,37 +163,18 @@ You can view email previews at http://fizzy.localhost:3006/rails/mailers. You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with: - bin/rails dev:email +```sh +bin/rails dev:email +``` Under the hood, this will create or remove `tmp/email-dev.txt`. -## Deployment - -We recommend [Kamal](https://kamal-deploy.org/) for deploying Fizzy. This project comes with a vanilla Rails template. You can find our production setup in [`fizzy-saas`](https://github.com/basecamp/fizzy-saas). - -### Web Push Notifications - -Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. You'll need to generate a key pair and set these environment variables: - -- `VAPID_PRIVATE_KEY` -- `VAPID_PUBLIC_KEY` - -Generate them with the `web-push` gem: - -```ruby -vapid_key = WebPush.generate_key - -puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" -puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" -``` - ## SaaS gem 37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup. This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure. - ## Contributing We welcome contributions! Please read our [style guide](STYLE.md) before submitting code. @@ -88,4 +182,3 @@ We welcome contributions! Please read our [style guide](STYLE.md) before submitt ## License Fizzy is released under the [O'Saasy License](LICENSE.md). - diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 865be918c..9e4a9f8bf 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -10,7 +10,7 @@ --block-space-double: calc(var(--block-space) * 2); /* Text */ - --font-sans: system-ui; + --font-sans: -apple-system, BlinkMacSystemFont, sans-serif; --font-serif: ui-serif, serif; --font-mono: ui-monospace, monospace; @@ -219,6 +219,27 @@ --color-card-7: oklch(var(--lch-purple-medium)); --color-card-8: oklch(var(--lch-pink-medium)); + /* Colors: Highlighter */ + --highlight-1: rgb(136, 118, 38); + --highlight-2: rgb(185, 94, 6); + --highlight-3: rgb(207, 0, 0); + --highlight-4: rgb(216, 28, 170); + --highlight-5: rgb(144, 19, 254); + --highlight-6: rgb(5, 98, 185); + --highlight-7: rgb(17, 138, 15); + --highlight-8: rgb(148, 82, 22); + --highlight-9: rgb(102, 102, 102); + + --highlight-bg-1: rgba(229, 223, 6, 0.3); + --highlight-bg-2: rgba(255, 185, 87, 0.3); + --highlight-bg-3: rgba(255, 118, 118, 0.3); + --highlight-bg-4: rgba(248, 137, 216, 0.3); + --highlight-bg-5: rgba(190, 165, 255, 0.3); + --highlight-bg-6: rgba(124, 192, 252, 0.3); + --highlight-bg-7: rgba(140, 255, 129, 0.3); + --highlight-bg-8: rgba(221, 170, 123, 0.3); + --highlight-bg-9: rgba(200, 200, 200, 0.3); + /* Colors: Syntax highlighting */ --color-code-token__att: oklch(var(--lch-blue-dark)); --color-code-token__comment: oklch(var(--lch-ink-medium)); diff --git a/app/assets/stylesheets/animation.css b/app/assets/stylesheets/animation.css index 8c617a1d7..d6f05efa9 100644 --- a/app/assets/stylesheets/animation.css +++ b/app/assets/stylesheets/animation.css @@ -44,6 +44,11 @@ to { transform: translateY(0); } } + @keyframes slide-up-fade-in { + from { transform: translateY(2rem); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes slide-down { from { transform: translateY(0); } to { transform: translateY(2rem); } diff --git a/app/assets/stylesheets/blank-slates.css b/app/assets/stylesheets/blank-slates.css index 188f4926b..0725dd336 100644 --- a/app/assets/stylesheets/blank-slates.css +++ b/app/assets/stylesheets/blank-slates.css @@ -39,4 +39,8 @@ display: none; } } + + .cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty { + display: none; + } } diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 150dde9a1..3132d886f 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -127,7 +127,7 @@ } @media (max-width: 799px) { - --padding-inline: var(--main-padding); + --padding-inline: 1.5ch; column-gap: 0; grid-template-areas: @@ -150,6 +150,10 @@ border-radius: 0.2em; grid-area: card; padding: clamp(2rem, 4vw, var(--padding-block)); + + @media (max-width: 799px) { + padding-inline: var(--padding-inline); + } } .card-perma__actions { diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index a0100ca1b..cc79ca76a 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -1,61 +1,39 @@ @layer components { + /* Editor + /* ------------------------------------------------------------------------ */ + lexxy-editor { display: block; position: relative; overflow: visible; figure.node--selected { - &:not(:has(img)) { - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } - &:has(img) { img { outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); } } - } - /* Lexical still uses the `lexxy-editor--empty` class if you have a list - started with no other characters. Here, we won't show the placeholder if - you've clicked the List button in the toolbar. */ - &.lexxy-editor--empty { - .lexxy-editor__content:not(:has(ul, ol))::before { - content: attr(placeholder); - color: currentColor; - cursor: text; - opacity: 0.66; - pointer-events: none; - position: absolute; - white-space: pre-line; + &:not(:has(img)) { + outline: var(--focus-ring-size) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); } } } - .lexxy-dialog-actions { - display: flex; - font-size: var(--text-x-small); - flex: 1 1 0%; - gap: var(--inline-space-half); - margin-block-start: var(--block-space-half); - - .btn { - --radius: 0.3em; - - inline-size: 100%; - justify-content: center; - - &:is([type="submit"]) { - --btn-background: var(--card-color, var(--color-link)); - --btn-color: var(--color-ink-inverted); - --focus-ring-color: var(--card-color, var(--color-link)); - } - } - - span { - inline-size: 100%; + /* Lexical uses the `lexxy-editor--empty` class even if you have a list + * started but haven't added other characters. Here, we hide the placeholder + * when you click the List button in the toolbar. */ + .lexxy-editor--empty { + .lexxy-editor__content:not(:has(ul, ol))::before { + content: attr(placeholder); + color: currentColor; + cursor: text; + opacity: 0.66; + pointer-events: none; + position: absolute; + white-space: pre-line; } } @@ -64,14 +42,9 @@ min-block-size: calc(7lh + var(--block-space)); outline: 0; - * { - &:first-child { - margin-block-start: 0; - } - - &:last-child { - margin-block-end: 0; - } + /* Allow color highlights to show through a bit */ + ::selection { + background: oklch(var(--lch-blue-light) / 0.5); } } @@ -81,6 +54,40 @@ outline: 2px dashed var(--color-selected-dark); } + .lexxy-code-language-picker { + -webkit-appearance: none; + appearance: none; + background-color: var(--color-canvas); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); + background-position: center right 0.9em; + background-repeat: no-repeat; + background-size: 0.5em; + border: 1px solid var(--color-ink-light); + border-radius: 99rem; + color: var(--color-ink); + cursor: pointer; + font-family: var(--font-base); + font-size: var(--text-x-small); + font-weight: 500; + inset-inline-end: 0; + line-height: 1.15lh; + margin: 0.75ch 0.75ch 0 0; + padding-inline: 1.5ch 1.8em; + text-align: start; + + @media (prefers-color-scheme: dark) { + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); + } + + option { + background-color: var(--color-canvas); + color: var(--color-ink); + } + } + + /* Toolbar + /* ------------------------------------------------------------------------ */ + lexxy-toolbar { --lexxy-toolbar-icon-size: 1em; @@ -96,21 +103,6 @@ position: sticky; inset-block-start: 0; z-index: 1; - - dialog { - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-lighter); - border-radius: 0.5em; - box-shadow: var(--shadow); - color: var(--color-ink); - padding: var(--block-space) calc(var(--inline-space) * 1.5); - position: absolute; - z-index: 1; - - .input[type="url"] { - min-inline-size: 30ch; - } - } } .lexxy-editor__toolbar-button { @@ -157,55 +149,130 @@ border-radius: 0.5ch; box-shadow: var(--shadow); display: flex; + flex-wrap: wrap; inset-inline-end: 0; padding: 4px; position: absolute; z-index: 1; } - :where(.lexxy-editor__toolbar-spacer) { + .lexxy-editor__toolbar-spacer { flex: 1; } - /* Based on .input, .input--select */ - .lexxy-code-language-picker { - -webkit-appearance: none; - appearance: none; + /* Dropdowns + /* ------------------------------------------------------------------------ */ + + .lexxy-editor__toolbar-dropdown-content { + --lexxy-dropdown-padding: 0.75rem; + --lexxy-dropdown-btn-size: 2rem; + background-color: var(--color-canvas); - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); - background-position: center right 0.9em; - background-repeat: no-repeat; - background-size: 0.5em; border: 1px solid var(--color-ink-lighter); - border-radius: 0.3em; + border-radius: 0.5em; + box-shadow: var(--shadow); color: var(--color-ink); - font-family: var(--font-base); - font-size: var(--text-x-small); - font-weight: 500; - inset-inline-end: 0; - line-height: inherit; - margin: 0.3em 0.3em 0 0; - padding: 0.5em 1.8em 0.5em 1.2em; - text-align: start; + font-size: var(--text-small); + padding: var(--lexxy-dropdown-padding); + position: absolute; + z-index: 1; - html[data-theme="dark"] & { - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); - } - - @media (prefers-color-scheme: dark) { - html:not([data-theme]) & { - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); - } - } - - - option { - background-color: var(--color-canvas); - color: var(--color-ink); + button { + block-size: var(--lexxy-dropdown-btn-size); } } - /* Prompt + .lexxy-editor__toolbar-dropdown-actions { + display: flex; + font-size: var(--text-x-small); + flex: 1 1 0%; + gap: var(--lexxy-dropdown-padding); + margin-block-start: var(--lexxy-dropdown-padding); + + .btn { + --radius: 99rem; + + inline-size: 100%; + } + + .btn[type="submit"] { + --btn-background: var(--card-color, var(--color-link)); + --btn-color: var(--color-ink-inverted); + --focus-ring-color: var(--card-color, var(--color-link)); + } + + span { + inline-size: 100%; + } + } + + lexxy-link-dropdown { + .input { + min-inline-size: 30ch; + } + } + + lexxy-highlight-dropdown { + --gap: 0.5ch; + + [data-button-group] { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--gap); + + + & { + margin-block-start: var(--gap); + } + } + } + + .lexxy-highlight-button { + --outline-color: oklch(var(--lch-ink-darkest) / 0.2); + + appearance: none; + background: var(--color-canvas); + border: none; + border-radius: 0.5ch; + color: inherit; + display: grid; + font-weight: 500; + inline-size: var(--lexxy-dropdown-btn-size); + place-content: center; + position: relative; + outline: none; + + &:after { + content: "Aa"; + } + + &:hover, + &[aria-pressed="true"] { + box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color); + } + + &[aria-pressed="true"] { + --outline-color: var(--color-link); + + &:after { + content: "✓"; + } + } + } + + .lexxy-editor__toolbar-dropdown-reset { + background: var(--color-canvas); + border: 1px solid var(--color-ink-light); + border-radius: 99rem; + inline-size: 100%; + margin-block-start: var(--lexxy-dropdown-padding); + + &[disabled] { + display: none; + } + } + + /* Prompt ,enu (@mentions, etc.) /* ------------------------------------------------------------------------ */ .lexxy-prompt-menu { diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index bcc60c85e..d7a12ed68 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -184,6 +184,7 @@ aspect-ratio: 5/3; background-color: var(--color-ink-lightest); border-radius: 0.5em; + container-type: inline-size; flex-basis: calc((100% - var(--gap) * 2) / 3); flex-direction: column; font-size: var(--text-small); @@ -218,6 +219,7 @@ @media (max-width: 639px) { font-size: var(--text-x-small); + font-size: clamp(var(--text-xx-small), 3.15cqi, var(--text-small)); font-weight: 500; } } diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index 06add9439..0213d04cb 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -10,8 +10,8 @@ inset: 0 auto auto 50%; max-block-size: 70dvh; - max-inline-size: min(55ch, calc(100vw - (var(--panel-padding) * 2))); - min-inline-size: min(25ch, calc(100vw - (var(--panel-padding) * 2))); + max-inline-size: min(55ch, calc(100vw - (var(--panel-padding)))); + min-inline-size: min(25ch, calc(100vw - (var(--panel-padding)))); overflow: auto; position: absolute; transform: translateX(-50%); diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 2d465c935..c9877f4bf 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -28,6 +28,19 @@ } } + :where(ol, ul) { + padding-inline-start: 3ch; + } + + :where(li:has(li)) { + list-style: none; + + :where(ol, ul) { + margin: 0; + padding-inline-start: 2ch; + } + } + :where(b, strong, .lexxy-content__bold) { font-weight: 700; } @@ -40,6 +53,10 @@ text-decoration: line-through; } + :where(mark, .lexxy-content__highlight) { + background-color: transparent; + color: inherit; + } :where(p, blockquote) { letter-spacing: -0.005ch; @@ -201,14 +218,17 @@ color: color-mix(in oklch, var(--color-ink) 66%, transparent); font-size: var(--text-small); - .input { + textarea { --input-border-radius: 0.3em; --input-border-size: 0; --input-padding: 0; + background-color: var(--input-background, transparent); + border: none; color: inherit; inline-size: 100%; max-inline-size: 100%; + resize: none; text-align: center; &:focus { @@ -274,7 +294,8 @@ display: flex; flex-wrap: wrap; gap: 1ch; - inline-size: auto; + inline-size: 100%; + margin-inline: 0; .attachment__caption { display: grid; diff --git a/app/controllers/boards/columns/not_nows_controller.rb b/app/controllers/boards/columns/not_nows_controller.rb index 6cb765979..0ff41e9ae 100644 --- a/app/controllers/boards/columns/not_nows_controller.rb +++ b/app/controllers/boards/columns/not_nows_controller.rb @@ -2,7 +2,7 @@ class Boards::Columns::NotNowsController < ApplicationController include BoardScoped def show - set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first.preloaded + set_page_and_extract_portion_from @board.cards.postponed.latest.preloaded fresh_when etag: @page.records end end diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb index b89395cd5..b23600d91 100644 --- a/app/controllers/cards/closures_controller.rb +++ b/app/controllers/cards/closures_controller.rb @@ -2,12 +2,12 @@ class Cards::ClosuresController < ApplicationController include CardScoped def create - @card.close(user: Current.user) + @card.close render_card_replacement end def destroy - @card.reopen(user: Current.user) + @card.reopen render_card_replacement end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 89eda5887..4cdbbdb69 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -97,6 +97,12 @@ module Authentication end end + def redirect_to_session_magic_link(magic_link, return_to: nil) + serve_development_magic_link(magic_link) + session[:return_to_after_authenticating] = return_to if return_to + redirect_to 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 diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 971b3c6f8..8bd67c9dc 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -3,6 +3,7 @@ class JoinCodesController < ApplicationController before_action :set_join_code before_action :ensure_join_code_is_valid + before_action :set_identity, only: :create layout "public" @@ -10,22 +11,35 @@ class JoinCodesController < ApplicationController end def create - identity = Identity.find_or_create_by!(email_address: params.expect(:email_address)) + @join_code.redeem_if { |account| @identity.join(account) } + user = User.active.find_by!(account: @join_code.account, identity: @identity) - @join_code.redeem_if { |account| identity.join(account) } - user = User.active.find_by!(account: @join_code.account, identity: identity) - - if identity == Current.identity && user.setup? + if @identity == Current.identity && user.setup? redirect_to landing_url(script_name: @join_code.account.slug) - elsif identity == Current.identity - redirect_to new_users_join_url(script_name: @join_code.account.slug) + elsif @identity == Current.identity + redirect_to new_users_verification_url(script_name: @join_code.account.slug) else - logout_and_send_new_magic_link(identity) - redirect_to session_magic_link_url(script_name: nil) + terminate_session if Current.identity + + redirect_to_session_magic_link \ + @identity.send_magic_link, + return_to: new_users_verification_url(script_name: @join_code.account.slug) end end private + def set_identity + @identity = Identity.find_or_initialize_by(email_address: params.expect(:email_address)) + + if @identity.new_record? + if @identity.invalid? + head :unprocessable_entity + else + @identity.save! + end + end + end + def set_join_code @join_code ||= Account::JoinCode.find_by(code: params.expect(:code), account: Current.account) end @@ -37,13 +51,4 @@ class JoinCodesController < ApplicationController render :inactive, status: :gone end end - - def logout_and_send_new_magic_link(identity) - terminate_session if Current.identity - - magic_link = identity.send_magic_link - serve_development_magic_link(magic_link) - - session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug) - end end diff --git a/app/controllers/public/boards/columns/not_nows_controller.rb b/app/controllers/public/boards/columns/not_nows_controller.rb index d518fb8e0..5a54df171 100644 --- a/app/controllers/public/boards/columns/not_nows_controller.rb +++ b/app/controllers/public/boards/columns/not_nows_controller.rb @@ -1,5 +1,5 @@ class Public::Boards::Columns::NotNowsController < Public::BaseController def show - set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first + set_page_and_extract_portion_from @board.cards.postponed.latest end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 5335f58f3..fb9fe0617 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,17 +9,16 @@ class SessionsController < ApplicationController end def create - identity = Identity.find_by_email_address(email_address) - - magic_link = if identity - identity.send_magic_link + if identity = Identity.find_by_email_address(email_address) + redirect_to_session_magic_link identity.send_magic_link else - Signup.new(email_address: email_address).create_identity + signup = Signup.new(email_address: email_address) + if signup.valid?(:identity_creation) + redirect_to_session_magic_link signup.create_identity + else + head :unprocessable_entity + end end - - serve_development_magic_link(magic_link) - - redirect_to session_magic_link_path end def destroy diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index 33da02f28..776421201 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -11,8 +11,12 @@ class SignupsController < ApplicationController end def create - Signup.new(signup_params).create_identity - redirect_to session_magic_link_path + signup = Signup.new(signup_params) + if signup.valid?(:identity_creation) + redirect_to_session_magic_link signup.create_identity + else + head :unprocessable_entity + end end private diff --git a/app/controllers/users/verifications_controller.rb b/app/controllers/users/verifications_controller.rb new file mode 100644 index 000000000..d99492576 --- /dev/null +++ b/app/controllers/users/verifications_controller.rb @@ -0,0 +1,11 @@ +class Users::VerificationsController < ApplicationController + layout "public" + + def new + end + + def create + Current.user.verify + redirect_to new_users_join_path + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ae800c75d..15f8ecd6f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -9,8 +9,11 @@ class UsersController < ApplicationController end def update - @user.update! user_params - redirect_to @user + if @user.update(user_params) + redirect_to @user + else + render :edit, status: :unprocessable_entity + end end def destroy diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb index 2d1b5522e..69ef2efec 100644 --- a/app/helpers/accesses_helper.rb +++ b/app/helpers/accesses_helper.rb @@ -1,6 +1,4 @@ module AccessesHelper - MAX_DISPLAYED_WATCHERS = 8 - def access_menu_tag(board, **options, &) tag.menu class: [ options[:class], { "toggler--toggled": board.all_access? } ], data: { controller: "filter toggle-class navigable-list", @@ -14,7 +12,7 @@ module AccessesHelper render partial: "boards/access_toggle", collection: users, as: :user, locals: { selected: selected, disabled: disabled }, - cached: ->(user) { [ user, selected ] } + cached: ->(user) { [ user, selected, disabled ] } end def access_involvement_advance_button(board, user, show_watchers: true, icon_only: false) @@ -27,45 +25,30 @@ module AccessesHelper end def board_watchers_list(board) - watchers = board.watchers.with_avatars + watchers = board.watchers.with_avatars.load - displayed_watchers = watchers.limit(MAX_DISPLAYED_WATCHERS) - overflow_count = watchers.count - MAX_DISPLAYED_WATCHERS + displayed_watchers = watchers.first(8) + overflow_count = watchers.size - 8 - safe_join([ - tag.strong(displayed_watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase"), - tag.div(class: "board-tools__watching") do - safe_join([ - safe_join(displayed_watchers.map { |watcher| avatar_tag(watcher) }), - (tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do - concat tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" }) - concat(tag.dialog(class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" }) do - safe_join(watchers.map { |watcher| avatar_tag(watcher) }) - end) - end if overflow_count > 0) - ].compact) - end - ]) + tag.strong(watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase") + + tag.div(avatar_tags(displayed_watchers), class: "board-tools__watching") do + tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do + tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" }) + + tag.dialog(avatar_tags(watchers), class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" }) + end if overflow_count > 0 + end end def involvement_button(board, access, show_watchers, icon_only) - involvement_label_id = dom_id(board, :involvement_label) - + label_text = access.access_only? ? "Watch this" : "Stop watching" button_to( - board_involvement_path(board), - method: :put, - aria: { labelledby: involvement_label_id }, - title: involvement_access_label(access), - class: class_names("btn", { "btn--reversed": access.watching? && icon_only }), - params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only } - ) do - safe_join([ - icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}"), - tag.span( - class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), - id: involvement_label_id - ) - ]) + board_involvement_path(board), method: :put, + params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }, + aria: { labelledby: dom_id(board, :involvement_label) }, + title: (label_text if icon_only), + class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do + icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") + + tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label)) end end @@ -74,12 +57,4 @@ module AccessesHelper order = %w[ watching access_only ] order[(order.index(involvement.to_s) + 1) % order.size] end - - def involvement_access_label(access) - if access.access_only? - "Watch this" - else - "Stop watching" - end - end end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 8fb42448a..7c064a650 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -19,6 +19,10 @@ module AvatarsHelper end end + def avatar_tags(users, **options) + users.collect { avatar_tag(it, **options) }.join.html_safe + end + def mail_avatar_tag(user, size: 48, **options) if user.avatar.attached? image_tag user_avatar_url(user), alt: user.name, class: "avatar", size: size, **options diff --git a/app/helpers/cards_helper.rb b/app/helpers/cards_helper.rb index 0dd93c520..9b149b707 100644 --- a/app/helpers/cards_helper.rb +++ b/app/helpers/cards_helper.rb @@ -1,5 +1,5 @@ module CardsHelper - def card_article_tag(card, id: dom_id(card, :article), **options, &block) + def card_article_tag(card, id: dom_id(card, :article), data: {}, **options, &block) classes = [ options.delete(:class), ("golden-effect" if card.golden?), @@ -7,22 +7,17 @@ module CardsHelper ("card--active" if card.active?) ].compact.join(" ") + data[:drag_and_drop_top] = true if card.golden? && !card.closed? && !card.postponed? + tag.article \ id: id, style: "--card-color: #{card.color}; view-transition-name: #{id}", class: classes, + data: data, **options, &block end - def button_to_delete_card(card) - button_to card_path(card), - method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_frame: "_top", turbo_confirm: "Are you sure you want to permanently delete this card?" } do - concat(icon_tag("trash")) - concat(tag.span("Delete this card")) - end - end - def card_title_tag(card) title = [ card.title, diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index 8a2e82e17..222d41357 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -10,14 +10,16 @@ module ColumnsHelper data: { turbo_frame: "_top" } end - def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {}, **properties, &block) + def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block) classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed) data = { drag_and_drop_target: "container", navigable_list_target: "item", column_name: name, - drag_and_drop_url: drop_url + drag_and_drop_url: drop_url, + drag_and_drop_css_variable_name: "--card-color", + drag_and_drop_css_variable_value: card_color }.merge(data) data[:action] = token_list( @@ -28,7 +30,8 @@ module ColumnsHelper tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do tag.div(class: "cards__transition-container", data: { - controller: "navigable-list", + controller: "navigable-list css-variable-counter", + css_variable_counter_property_name_value: "--card-count", navigable_list_supports_horizontal_navigation_value: "false", navigable_list_prevent_handled_keys_value: "true", navigable_list_auto_select_value: "false", diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb index 82a18722d..8998ffcb9 100644 --- a/app/helpers/html_helper.rb +++ b/app/helpers/html_helper.rb @@ -9,7 +9,7 @@ module HtmlHelper private EXCLUDED_ELEMENTS = %w[ a figcaption pre code ] - EMAIL_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/ + EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/ URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https]) def auto_link(fragment) @@ -48,7 +48,7 @@ module HtmlHelper end def auto_link_emails(text) - text.gsub!(EMAIL_REGEXP) do |match| + text.gsub!(EMAIL_AUTOLINK_REGEXP) do |match| %(#{match}) end end diff --git a/app/javascript/controllers/back_navigation_controller.js b/app/javascript/controllers/back_navigation_controller.js deleted file mode 100644 index 8fad83419..000000000 --- a/app/javascript/controllers/back_navigation_controller.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static values = { fallbackDestination: String } - - connect() { - if (history.state.turbo?.restorationIndex > 0) { - this.element.href = "javascript:history.back()" - } else { - this.element.href = this.fallbackDestinationValue - } - } -} diff --git a/app/javascript/controllers/bubble_controller.js b/app/javascript/controllers/bubble_controller.js index 66fd469cf..714977c66 100644 --- a/app/javascript/controllers/bubble_controller.js +++ b/app/javascript/controllers/bubble_controller.js @@ -33,8 +33,7 @@ export default class extends Controller { } get #entropyCleanupInDays() { - this.entropyCleanupInDays ??= signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt)) - return this.entropyCleanupInDays + return signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt)) } #showEntropy() { diff --git a/app/javascript/controllers/css_variable_counter_controller.js b/app/javascript/controllers/css_variable_counter_controller.js new file mode 100644 index 000000000..1fa2cd5b9 --- /dev/null +++ b/app/javascript/controllers/css_variable_counter_controller.js @@ -0,0 +1,33 @@ +import { Controller } from "@hotwired/stimulus" +import { debounce } from "helpers/timing_helpers" + +export default class extends Controller { + static targets = [ "item", "counter" ] + static values = { + propertyName: String, + maxValue: { type: Number, default: 20 } + } + + initialize() { + this.#updateCounter = debounce(this.#updateCounter.bind(this), 50) + } + + connect() { + this.#updateCounter() + } + + itemTargetConnected() { + this.#updateCounter() + } + + itemTargetDisconnected() { + this.#updateCounter() + } + + #updateCounter = () => { + if (!this.hasCounterTarget) return + + const count = Math.min(this.itemTargets.length, this.maxValueValue) + this.counterTarget.style.setProperty(this.propertyNameValue, count) + } +} diff --git a/app/javascript/controllers/drag_and_drop_controller.js b/app/javascript/controllers/drag_and_drop_controller.js index 5c03853be..5fc532ca1 100644 --- a/app/javascript/controllers/drag_and_drop_controller.js +++ b/app/javascript/controllers/drag_and_drop_controller.js @@ -16,11 +16,14 @@ export default class extends Controller { await nextFrame() this.dragItem = this.#itemContaining(event.target) this.sourceContainer = this.#containerContaining(this.dragItem) + this.originalDraggedItemCssVariable = this.#containerCssVariableFor(this.sourceContainer) this.dragItem.classList.add(this.draggedItemClass) } dragOver(event) { event.preventDefault() + if (!this.dragItem) { return } + const container = this.#containerContaining(event.target) this.#clearContainerHoverClasses() @@ -28,32 +31,39 @@ export default class extends Controller { if (container !== this.sourceContainer) { container.classList.add(this.hoverContainerClass) + this.#applyContainerCssVariableToDraggedItem(container) + } else { + this.#restoreOriginalDraggedItemCssVariable() } } async drop(event) { - const container = this.#containerContaining(event.target) + const targetContainer = this.#containerContaining(event.target) - if (!container || container === this.sourceContainer) { return } + if (!targetContainer || targetContainer === this.sourceContainer) { return } this.wasDropped = true + this.#increaseCounter(targetContainer) this.#decreaseCounter(this.sourceContainer) + const sourceContainer = this.sourceContainer - await this.#submitDropRequest(this.dragItem, container) - this.#reloadSourceFrame(sourceContainer); + this.#insertDraggedItem(targetContainer, this.dragItem) + await this.#submitDropRequest(this.dragItem, targetContainer) + this.#reloadSourceFrame(sourceContainer) } dragEnd() { this.dragItem.classList.remove(this.draggedItemClass) this.#clearContainerHoverClasses() - if (this.wasDropped) { - this.dragItem.remove() + if (!this.wasDropped) { + this.#restoreOriginalDraggedItemCssVariable() } this.sourceContainer = null this.dragItem = null this.wasDropped = false + this.originalDraggedItemCssVariable = null } #itemContaining(element) { @@ -68,6 +78,63 @@ export default class extends Controller { this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass)) } + #applyContainerCssVariableToDraggedItem(container) { + const cssVariable = this.#containerCssVariableFor(container) + if (cssVariable) { + this.dragItem.style.setProperty(cssVariable.name, cssVariable.value) + } + } + + #restoreOriginalDraggedItemCssVariable() { + if (this.originalDraggedItemCssVariable) { + const { name, value } = this.originalDraggedItemCssVariable + this.dragItem.style.setProperty(name, value) + } + } + + #containerCssVariableFor(container) { + const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = container.dataset + if (dragAndDropCssVariableName && dragAndDropCssVariableValue) { + return { name: dragAndDropCssVariableName, value: dragAndDropCssVariableValue } + } + return null + } + + #increaseCounter(container) { + this.#modifyCounter(container, count => count + 1) + } + + #decreaseCounter(container) { + this.#modifyCounter(container, count => Math.max(0, count - 1)) + } + + #modifyCounter(container, fn) { + const counterElement = container.querySelector("[data-drag-and-drop-counter]") + if (counterElement) { + const currentValue = counterElement.textContent.trim() + + if (!/^\d+$/.test(currentValue)) return + + counterElement.textContent = fn(parseInt(currentValue)) + } + } + + #insertDraggedItem(container, item) { + const itemContainer = container.querySelector("[data-drag-drop-item-container]") + const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]") + const firstTopItem = topItems[0] + const lastTopItem = topItems[topItems.length - 1] + + const isTopItem = item.hasAttribute("data-drag-and-drop-top") + const referenceItem = isTopItem ? firstTopItem : lastTopItem + + if (referenceItem) { + referenceItem[isTopItem ? "before" : "after"](item) + } else { + itemContainer.prepend(item) + } + } + async #submitDropRequest(item, container) { const body = new FormData() const id = item.dataset.id @@ -80,18 +147,4 @@ export default class extends Controller { const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]") if (frame) frame.reload() } - - #decreaseCounter(sourceContainer) { - const counterElement = sourceContainer.querySelector("[data-drag-and-drop-counter]") - if (counterElement) { - const currentValue = counterElement.textContent.trim() - - if (!/^\d+$/.test(currentValue)) return - - const count = parseInt(currentValue) - if (count > 0) { - counterElement.textContent = count - 1 - } - } - } } diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js index e910d6622..beca1f08f 100644 --- a/app/javascript/controllers/form_controller.js +++ b/app/javascript/controllers/form_controller.js @@ -8,10 +8,21 @@ export default class extends Controller { debounceTimeout: { type: Number, default: 300 } } + #isComposing = false + initialize() { this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), this.debounceTimeoutValue) } + // IME Composition tracking + compositionStart() { + this.#isComposing = true + } + + compositionEnd() { + this.#isComposing = false + } + submit() { this.element.requestSubmit() } @@ -21,12 +32,23 @@ export default class extends Controller { if (input) { const value = (input.value || "").trim() - if (value.length === 0) { + const isEmpty = value.length === 0 + + if (isEmpty) { event.preventDefault() + input.setCustomValidity(input.dataset.validationMessage || "Please fill out this field") + input.reportValidity() + input.addEventListener("input", () => input.setCustomValidity(""), { once: true }) } } } + preventComposingSubmit(event) { + if (this.#isComposing) { + event.preventDefault() + } + } + debouncedSubmit(event) { this.submit(event) } diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js index b39566b44..e8bc48f03 100644 --- a/app/javascript/controllers/lightbox_controller.js +++ b/app/javascript/controllers/lightbox_controller.js @@ -3,14 +3,6 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "caption", "image", "dialog", "zoomedImage" ] - connect() { - this.dialogTarget.addEventListener('transitionend', this.handleTransitionEnd.bind(this)) - } - - disconnect() { - this.dialogTarget.removeEventListener('transitionend', this.handleTransitionEnd.bind(this)) - } - open(event) { this.dialogTarget.showModal() this.#set(event.target.closest("a")) diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js index 4d6c45ab5..3e2e4618c 100644 --- a/app/javascript/controllers/magic_link_controller.js +++ b/app/javascript/controllers/magic_link_controller.js @@ -4,12 +4,18 @@ import { onNextEventLoopTick } from "helpers/timing_helpers" export default class extends Controller { static targets = [ "input" ] + submitOnEnter(event) { + event.preventDefault() + this.submit() + } + + submitOnPaste() { + onNextEventLoopTick(() => this.submit()) + } + submit() { - onNextEventLoopTick(() => { - if (!this.inputTarget.disabled) { - this.element.submit() - this.inputTarget.disabled = true - } - }) + if (this.inputTarget.disabled) return + this.element.submit() + this.inputTarget.disabled = true } } diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js index 4af794c4b..bd5e87665 100644 --- a/app/javascript/controllers/navigable_list_controller.js +++ b/app/javascript/controllers/navigable_list_controller.js @@ -245,6 +245,9 @@ export default class extends Controller { } }, Enter(event) { + // Skip handling during IME composition (e.g., Japanese input) + if (event.isComposing) { return } + if (event.shiftKey) { this.#toggleCurrentItem(event) } else { diff --git a/app/models/account.rb b/app/models/account.rb index 50f3bf3ee..61a636adb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -21,7 +21,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")) + account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current)) end end end diff --git a/app/models/card/broadcastable.rb b/app/models/card/broadcastable.rb index aa24b776c..9cf112200 100644 --- a/app/models/card/broadcastable.rb +++ b/app/models/card/broadcastable.rb @@ -3,5 +3,16 @@ module Card::Broadcastable included do broadcasts_refreshes + + before_update :remember_if_preview_changed end + + private + def remember_if_preview_changed + @preview_changed ||= title_changed? || column_id_changed? || board_id_changed? + end + + def preview_changed? + @preview_changed + end end diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb index c52fb7a29..520fd95d4 100644 --- a/app/models/card/eventable.rb +++ b/app/models/card/eventable.rb @@ -19,7 +19,6 @@ module Card::Eventable def touch_last_active_at # Not using touch so that we can detect attribute change on callbacks update!(last_active_at: Time.current) - broadcast_activity end private @@ -36,8 +35,4 @@ module Card::Eventable def create_system_comment_for(event) SystemCommenter.new(self, event).comment end - - def broadcast_activity - broadcast_render_later_to self, :activity, partial: "card/display/refresh_activity", locals: { card: self } - end end diff --git a/app/models/card/pinnable.rb b/app/models/card/pinnable.rb index ff3407ea0..9929a785d 100644 --- a/app/models/card/pinnable.rb +++ b/app/models/card/pinnable.rb @@ -3,6 +3,8 @@ module Card::Pinnable included do has_many :pins, dependent: :destroy + + after_update_commit :broadcast_pin_updates, if: :preview_changed? end def pinned_by?(user) @@ -20,4 +22,11 @@ module Card::Pinnable def unpin_by(user) pins.find_by(user: user).tap { it.destroy } end + + private + def broadcast_pin_updates + pins.find_each do |pin| + pin.broadcast_replace_later_to [ pin.user, :pins_tray ], partial: "my/pins/pin" + end + end end diff --git a/app/models/concerns/attachments.rb b/app/models/concerns/attachments.rb index 1d1c2c4d6..bcc7303ae 100644 --- a/app/models/concerns/attachments.rb +++ b/app/models/concerns/attachments.rb @@ -1,13 +1,10 @@ module Attachments extend ActiveSupport::Concern - # The variants we use must all declared here so that we can preprocess them. + # Variants used by ActionText embeds. Processed immediately on attachment to avoid + # read replica issues (lazy variants would attempt writes on read replicas). # - # If they are not preprocessed, then Rails will attempt to transform the image on-the-fly when - # they are first viewed, which may be on the read replica where writing to the database is not - # allowed. Chaos will ensue if that happens. - # - # These variants are patched into ActionText::RichText in config/initializers/action_text.rb + # Patched into ActionText::RichText in config/initializers/action_text.rb VARIANTS = { # vipsthumbnail used to create sized image variants has a intent setting to manage colors during # resize. By setting an invalid intent value the gif-incompatible intent filtering is skipped and diff --git a/app/models/identity.rb b/app/models/identity.rb index 135fdaae7..bb69734b4 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -8,8 +8,9 @@ class Identity < ApplicationRecord has_one_attached :avatar - before_destroy :deactivate_users + before_destroy :deactivate_users, prepend: true + validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } normalizes :email_address, with: ->(value) { value.strip.downcase.presence } def send_magic_link(**attributes) diff --git a/app/models/push/subscription.rb b/app/models/push/subscription.rb index 0b273b563..afd417367 100644 --- a/app/models/push/subscription.rb +++ b/app/models/push/subscription.rb @@ -1,14 +1,54 @@ class Push::Subscription < ApplicationRecord + PERMITTED_ENDPOINT_HOSTS = %w[ + fcm.googleapis.com + updates.push.services.mozilla.com + web.push.apple.com + notify.windows.com + ].freeze + belongs_to :account, default: -> { user.account } belongs_to :user + validates :endpoint, presence: true + validate :validate_endpoint_url + def notification(**params) WebPush::Notification.new( **params, badge: user.notifications.unread.count, endpoint: endpoint, + endpoint_ip: resolved_endpoint_ip, p256dh_key: p256dh_key, auth_key: auth_key ) end + + def resolved_endpoint_ip + return @resolved_endpoint_ip if defined?(@resolved_endpoint_ip) + @resolved_endpoint_ip = SsrfProtection.resolve_public_ip(endpoint_uri&.host) + end + + private + def endpoint_uri + @endpoint_uri ||= URI.parse(endpoint) if endpoint.present? + rescue URI::InvalidURIError + nil + end + + def validate_endpoint_url + if endpoint_uri.nil? + errors.add(:endpoint, "is not a valid URL") + elsif endpoint_uri.scheme != "https" + errors.add(:endpoint, "must use HTTPS") + elsif !permitted_endpoint_host? + errors.add(:endpoint, "is not a permitted push service") + elsif resolved_endpoint_ip.nil? + errors.add(:endpoint, "resolves to a private or invalid IP address") + end + end + + def permitted_endpoint_host? + host = endpoint_uri&.host&.downcase + PERMITTED_ENDPOINT_HOSTS.any? { |permitted| host&.end_with?(permitted) } + end end diff --git a/app/models/signup.rb b/app/models/signup.rb index 15ac5873c..110c253ef 100644 --- a/app/models/signup.rb +++ b/app/models/signup.rb @@ -6,9 +6,8 @@ class Signup attr_accessor :full_name, :email_address, :identity attr_reader :account, :user - with_options on: :completion do - validates_presence_of :full_name, :identity - end + validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation + validates :full_name, :identity, presence: true, on: :completion def initialize(...) super diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb index f3df2511b..a73b6b3be 100644 --- a/app/models/ssrf_protection.rb +++ b/app/models/ssrf_protection.rb @@ -3,14 +3,21 @@ module SsrfProtection DNS_RESOLUTION_TIMEOUT = 2 + DNS_NAMESERVERS = %w[ + 1.1.1.1 + 8.8.8.8 + ] + DISALLOWED_IP_RANGES = [ - IPAddr.new("0.0.0.0/8") # Broadcasts + IPAddr.new("0.0.0.0/8"), # "This" network (RFC1700) + IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598) + IPAddr.new("198.18.0.0/15") # Benchmark testing (RFC2544) ].freeze def resolve_public_ip(hostname) ip_addresses = resolve_dns(hostname) public_ips = ip_addresses.reject { |ip| private_address?(ip) } - public_ips.first&.to_s + public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s end def private_address?(ip) @@ -22,7 +29,7 @@ module SsrfProtection def resolve_dns(hostname) ip_addresses = [] - Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns| + Resolv::DNS.open(nameserver: DNS_NAMESERVERS, timeouts: DNS_RESOLUTION_TIMEOUT) do |dns| dns.each_address(hostname) do |ip_address| ip_addresses << IPAddr.new(ip_address.to_s) end diff --git a/app/models/user.rb b/app/models/user.rb index 8c0ac42a5..20ee1c40c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,6 +28,14 @@ class User < ApplicationRecord name != identity.email_address end + def verified? + verified_at.present? + end + + def verify + update!(verified_at: Time.current) unless verified? + end + private def close_remote_connections ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false) diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index a4b2ae631..c635edddb 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -5,7 +5,7 @@ module User::Avatar included do has_one_attached :avatar do |attachable| - attachable.variant :thumb, resize_to_fill: [ 256, 256 ] + attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately end validate :avatar_content_type_allowed diff --git a/app/models/user/day_timeline.rb b/app/models/user/day_timeline.rb index 1fe4ed36e..4b30d24ca 100644 --- a/app/models/user/day_timeline.rb +++ b/app/models/user/day_timeline.rb @@ -30,15 +30,15 @@ class User::DayTimeline end def added_column - @added_column ||= build_column("Added", 1, events.where(action: %w[card_published card_reopened])) + @added_column ||= build_column(:added, "Added", 1, events.where(action: %w[card_published card_reopened])) end def updated_column - @updated_column ||= build_column("Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened])) + @updated_column ||= build_column(:updated, "Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened])) end def closed_column - @closed_column ||= build_column("Done", 3, events.where(action: "card_closed")) + @closed_column ||= build_column(:closed, "Done", 3, events.where(action: "card_closed")) end def cache_key @@ -84,8 +84,8 @@ class User::DayTimeline filtered_events.where(created_at: ...day.beginning_of_day).chronologically.last end - def build_column(base_title, index, events) - Column.new(self, base_title, index, events) + def build_column(id, base_title, index, events) + Column.new(self, id, base_title, index, events) end def window diff --git a/app/models/user/day_timeline/column.rb b/app/models/user/day_timeline/column.rb index ce87873fa..0f9261bbb 100644 --- a/app/models/user/day_timeline/column.rb +++ b/app/models/user/day_timeline/column.rb @@ -1,9 +1,10 @@ class User::DayTimeline::Column include ActionView::Helpers::TagHelper, ActionView::Helpers::OutputSafetyHelper, TimeHelper - attr_reader :index, :base_title, :day_timeline, :events + attr_reader :index, :id, :base_title, :day_timeline, :events - def initialize(day_timeline, base_title, index, events) + def initialize(day_timeline, id, base_title, index, events) + @id = id @day_timeline = day_timeline @base_title = base_title @index = index @@ -17,15 +18,6 @@ class User::DayTimeline::Column safe_join(parts, " ") end - def base_title_for_route - case base_title - when "Done" - "closed" - else - base_title.downcase - end - end - def events_by_hour limited_events.group_by { it.created_at.hour } end @@ -38,6 +30,10 @@ class User::DayTimeline::Column full_events_count - limited_events.count end + def to_param + id + end + private def limited_events @limited_events ||= events.limit(100).load diff --git a/app/models/user/settings.rb b/app/models/user/settings.rb index 1651e9a64..598ede9c5 100644 --- a/app/models/user/settings.rb +++ b/app/models/user/settings.rb @@ -21,7 +21,7 @@ class User::Settings < ApplicationRecord end def bundling_emails? - !bundle_email_never? && !user.system? && user.active? + !bundle_email_never? && !user.system? && user.active? && user.verified? end def timezone diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index b635baac5..c5dab1497 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -78,7 +78,8 @@ class Webhook::Delivery < ApplicationRecord end def http - Net::HTTP.new(uri.host, uri.port, ipaddr: resolved_ip).tap do |http| + Net::HTTP.new(uri.host, uri.port).tap do |http| + http.ipaddr = resolved_ip http.use_ssl = (uri.scheme == "https") http.open_timeout = ENDPOINT_TIMEOUT http.read_timeout = ENDPOINT_TIMEOUT diff --git a/app/views/boards/_access_toggle.erb b/app/views/boards/_access_toggle.html.erb similarity index 100% rename from app/views/boards/_access_toggle.erb rename to app/views/boards/_access_toggle.html.erb diff --git a/app/views/boards/columns/_empty_placeholder.html.erb b/app/views/boards/columns/_empty_placeholder.html.erb new file mode 100644 index 000000000..36507c1ff --- /dev/null +++ b/app/views/boards/columns/_empty_placeholder.html.erb @@ -0,0 +1,6 @@ +
+
+

Drag cards here

+
+
No cards here
+
diff --git a/app/views/boards/columns/_list.html.erb b/app/views/boards/columns/_list.html.erb index a3a65ae74..7adedf322 100644 --- a/app/views/boards/columns/_list.html.erb +++ b/app/views/boards/columns/_list.html.erb @@ -1,3 +1,3 @@ -
+
<%= render "cards/display/previews", cards: cards, draggable: draggable %>
diff --git a/app/views/boards/columns/closeds/show.html.erb b/app/views/boards/columns/closeds/show.html.erb index fc8aaddfb..8a9d1f56c 100644 --- a/app/views/boards/columns/closeds/show.html.erb +++ b/app/views/boards/columns/closeds/show.html.erb @@ -17,12 +17,7 @@ <%= render "boards/columns/list", cards: @page.records, draggable: true %> <% end %> <% else %> -
-
-

Drag cards here

-
-
No cards here
-
+ <%= render "boards/columns/empty_placeholder" %> <% end %> <% end %> diff --git a/app/views/boards/columns/not_nows/show.html.erb b/app/views/boards/columns/not_nows/show.html.erb index 2f6623b02..88ea33868 100644 --- a/app/views/boards/columns/not_nows/show.html.erb +++ b/app/views/boards/columns/not_nows/show.html.erb @@ -17,12 +17,7 @@ <%= render "boards/columns/list", cards: @page.records, draggable: true %> <% end %> <% else %> -
-
-

Drag cards here

-
-
No cards here
-
+ <%= render "boards/columns/empty_placeholder" %> <% end %> <% end %> diff --git a/app/views/boards/columns/show.html.erb b/app/views/boards/columns/show.html.erb index 9aafae01f..4fef4eec7 100644 --- a/app/views/boards/columns/show.html.erb +++ b/app/views/boards/columns/show.html.erb @@ -17,12 +17,7 @@ <%= render "boards/columns/list", cards: @page.records, draggable: true %> <% end %> <% else %> -
-
-

Drag cards here

-
-
No cards here
-
+ <%= render "boards/columns/empty_placeholder" %> <% end %> <% end %> diff --git a/app/views/boards/columns/streams/show.html.erb b/app/views/boards/columns/streams/show.html.erb index b1bb7919e..9f0477b39 100644 --- a/app/views/boards/columns/streams/show.html.erb +++ b/app/views/boards/columns/streams/show.html.erb @@ -17,7 +17,7 @@ <%= render "boards/columns/list", cards: @page.records, draggable: true %> <% end %> <% else %> -
No cards here
+ <%= render "boards/columns/empty_placeholder" %> <% end %> <% end %> diff --git a/app/views/boards/edit/_delete.html.erb b/app/views/boards/edit/_delete.html.erb index 94a85d63f..ec346f590 100644 --- a/app/views/boards/edit/_delete.html.erb +++ b/app/views/boards/edit/_delete.html.erb @@ -1,7 +1,16 @@ -<%= form_with model: board, class: "txt-align-center margin-block-start-auto", method: :delete do |form| %> - <%= form.button class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to permanently delete this board and all the cards on it? This can't be undone." } do %> +
+ + +

Delete this board?

+

Are you sure you want to permanently delete this board and all the cards on it? This can't be undone.

+
+ + <%= button_to board_path(board), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %> + Delete board + <% end %> +
+
+
diff --git a/app/views/boards/edit/_users.html.erb b/app/views/boards/edit/_users.html.erb index 3214a2f2c..d8c16484a 100644 --- a/app/views/boards/edit/_users.html.erb +++ b/app/views/boards/edit/_users.html.erb @@ -1,3 +1,4 @@ +<% disabled = !Current.user.can_administer_board?(board) %>

Who can access this board?

@@ -16,7 +17,7 @@ @@ -26,14 +27,14 @@
- + <%= button_tag "Select all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkAll" }, disabled: %> · - + <%= button_tag "Select none", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkNone" }, disabled: %>
<% end %> diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb index 4536af7fc..748028b8d 100644 --- a/app/views/boards/new.html.erb +++ b/app/views/boards/new.html.erb @@ -1,9 +1,9 @@ <% @page_title = "Create a new board" %>
- <%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form" } do |form| %> + <%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>

<%= @page_title %>

- <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off",placeholder: "Name it…", data: { action: "keydown.esc@document->form#cancel" } %> + <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can‘t be blank" } %> + +

Delete this card?

+

Are you sure you want to permanently delete this card?

+
+ + <%= button_to card_path(card), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %> + Delete card + <% end %> +
+
+
diff --git a/app/views/cards/_messages.html.erb b/app/views/cards/_messages.html.erb index 402a39ec0..a2784e72a 100644 --- a/app/views/cards/_messages.html.erb +++ b/app/views/cards/_messages.html.erb @@ -9,7 +9,7 @@ <% if Current.user.can_administer_card?(card) %> <% end %> <% end %> diff --git a/app/views/cards/comments/reactions/new.html.erb b/app/views/cards/comments/reactions/new.html.erb index 38c6daf98..8cc5dda73 100644 --- a/app/views/cards/comments/reactions/new.html.erb +++ b/app/views/cards/comments/reactions/new.html.erb @@ -2,14 +2,14 @@ <%= form_with model: [ @comment.card, @comment, Reaction.new ], class: "reaction reaction__form expanded", html: { aria: { label: "New reaction" } }, - data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit" } do |form| %> + data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %> <%= render "cards/comments/reactions/menu", comment: @comment %> diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index 9c7824b01..c47ea7802 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -1,6 +1,6 @@ <% draggable = local_assigns.fetch(:draggable, false) && card.published? %> -<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item" }, tabindex: 0 do %> +<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item", css_variable_counter_target: "item" }, tabindex: 0 do %>
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %> <%= card.title %> diff --git a/app/views/cards/display/perma/_steps.html.erb b/app/views/cards/display/perma/_steps.html.erb index 36ce68fc5..9f65dec2a 100644 --- a/app/views/cards/display/perma/_steps.html.erb +++ b/app/views/cards/display/perma/_steps.html.erb @@ -4,8 +4,8 @@ <% unless card.closed? %>
  • - <%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit turbo:submit-end->form#reset" } do |form| %> - <%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true" }, aria: { label: "Add a step" } %> + <%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit turbo:submit-end->form#reset" } do |form| %> + <%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a step" } %> <% end %>
  • <% end %> diff --git a/app/views/cards/display/preview/_bubble.html.erb b/app/views/cards/display/preview/_bubble.html.erb index c39772090..40d8e4311 100644 --- a/app/views/cards/display/preview/_bubble.html.erb +++ b/app/views/cards/display/preview/_bubble.html.erb @@ -4,7 +4,7 @@ class: "bubble", data: { controller: "bubble", - action: "turbo:morph-element->bubble#update", + action: "turbo:morph-element->bubble#update:self", bubble_entropy_value: entropy_bubble_options_for(card).to_json, bubble_stalled_value: stalled_bubble_options_for(card)&.to_json } do %> diff --git a/app/views/cards/taggings/new.html.erb b/app/views/cards/taggings/new.html.erb index 644b29d2b..b8ff47df6 100644 --- a/app/views/cards/taggings/new.html.erb +++ b/app/views/cards/taggings/new.html.erb @@ -11,10 +11,10 @@ <%= form_with url: card_taggings_path(@card), id: dom_id(@card, :tags_form), - data: { controller: "form", action: "submit->form#preventEmptySubmit" }, + data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit" }, class: "flex flex-column gap-half full-width margin-block-half" do |form| %> <%= form.text_field :tag_title, placeholder: @tags.any? ? "Add a new tag or filter…" : "Name this tag…", class: "input txt-small full-width", - autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter" } %> + autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter compositionstart->form#compositionStart compositionend->form#compositionEnd" } %> <% end %>
    -<%= turbo_frame_tag "user_events", src: user_events_path(@user) %> +<% if @user.verified? %> + <%= turbo_frame_tag "user_events", src: user_events_path(@user) %> +<% end %> diff --git a/app/views/users/verifications/new.html.erb b/app/views/users/verifications/new.html.erb new file mode 100644 index 000000000..fe830cd28 --- /dev/null +++ b/app/views/users/verifications/new.html.erb @@ -0,0 +1 @@ +<%= auto_submit_form_with url: users_verifications_path, method: :post %> diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb index a98a8acf3..2fe01f070 100644 --- a/app/views/webhooks/show.html.erb +++ b/app/views/webhooks/show.html.erb @@ -22,7 +22,7 @@ <% unless @webhook.active? %>
    - <%= button_to "Reactivate", board_webhook_activation_path(@webhook), method: :post %> + <%= button_to "Reactivate", board_webhook_activation_path(@webhook.board_id, @webhook), method: :post %>
    <% end %> diff --git a/config/deploy.yml b/config/deploy.yml index be1e3c21e..0e855727d 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,65 +1,45 @@ -# Name of your application. Used to uniquely configure containers. +# Name of this app service: fizzy - -# Name of the container image (use your-user/app-name on external registries). image: fizzy -# Deploy to these servers. + +#-- About your deployment --# + +# Where to deploy fizzy servers: web: - - 192.168.0.1 - # job: - # hosts: - # - 192.168.0.1 - # cmd: bin/jobs + - fizzy.example.com # Set your server name here -# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. -# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. -# -# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb! -# -# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer). -# -# proxy: -# ssl: true -# host: app.example.com +# How you connect to your server +ssh: + user: root # If you use a different username to SSH to your server, specify it here -# Where you keep your container images. -registry: - # Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ... - server: localhost:5555 +# Automatic SSL +proxy: + ssl: true # Set this to false if you *don't* want SSL + host: fizzy.example.com # Set your server name here to use automatic SSL - # Needed for authenticated registries. - # username: your-user - - # Always use an access token rather than real password when possible. - # password: - # - KAMAL_REGISTRY_PASSWORD - -# Inject ENV variables into containers (secrets come from .kamal/secrets). +# Your application configuration (secrets come from .kamal/secrets). env: secret: - - RAILS_MASTER_KEY + - SECRET_KEY_BASE + - VAPID_PUBLIC_KEY + - VAPID_PRIVATE_KEY + - SMTP_USERNAME + - SMTP_PASSWORD clear: - # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. - # When you start using multiple servers, you should split out job processing to a dedicated machine. - SOLID_QUEUE_IN_PUMA: true + MAILER_FROM_ADDRESS: support@example.com # The email address that Fizzy sends email from + SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container - # Set number of processes dedicated to Solid Queue (default: 1) - # JOB_CONCURRENCY: 3 - # Set number of cores available to the application on each server (default: 1). - # WEB_CONCURRENCY: 2 +#-- General configuration --# - # Match this to any external database server to configure Active Record correctly - # Use fizzy-db for a db accessory server on same machine via local kamal docker network. - # DB_HOST: 192.168.0.2 +# Use a local registry to deploy +registry: + server: localhost:5555 - # Log everything from Rails - # RAILS_LOG_LEVEL: debug - -# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: -# "bin/kamal logs -r job" will tail logs from the first server in the job section. +# Handy aliases for interacting with your deployment. For eaxmple: `bin/kamal console` will connect to a +# Rails console in production. aliases: console: app exec --interactive --reuse "bin/rails console" shell: app exec --interactive --reuse "bash" @@ -76,45 +56,6 @@ volumes: # version inside the asset_path. asset_path: /rails/public/assets - # Configure the image builder. builder: arch: amd64 - - # # Build image via remote server (useful for faster amd64 builds on arm64 computers) - # remote: ssh://docker@docker-builder-server - # - # # Pass arguments and secrets to the Docker build process - # args: - # RUBY_VERSION: ruby-3.4.7 - # secrets: - # - GITHUB_TOKEN - # - RAILS_MASTER_KEY - -# Use a different ssh user than root -# ssh: -# user: app - -# Use accessory services (secrets come from .kamal/secrets). -# accessories: -# db: -# image: mysql:8.0 -# host: 192.168.0.2 -# # Change to 3306 to expose port to the world instead of just local network. -# port: "127.0.0.1:3306:3306" -# env: -# clear: -# MYSQL_ROOT_HOST: '%' -# secret: -# - MYSQL_ROOT_PASSWORD -# files: -# - config/mysql/production.cnf:/etc/mysql/my.cnf -# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql -# directories: -# - data:/var/lib/mysql -# redis: -# image: valkey/valkey:8 -# host: 192.168.0.2 -# port: 6379 -# directories: -# - data:/data diff --git a/config/environments/production.rb b/config/environments/production.rb index bdb274efb..7b29a00d1 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -3,6 +3,21 @@ require "active_support/core_ext/integer/time" Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. + # Email provider Settings + # + # Configure these according to whichever email provider you use. An example setup + # using SMTP looks like the following: + # + # config.action_mailer.smtp_settings = { + # address: 'smtp.example.com', # The address of your email provider's SMTP server + # port: 2525, + # domain: 'example.com', # Your domain, which Fizzy will send email from + # user_name: ENV["SMTP_USERNAME"], + # password: ENV["SMTP_PASSWORD"], + # authentication: :plain, + # enable_starttls_auto: true + # } + # Code is not reloaded between requests. config.enable_reloading = false diff --git a/config/initializers/action_text.rb b/config/initializers/action_text.rb index b2724b1c7..eee87de0d 100644 --- a/config/initializers/action_text.rb +++ b/config/initializers/action_text.rb @@ -7,7 +7,7 @@ module ActionText # This overrides the default :embeds association! has_many_attached :embeds do |attachable| ::Attachments::VARIANTS.each do |variant_name, variant_options| - attachable.variant variant_name, variant_options.merge(preprocessed: true) + attachable.variant variant_name, **variant_options, process: :immediately end end end diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index d00bd74bf..4a4a0343d 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -4,9 +4,17 @@ ActiveSupport.on_load(:active_storage_blob) do end end -ActiveSupport.on_load(:active_storage_record) do - configure_replica_connections -end +# Don't configure replica connections for ActiveStorage::Record. +# When ActiveStorage uses `connects_to`, it creates a separate connection pool +# from ApplicationRecord. This causes after_commit callbacks to fire in +# non-deterministic order - the Attachment's create_variants callback can fire +# before the User model's upload callback, causing FileNotFoundError when +# using `process: :immediately` for variants. +# See: https://github.com/rails/rails/issues/53694 +# +# ActiveSupport.on_load(:active_storage_record) do +# configure_replica_connections +# end module ActiveStorageControllerExtensions extend ActiveSupport::Concern diff --git a/config/initializers/autotuner.rb b/config/initializers/autotuner.rb index ec8572b11..33eab9a05 100644 --- a/config/initializers/autotuner.rb +++ b/config/initializers/autotuner.rb @@ -3,12 +3,14 @@ Autotuner.enabled = true # This callback is called whenever a suggestion is provided by this gem. -# You can output this report to your logging pipeline, stdout, a file, -# or somewhere else! -Autotuner.reporter = proc do |report| +# Log to structured logging for query/analysis in Loki. This is called +# once per autotuner heuristic. +Autotuner.reporter = proc do |heuristic_report| + report = heuristic_report.to_s + Rails.logger.info "GCAUTOTUNE: #{report}" - if Fizzy.saas? - Sentry.capture_message "Autotuner suggestion", level: :info, extra: { report: report.to_s } - end + RailsStructuredLogging.instrument_script "autotuner" do + Rails.logger.info report.to_s + end if defined? RailsStructuredLogging end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index b3076b38f..fe37a5cde 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,25 +1,45 @@ # Be sure to restart your server when you modify this file. -# Define an application-wide content security policy. -# See the Securing Rails Applications Guide for more information: -# https://guides.rubyonrails.org/security.html#content-security-policy-header +# Define an application-wide Content Security Policy. +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end -# -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +Rails.application.configure do + # Configure with environment variables with fallback to config.x values (via fizzy-sass) + report_uri = ENV.fetch("CSP_REPORT_URI") { config.x.content_security_policy.report_uri } + report_only = + if ENV.key?("CSP_REPORT_ONLY") + ENV["CSP_REPORT_ONLY"] == "true" + else + config.x.content_security_policy.report_only + end + + # Generate nonces for importmap and inline scripts + config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) } + config.content_security_policy_nonce_directives = %w[ script-src ] + + config.content_security_policy do |policy| + policy.default_src :self + policy.script_src :self, "https://challenges.cloudflare.com" + policy.connect_src :self, "https://storage.basecamp.com" + policy.frame_src :self, "https://challenges.cloudflare.com" + + # Don't fight user tools: permit inline styles, data:/https: sources, and + # blob: workers for accessibility extensions, privacy tools, and custom fonts. + policy.style_src :self, :unsafe_inline + policy.img_src :self, "blob:", "data:", "https:" + policy.font_src :self, "data:", "https:" + policy.media_src :self, "blob:", "data:", "https:" + policy.worker_src :self, "blob:" + + policy.object_src :none + policy.base_uri :none + policy.form_action :self + policy.frame_ancestors :self + + # Specify URI for violation reports (e.g., Sentry CSP endpoint) + policy.report_uri report_uri if report_uri + end + + # Report violations without enforcing the policy. + config.content_security_policy_report_only = report_only +end unless ENV["DISABLE_CSP"] diff --git a/config/initializers/error_context.rb b/config/initializers/error_context.rb new file mode 100644 index 000000000..a1b207b6e --- /dev/null +++ b/config/initializers/error_context.rb @@ -0,0 +1,7 @@ +# Lazily add identity and account context to error reports. +# Only evaluated when an error is actually reported. +Rails.error.add_middleware ->(error, context:, **) do + context.merge \ + identity_id: Current.identity&.id, + account_id: Current.account&.external_account_id +end diff --git a/config/initializers/web_push.rb b/config/initializers/web_push.rb index ac960468e..6914dae22 100644 --- a/config/initializers/web_push.rb +++ b/config/initializers/web_push.rb @@ -17,7 +17,15 @@ end module WebPush::PersistentRequest def perform - if @options[:connection] + endpoint_ip = @options[:endpoint_ip] + + if endpoint_ip + http = Net::HTTP.new(uri.host, uri.port, ipaddr: endpoint_ip) + http.use_ssl = true + http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil? + http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil? + http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil? + elsif @options[:connection] http = @options[:connection] else http = Net::HTTP.new(uri.host, uri.port, *proxy_options) diff --git a/config/puma.rb b/config/puma.rb index 8d09b00ac..489ae0779 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -25,7 +25,7 @@ if !Rails.env.local? # worker per CPU, 1 thread per worker and tune it from there. # # https://edgeguides.rubyonrails.org/tuning_performance_for_deployment.html#puma - workers Concurrent.physical_processor_count + workers Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) threads 1, 1 # Tell the Ruby VM that we're finished booting up. diff --git a/config/queue.yml b/config/queue.yml index 130b0d9bd..3f5615fa8 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -5,7 +5,7 @@ default: &default workers: - queues: [ "default", "solid_queue_recurring", "backend", "webhooks" ] threads: 3 - processes: <%= ENV.fetch("JOB_CONCURRENCY", Concurrent.physical_processor_count) %> + processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %> polling_interval: 0.1 development: *default diff --git a/config/routes.rb b/config/routes.rb index 9fa8f81bf..e67e4ef7c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -138,6 +138,7 @@ Rails.application.routes.draw do namespace :users do resources :joins + resources :verifications, only: %i[ new create ] end resource :session do diff --git a/db/migrate/20251205010536_add_verified_at_to_users.rb b/db/migrate/20251205010536_add_verified_at_to_users.rb new file mode 100644 index 000000000..b853cb952 --- /dev/null +++ b/db/migrate/20251205010536_add_verified_at_to_users.rb @@ -0,0 +1,5 @@ +class AddVerifiedAtToUsers < ActiveRecord::Migration[8.2] + def change + add_column :users, :verified_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index b84d3c160..42458db17 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do +ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -726,6 +726,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do t.string "name", null: false t.string "role", default: "member", null: false t.datetime "updated_at", null: false + t.datetime "verified_at" t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true t.index ["account_id", "role"], name: "index_users_on_account_id_and_role" t.index ["identity_id"], name: "index_users_on_identity_id" diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 6f6d6cf53..1dd2b3e00 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do +ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -499,6 +499,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do t.string "name", limit: 255, null: false t.string "role", limit: 255, default: "member", null: false t.datetime "updated_at", null: false + t.datetime "verified_at" t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true t.index ["account_id", "role"], name: "index_users_on_account_id_and_role" t.index ["identity_id"], name: "index_users_on_identity_id" diff --git a/db/seeds.rb b/db/seeds.rb index bb8db7db8..7cba1b5f9 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -36,7 +36,7 @@ else if user = identity.users.find_by(account: Current.account) user else - User.create!(name: full_name, identity: identity, account: Current.account) + User.create!(name: full_name, identity: identity, account: Current.account, verified_at: Time.current) end end diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb index d2865ff46..4c873fb48 100644 --- a/lib/web_push/notification.rb +++ b/lib/web_push/notification.rb @@ -1,13 +1,13 @@ class WebPush::Notification - def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:) + def initialize(title:, body:, path:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:) @title, @body, @path, @badge = title, body, path, badge - @endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key + @endpoint, @endpoint_ip, @p256dh_key, @auth_key = endpoint, endpoint_ip, p256dh_key, auth_key end def deliver(connection: nil) WebPush.payload_send \ message: encoded_message, - endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key, + endpoint: @endpoint, endpoint_ip: @endpoint_ip, p256dh: @p256dh_key, auth: @auth_key, vapid: vapid_identification, connection: connection, urgency: "high" diff --git a/script/create-new-user.rb b/script/create-new-user.rb deleted file mode 100755 index ab30c585e..000000000 --- a/script/create-new-user.rb +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env ruby - -require_relative "../config/environment" - -if ARGV.length < 3 - puts "Usage: #{$0} " - exit 1 -end - -tenant = ARGV.shift -email_address = ARGV.shift -name = ARGV.join(" ") - -begin - ApplicationRecord.with_tenant(tenant) do - password = SecureRandom.hex(16) - - user = User.create!(name:, email_address:, password:) - puts "Created: " - pp user - - puts "Password is: #{password.inspect}" - end -rescue Exception => e - puts "Failed with error: #{e.inspect}" -end diff --git a/script/migrations/20251205-backfill-verified-at.rb b/script/migrations/20251205-backfill-verified-at.rb new file mode 100755 index 000000000..7cd4f0030 --- /dev/null +++ b/script/migrations/20251205-backfill-verified-at.rb @@ -0,0 +1,112 @@ +#!/usr/bin/env ruby + +require_relative "../../config/environment" + +BACKFILL_TIMESTAMP = Time.parse("2025-12-02 12:00:00 UTC") + +def collect_verified_user_ids + verified_ids = Set.new + + # Owners (they created the account) + verified_ids.merge(User.where(role: :owner).pluck(:id)) + puts "After owners: #{verified_ids.size} users" + + # Card creators + verified_ids.merge(Card.distinct.pluck(:creator_id).compact) + puts "After card creators: #{verified_ids.size} users" + + # Comment creators + verified_ids.merge(Comment.distinct.pluck(:creator_id).compact) + puts "After comment creators: #{verified_ids.size} users" + + # Board creators + verified_ids.merge(Board.distinct.pluck(:creator_id).compact) + puts "After board creators: #{verified_ids.size} users" + + # Event creators + verified_ids.merge(Event.distinct.pluck(:creator_id).compact) + puts "After event creators: #{verified_ids.size} users" + + # Assigners (not assignees - they could be assigned without logging in) + verified_ids.merge(Assignment.distinct.pluck(:assigner_id).compact) + puts "After assigners: #{verified_ids.size} users" + + # Manual closers (user_id is nil for automatic closures) + verified_ids.merge(Closure.where.not(user_id: nil).distinct.pluck(:user_id).compact) + puts "After closers: #{verified_ids.size} users" + + # Manual postponers (user_id is nil for automatic entropy postponements) + verified_ids.merge(Card::NotNow.where.not(user_id: nil).distinct.pluck(:user_id).compact) + puts "After postponers: #{verified_ids.size} users" + + # Reactors + verified_ids.merge(Reaction.distinct.pluck(:reacter_id).compact) + puts "After reactors: #{verified_ids.size} users" + + # Filter creators + verified_ids.merge(Filter.distinct.pluck(:creator_id).compact) + puts "After filter creators: #{verified_ids.size} users" + + # Pinners + verified_ids.merge(Pin.distinct.pluck(:user_id).compact) + puts "After pinners: #{verified_ids.size} users" + + # Board accessors (accessed_at is touched when viewing boards) + verified_ids.merge(Access.where.not(accessed_at: nil).distinct.pluck(:user_id).compact) + puts "After board accessors: #{verified_ids.size} users" + + # Export requesters + verified_ids.merge(Account::Export.distinct.pluck(:user_id).compact) + puts "After export requesters: #{verified_ids.size} users" + + # Push subscribers + verified_ids.merge(Push::Subscription.distinct.pluck(:user_id).compact) + puts "After push subscribers: #{verified_ids.size} users" + + # Users who completed setup (name != email) + verified_ids.merge( + User.joins(:identity) + .where.not("users.name = identities.email_address") + .pluck(:id) + ) + puts "After setup completers: #{verified_ids.size} users" + + # Users whose identity has at least one session + verified_ids.merge( + User.where(identity_id: Session.distinct.select(:identity_id)).pluck(:id) + ) + puts "After identity sessions: #{verified_ids.size} users" + + verified_ids +end + +puts "Collecting verified user IDs..." +verified_user_ids = collect_verified_user_ids + +puts "\nFiltering to unverified users only..." +users_to_update = User.where(id: verified_user_ids.to_a) + .where(verified_at: nil) + .where(active: true) + .where.not(identity_id: nil) + .where.not(role: :system) + +update_count = users_to_update.count +puts "Found #{update_count} users to backfill" + +# Report remaining unverified users (before update) +remaining_before = User.where(verified_at: nil, active: true) + .where.not(identity_id: nil) + .where.not(role: :system) + .count +remaining_after = remaining_before - update_count +puts "\nCurrently unverified active users: #{remaining_before}" +puts "After backfill, remaining unverified: #{remaining_after}" +puts "These users will need to verify on next login." + +if update_count > 0 + puts "\nBackfilling verified_at..." + updated = users_to_update.update_all(verified_at: BACKFILL_TIMESTAMP) + puts "Updated #{updated} users" +end + +puts "\nDone!" diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 816a2e7b3..a7e3580de 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -145,4 +145,49 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end + + test "disables select all/none buttons for non-privileged user" do + logout_and_sign_in_as :jz + assert_not users(:jz).can_administer_board?(boards(:writebook)) + + get edit_board_path(boards(:writebook)) + + assert_response :success + assert_select "button[disabled]", text: "Select all" + assert_select "button[disabled]", text: "Select none" + end + + test "enables select all/none buttons for privileged user" do + assert users(:kevin).can_administer_board?(boards(:writebook)) + + get edit_board_path(boards(:writebook)) + + assert_response :success + assert_select "button:not([disabled])", text: "Select all" + assert_select "button:not([disabled])", text: "Select none" + end + + test "access toggle disabled state is cached correctly" do + board = boards(:writebook) + david = users(:david) + + with_actionview_partial_caching do + # privileged user + assert users(:kevin).can_administer_board?(board) + + get edit_board_path(board) + + assert_response :success + assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}']:not([disabled])" + + # unprivileged user + logout_and_sign_in_as :jz + assert_not users(:jz).can_administer_board?(board) + + get edit_board_path(board) + + assert_response :success + assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]" + end + end end diff --git a/test/controllers/join_codes_controller_test.rb b/test/controllers/join_codes_controller_test.rb index fe8c820d9..415bde3e5 100644 --- a/test/controllers/join_codes_controller_test.rb +++ b/test/controllers/join_codes_controller_test.rb @@ -36,7 +36,7 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to session_magic_link_url(script_name: nil) - assert_equal new_users_join_url(script_name: @account.slug), session[:return_to_after_authenticating] + assert_equal new_users_verification_url(script_name: @account.slug), session[:return_to_after_authenticating] end test "create for existing identity" do @@ -55,7 +55,7 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to landing_url(script_name: @account.slug) end - test "create for signed-in identity without a user in the account redirects to user setup" do + test "create for signed-in identity without a user in the account redirects to verification" do identity = identities(:mike) sign_in_as :mike @@ -67,6 +67,31 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest end end - assert_redirected_to new_users_join_url(script_name: @account.slug) + assert_redirected_to new_users_verification_url(script_name: @account.slug) + end + + test "create for different identity terminates existing session" do + sign_in_as :kevin + + assert_difference -> { Identity.count }, 1 do + assert_difference -> { User.count }, 1 do + post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "new_user@example.com" } + end + end + + assert_redirected_to session_magic_link_url(script_name: nil) + assert_not_predicate cookies[:session_token], :present? + end + + test "create with invalid email address" do + # Avoid Sentry exceptions when attackers try to stuff invalid emails into the system + without_action_dispatch_exception_handling do + assert_no_difference -> { Identity.count } do + assert_no_difference -> { User.count } do + post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "not-a-valid-email" } + end + end + assert_response :unprocessable_entity + end end end diff --git a/test/controllers/notifications/settings_controller_test.rb b/test/controllers/notifications/settings_controller_test.rb new file mode 100644 index 000000000..d8472bb02 --- /dev/null +++ b/test/controllers/notifications/settings_controller_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class Notifications::SettingsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:david) + + sign_in_as @user + end + + test "show" do + get notifications_settings_path + + assert_response :success + end + + test "update email frequency" do + assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do + put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } } + end + + assert_redirected_to notifications_settings_path + assert_equal "Settings updated", flash[:notice] + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index f2318bf46..7ebe4d518 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -36,15 +36,29 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end end - private - test "destroy" do - sign_in_as :kevin - + test "create with invalid email address" do + # Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form + # field validation that should normally prevent this from occurring, so I'm not worried about + # returning proper validation errors. + without_action_dispatch_exception_handling do untenanted do - delete session_path + assert_no_difference -> { Identity.count } do + post session_path, params: { email_address: "not-a-valid-email" } + end - assert_redirected_to new_session_path - assert_not cookies[:session_token].present? + assert_response :unprocessable_entity end end + end + + test "destroy" do + sign_in_as :kevin + + untenanted do + delete session_path + + assert_redirected_to new_session_path + assert_not cookies[:session_token].present? + end + end end diff --git a/test/controllers/signups_controller_test.rb b/test/controllers/signups_controller_test.rb index 9d65c7a55..17314ee90 100644 --- a/test/controllers/signups_controller_test.rb +++ b/test/controllers/signups_controller_test.rb @@ -34,6 +34,20 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest end end + test "create with invalid email address" do + without_action_dispatch_exception_handling do + untenanted do + assert_no_difference -> { Identity.count } do + assert_no_difference -> { MagicLink.count } do + post signup_path, params: { signup: { email_address: "not-a-valid-email" } } + end + end + + assert_response :unprocessable_entity + end + end + end + test "create for an authenticated user" do identity = identities(:kevin) sign_in_as identity diff --git a/test/controllers/users/push_subscriptions_controller_test.rb b/test/controllers/users/push_subscriptions_controller_test.rb index 9c77dec9d..c41d8313e 100644 --- a/test/controllers/users/push_subscriptions_controller_test.rb +++ b/test/controllers/users/push_subscriptions_controller_test.rb @@ -1,12 +1,15 @@ require "test_helper" class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest + PUBLIC_TEST_IP = "142.250.185.206" + setup do sign_in_as :david + stub_dns_resolution(PUBLIC_TEST_IP) end test "create new push subscription" do - subscription_params = { "endpoint" => "https://apple", "p256dh_key" => "123", "auth_key" => "456" } + subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" } post user_push_subscriptions_path(users(:david)), params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" } @@ -19,7 +22,7 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest test "touch existing subscription" do existing_subscription = users(:david).push_subscriptions.create!( - endpoint: "https://apple", + endpoint: "https://fcm.googleapis.com/fcm/send/abc123", p256dh_key: "123", auth_key: "456" ) @@ -37,7 +40,7 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest test "destroy a push subscription" do subscription = users(:david).push_subscriptions.create!( - endpoint: "https://apple", + endpoint: "https://fcm.googleapis.com/fcm/send/abc123", p256dh_key: "123", auth_key: "456" ) @@ -47,4 +50,35 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to user_push_subscriptions_path(users(:david)) end end + + test "rejects subscription with non-permitted endpoint" do + subscription_params = { "endpoint" => "https://attacker.example.com/steal", "p256dh_key" => "123", "auth_key" => "456" } + + assert_no_difference -> { Push::Subscription.count } do + post user_push_subscriptions_path(users(:david)), + params: { push_subscription: subscription_params } + end + + assert_response :unprocessable_entity + end + + test "rejects subscription with endpoint resolving to private IP" do + stub_dns_resolution("192.168.1.1") + + subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" } + + assert_no_difference -> { Push::Subscription.count } do + post user_push_subscriptions_path(users(:david)), + params: { push_subscription: subscription_params } + end + + assert_response :unprocessable_entity + end + + private + def stub_dns_resolution(*ips) + dns_mock = mock("dns") + dns_mock.stubs(:each_address).multiple_yields(*ips) + Resolv::DNS.stubs(:open).yields(dns_mock) + end end diff --git a/test/controllers/users/verifications_controller_test.rb b/test/controllers/users/verifications_controller_test.rb new file mode 100644 index 000000000..25ecdba1c --- /dev/null +++ b/test/controllers/users/verifications_controller_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class Users::VerificationsControllerTest < ActionDispatch::IntegrationTest + test "new renders the auto-submit form" do + sign_in_as :david + + get new_users_verification_path + + assert_response :ok + end + + test "create verifies the user and redirects to join" do + sign_in_as :david + + user = users(:david) + user.update_column(:verified_at, nil) + assert_not user.verified? + + post users_verifications_path + + assert_redirected_to new_users_join_path + assert user.reload.verified? + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 081579dfc..385bacaf3 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -64,4 +64,26 @@ class UsersControllerTest < ActionDispatch::IntegrationTest delete user_path(users(:david)) assert_response :forbidden end + + test "update with invalid avatar shows validation error" do + sign_in_as :kevin + + svg_file = fixture_file_upload("avatar.svg", "image/svg+xml") + + put user_path(users(:kevin)), params: { user: { avatar: svg_file } } + assert_response :unprocessable_entity + assert_select "form[action='#{user_path(users(:kevin))}']" + assert_select ".txt-negative", text: /must be a JPEG, PNG, GIF, or WebP image/ + end + + test "update with valid avatar" do + sign_in_as :kevin + + png_file = fixture_file_upload("avatar.png", "image/png") + + put user_path(users(:kevin)), params: { user: { avatar: png_file } } + assert_redirected_to user_path(users(:kevin)) + assert users(:kevin).reload.avatar.attached? + assert_equal "image/png", users(:kevin).avatar.content_type + end end diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb index 2cd427b5b..c9316172d 100644 --- a/test/controllers/webhooks_controller_test.rb +++ b/test/controllers/webhooks_controller_test.rb @@ -14,6 +14,10 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest webhook = webhooks(:active) get board_webhook_path(webhook.board, webhook) assert_response :success + + webhook = webhooks(:inactive) + get board_webhook_path(webhook.board, webhook) + assert_response :success end test "new" do @@ -63,6 +67,11 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest get edit_board_webhook_path(webhook.board, webhook) assert_response :success assert_select "form" + + webhook = webhooks(:inactive) + get edit_board_webhook_path(webhook.board, webhook) + assert_response :success + assert_select "form" end test "update with valid params" do diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 4c7dad414..cf4d9e6d0 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -4,6 +4,7 @@ david: role: member identity: david account: 37s_uuid + verified_at: <%= Time.current.to_fs(:db) %> jz: id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %> @@ -11,6 +12,7 @@ jz: role: member identity: jz account: 37s_uuid + verified_at: <%= Time.current.to_fs(:db) %> jason: id: <%= ActiveRecord::FixtureSet.identify("jason", :uuid) %> @@ -18,6 +20,7 @@ jason: role: owner identity: jason account: 37s_uuid + verified_at: <%= Time.current.to_fs(:db) %> kevin: id: <%= ActiveRecord::FixtureSet.identify("kevin", :uuid) %> @@ -25,6 +28,7 @@ kevin: role: admin identity: kevin account: 37s_uuid + verified_at: <%= Time.current.to_fs(:db) %> system: id: <%= ActiveRecord::FixtureSet.identify("system", :uuid) %> @@ -38,6 +42,7 @@ mike: role: admin identity: mike account: initech_uuid + verified_at: <%= Time.current.to_fs(:db) %> system_initech: id: <%= ActiveRecord::FixtureSet.identify("system_initech", :uuid) %> diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 490d6e32e..75da41fb9 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -44,6 +44,8 @@ class AccountTest < ActiveSupport::TestCase assert owner.admin?, "owner should also be considered an admin" assert_predicate account.system_user, :present? + + assert owner.verified?, "owner should be verified on account creation" end end diff --git a/test/models/card/pinnable_test.rb b/test/models/card/pinnable_test.rb new file mode 100644 index 000000000..d19348afd --- /dev/null +++ b/test/models/card/pinnable_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Card::PinnableTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + end + + test "broadcasts pin update when title changes" do + assert_broadcasted_pin_update do + cards(:logo).update!(title: "New title") + end + end + + test "broadcasts pin update when column changes" do + assert_broadcasted_pin_update do + cards(:logo).update!(column: columns(:writebook_in_progress)) + end + end + + test "broadcasts pin update when board changes" do + assert_broadcasted_pin_update do + cards(:logo).update!(board: boards(:private), column: nil) + end + end + + test "does not broadcast pin update when other properties change" do + perform_enqueued_jobs do + assert_turbo_stream_broadcasts([ pins(:logo_kevin).user, :pins_tray ], count: 0) do + cards(:logo).update!(last_active_at: Time.current) + end + end + end + + private + def assert_broadcasted_pin_update(&block) + perform_enqueued_jobs do + assert_turbo_stream_broadcasts([ pins(:logo_kevin).user, :pins_tray ], &block) + end + end +end diff --git a/test/models/comment_test.rb b/test/models/comment_test.rb index c519f8fbd..13a5e36c3 100644 --- a/test/models/comment_test.rb +++ b/test/models/comment_test.rb @@ -4,4 +4,24 @@ class CommentTest < ActiveSupport::TestCase setup do Current.session = sessions(:david) end + + test "rich text embed variants are processed immediately on attachment" do + comment = cards(:logo).comments.create!(body: "Check this out") + comment.body.body.attachables # force load + + blob = ActiveStorage::Blob.create_and_upload! \ + io: File.open(file_fixture("moon.jpg")), + filename: "moon.jpg", + content_type: "image/jpeg" + + comment.body.body = ActionText::Content.new(comment.body.body.to_html).append_attachables(blob) + comment.save! + + embed = comment.body.embeds.sole + + Attachments::VARIANTS.each_key do |variant_name| + variant = embed.variant(variant_name) + assert variant.processed?, "Expected #{variant_name} variant to be processed immediately" + end + end end diff --git a/test/models/identity_test.rb b/test/models/identity_test.rb index 2e7687169..5b5763ffd 100644 --- a/test/models/identity_test.rb +++ b/test/models/identity_test.rb @@ -13,6 +13,23 @@ class IdentityTest < ActiveSupport::TestCase end end + test "email address format validation" do + invalid_emails = [ + "sam smith@example.com", # space in local part + "@example.com", # missing local part + "test@", # missing domain + "test", # missing @ and domain + "