diff --git a/.gitattributes b/.gitattributes index 8dc432343..31eeee0b6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,5 +5,3 @@ db/schema.rb linguist-generated # Mark any vendored files as having been vendored. vendor/* linguist-vendored -config/credentials/*.yml.enc diff=rails_credentials -config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dbb544dc1..1efb99ec1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ registries: type: git url: https://github.com username: x-access-token - password: ${{secrets.GH_TOKEN}} + password: ${{secrets.FIZZY_GH_TOKEN}} updates: - package-ecosystem: bundler diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index d097b9590..773674233 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -7,6 +7,20 @@ permissions: contents: read jobs: + gemfile-drift: + name: Gemfile drift + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Check for lockfile drift + run: bin/bundle-drift check + security: name: Security runs-on: ubuntu-latest diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml index aa98a8e93..afb91c04d 100644 --- a/.github/workflows/ci-saas.yml +++ b/.github/workflows/ci-saas.yml @@ -18,4 +18,4 @@ jobs: with: saas: true secrets: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index beee16f15..777471774 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: type: boolean required: true secrets: - GH_TOKEN: + FIZZY_GH_TOKEN: required: false permissions: @@ -50,7 +50,7 @@ jobs: MYSQL_USER: root FIZZY_DB_HOST: 127.0.0.1 FIZZY_DB_PORT: 3306 - BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }} + BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} steps: - name: Install system packages diff --git a/.gitignore b/.gitignore index 8ef4e9012..38bd80d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,3 @@ /config/credentials/*.key .DS_Store - -.kamal/* \ No newline at end of file diff --git a/Gemfile b/Gemfile index e8a5f8514..8c6d39d0f 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source "https://rubygems.org" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } -gem "rails", github: "rails/rails", branch: "ast-immediate-variants-process-locally" +gem "rails", github: "rails/rails", branch: "main" # Assets & front end gem "importmap-rails" diff --git a/Gemfile.lock b/Gemfile.lock index d99375463..3a9430a61 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,8 +13,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 - branch: ast-immediate-variants-process-locally + revision: 4f4e0acaf558f6adf7df3ac23a51beb470463901 + branch: main specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -117,8 +117,8 @@ GEM specs: action_text-trix (2.1.15) railties - addressable (2.8.8) - public_suffix (>= 2.0.2, < 8.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) @@ -151,8 +151,8 @@ GEM brakeman (7.1.1) racc builder (3.3.0) - bundler-audit (0.9.3) - bundler (>= 1.2.0) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) thor (~> 1.0) capybara (3.40.0) addressable @@ -185,15 +185,11 @@ GEM tzinfo faker (3.5.2) i18n (>= 1.8.11, < 2) - ffi (1.17.2) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) - ffi (1.17.2-x86-linux-gnu) - ffi (1.17.2-x86-linux-musl) - ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) fugit (1.12.1) @@ -260,7 +256,6 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - mini_portile2 (2.8.9) minitest (5.26.2) mission_control-jobs (1.1.0) actioncable (>= 7.1) @@ -272,7 +267,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.1) + mittens (0.3.0) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -293,9 +288,6 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.18.10) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-aarch64-linux-musl) @@ -306,8 +298,6 @@ GEM racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-darwin) - racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) @@ -332,7 +322,7 @@ GEM psych (5.2.6) date stringio - public_suffix (7.0.0) + public_suffix (6.0.2) puma (7.1.0) nio4r (~> 2.0) raabro (1.4.0) @@ -366,10 +356,10 @@ GEM io-console (~> 0.5) rexml (3.4.4) rouge (4.6.1) - rqrcode (3.1.1) + rqrcode (3.1.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.1) + rqrcode_core (2.0.0) rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -388,7 +378,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.1) + rubocop-rails (2.34.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -427,18 +417,13 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.8.1) - mini_portile2 (~> 2.8.0) - sqlite3 (2.8.1-aarch64-linux-gnu) - sqlite3 (2.8.1-aarch64-linux-musl) - sqlite3 (2.8.1-arm-linux-gnu) - sqlite3 (2.8.1-arm-linux-musl) - sqlite3 (2.8.1-arm64-darwin) - sqlite3 (2.8.1-x86-linux-gnu) - sqlite3 (2.8.1-x86-linux-musl) - sqlite3 (2.8.1-x86_64-darwin) - sqlite3 (2.8.1-x86_64-linux-gnu) - sqlite3 (2.8.1-x86_64-linux-musl) + sqlite3 (2.8.0-aarch64-linux-gnu) + sqlite3 (2.8.0-aarch64-linux-musl) + sqlite3 (2.8.0-arm-linux-gnu) + sqlite3 (2.8.0-arm-linux-musl) + sqlite3 (2.8.0-arm64-darwin) + sqlite3 (2.8.0-x86_64-linux-gnu) + sqlite3 (2.8.0-x86_64-linux-musl) sshkit (1.24.0) base64 logger @@ -453,7 +438,6 @@ GEM thruster (0.1.16) thruster (0.1.16-aarch64-linux) thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-darwin) thruster (0.1.16-x86_64-linux) timeout (0.4.4) trilogy (2.9.0) @@ -497,11 +481,6 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin - arm64-linux - ruby - x86-linux-gnu - x86-linux-musl - x86_64-darwin x86_64-linux x86_64-linux-gnu x86_64-linux-musl diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 91c11943f..3d12fc6c7 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: a14df11b57818697df4b2cc7b6a43e762ebaa196 + revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143 specs: fizzy-saas (0.1.0) audits1984 @@ -50,7 +50,7 @@ GIT GIT remote: https://github.com/basecamp/queenbee-plugin - revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2 + revision: 14312a940471e20617b38cdec7c092a01567d18b specs: queenbee (3.2.0) activeresource @@ -82,8 +82,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 - branch: ast-immediate-variants-process-locally + revision: 4f4e0acaf558f6adf7df3ac23a51beb470463901 + branch: main specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) diff --git a/README.md b/README.md index 100d46b0b..41b78dcd1 100644 --- a/README.md +++ b/README.md @@ -2,201 +2,27 @@ 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. +## Running your own Fizzy instance -(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).) +If you want to run your own Fizzy instance, but don't need to change its code, you can use our pre-built Docker image. +You'll need access to a server on which you can run Docker, and you'll need to configure some options to customize your installation. -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. +You can find the details of how to do a Docker-based deployment in our [Docker deployment guide](docs/docker-deployment.md). -The steps to configure your very own Fizzy are: - -1. Fork the repo -2. Initialize Kamal by running `kamal init`. This command generates the `.kamal` directory along with the required configuration files, including `.kamal/secrets`. -3. Edit few things in `config/deploy.yml` and `.kamal/secrets` -4. 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. -- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this. - -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. -- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use. -- `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}" - ``` - -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 -``` - -## File storage (Active Storage) - -Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`. - -To use the included `s3` service, set: - -- `ACTIVE_STORAGE_SERVICE=s3` -- `S3_ACCESS_KEY_ID` -- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`) -- `S3_REGION` (defaults to `us-east-1`) -- `S3_SECRET_ACCESS_KEY` - -Optional for S3-compatible endpoints: - -- `S3_ENDPOINT` -- `S3_FORCE_PATH_STYLE=true` -- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`) -- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`) +If you want more flexibility to customize your Fizzy installation by changing its code, and deploy those changes to your server, then we recommend you deploy Fizzy with Kamal. You can find a complete walkthrough of doing that in our [Kamal deployment guide](docs/kamal-deployment.md). ## Development -### Setting up +You are welcome -- and encouraged -- to modify Fizzy to your liking. +Please see our [Development guide](docs/development.md) for how to get Fizzy set up for local development. -First, get everything installed and configured with: - -```sh -bin/setup -bin/setup --reset # Reset the database and seed it -``` - -And then run the development server: - -```sh -bin/dev -``` - -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: - -```sh -bin/rails test -``` - -The full continuous integration tests can be run with: - -```sh -bin/ci -``` - -### Database configuration - -Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL: - -```sh -DATABASE_ADAPTER=mysql bin/setup --reset -DATABASE_ADAPTER=mysql bin/ci -``` - -The remote CI pipeline will run tests against both SQLite and MySQL. - -### Outbound Emails - -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: - -```sh -bin/rails dev:email -``` - -Under the hood, this will create or remove `tmp/email-dev.txt`. - -## 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. + ## License Fizzy is released under the [O'Saasy License](LICENSE.md). diff --git a/app/assets/images/monitor.svg b/app/assets/images/monitor.svg new file mode 100644 index 000000000..a1140a2a8 --- /dev/null +++ b/app/assets/images/monitor.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/moon.svg b/app/assets/images/moon.svg new file mode 100644 index 000000000..8db6c1b09 --- /dev/null +++ b/app/assets/images/moon.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/sun.svg b/app/assets/images/sun.svg new file mode 100644 index 000000000..6dd6e54c8 --- /dev/null +++ b/app/assets/images/sun.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 52aa53805..dc928b40d 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: -apple-system, BlinkMacSystemFont, sans-serif; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; --font-serif: ui-serif, serif; --font-mono: ui-monospace, monospace; @@ -255,8 +255,117 @@ --color-gradient-2: oklch(var(--lch-pink-lighter)); --color-gradient-3: oklch(var(--lch-purple-lighter)); --color-gradient-4: var(--color-canvas); +} - @media (prefers-color-scheme: dark) { +/* Dark mode - explicit theme choice overrides system preference */ +html[data-theme="dark"] { + --lch-canvas: 20% 0.0195 232.58; + --lch-ink-inverted: var(--lch-black); + + --lch-ink-darkest: 96.02% 0.0034 260; + --lch-ink-darker: 86% 0.0061 260; + --lch-ink-dark: 73.97% 0.009 260; + --lch-ink-medium: 62% 0.0122 260; + --lch-ink-light: 40% 0.0148 260; + --lch-ink-lighter: 30% 0.0178 260; + --lch-ink-lightest: 25% 0.0204 260; + + --lch-uncolor-darkest: 96.09% 0.0076 100; + --lch-uncolor-darker: 86% 0.021 90; + --lch-uncolor-dark: 73.93% 0.041 80; + --lch-uncolor-medium: 62% 0.0552 70; + --lch-uncolor-light: 40% 0.0387 60; + --lch-uncolor-lighter: 30% 0.012 50; + --lch-uncolor-lightest: 25% 0.0017 40; + + --lch-red-darkest: 95.85% 0.0218 46; + --lch-red-darker: 86% 0.086 44; + --lch-red-dark: 73.95% 0.139 42; + --lch-red-medium: 62% 0.154 40; + --lch-red-light: 40% 0.088 38; + --lch-red-lighter: 30% 0.032 36; + --lch-red-lightest: 25% 0.011 34; + + --lch-yellow-darkest: 96% 0.056 100; + --lch-yellow-darker: 86% 0.103 90; + --lch-yellow-dark: 74.06% 0.136 80; + --lch-yellow-medium: 62.1% 0.146 70; + --lch-yellow-light: 40% 0.0736 60; + --lch-yellow-lighter: 30% 0.026 50; + --lch-yellow-lightest: 25% 0.01 40; + + --lch-lime-darkest: 96.04% 0.066 115; + --lch-lime-darker: 86% 0.098 114; + --lch-lime-dark: 73.97% 0.121 113; + --lch-lime-medium: 62% 0.128 112; + --lch-lime-light: 40% 0.0637 111; + --lch-lime-lighter: 30% 0.024 110; + --lch-lime-lightest: 25% 0.012 109; + + --lch-green-darkest: 96.12% 0.035 143; + --lch-green-darker: 86% 0.082 144; + --lch-green-dark: 73.99% 0.117 145; + --lch-green-medium: 62% 0.1261 146; + --lch-green-light: 40% 0.065 147; + --lch-green-lighter: 30% 0.03 148; + --lch-green-lightest: 25% 0.018 149; + + --lch-aqua-darkest: 96.15% 0.0244 202; + --lch-aqua-darker: 86% 0.06 204; + --lch-aqua-dark: 73.92% 0.095 206; + --lch-aqua-medium: 62% 0.106 208; + --lch-aqua-light: 40% 0.0594 210; + --lch-aqua-lighter: 30% 0.028 212; + --lch-aqua-lightest: 25% 0.017 214; + + --lch-blue-darkest: 95.93% 0.0217 252; + --lch-blue-darker: 86% 0.068 254; + --lch-blue-dark: 74% 0.1293 256; + --lch-blue-medium: 62% 0.159 258; + --lch-blue-light: 40% 0.094 260; + --lch-blue-lighter: 30% 0.0452 262; + --lch-blue-lightest: 25% 0.0318 264; + + --lch-violet-darkest: 95.97% 0.019 280; + --lch-violet-darker: 86% 0.068 282; + --lch-violet-dark: 74.08% 0.142 284; + --lch-violet-medium: 62% 0.184 286; + --lch-violet-light: 40% 0.108 288; + --lch-violet-lighter: 30% 0.048 290; + --lch-violet-lightest: 25% 0.025 292; + + --lch-purple-darkest: 95.99% 0.0217 302; + --lch-purple-darker: 86% 0.068 304; + --lch-purple-dark: 73.98% 0.141 306; + --lch-purple-medium: 62% 0.177 308; + --lch-purple-light: 40% 0.099 310; + --lch-purple-lighter: 30% 0.04 312; + --lch-purple-lightest: 25% 0.017 314; + + --lch-pink-darkest: 95.84% 0.0308 336; + --lch-pink-darker: 86% 0.074 338; + --lch-pink-dark: 74.04% 0.1294 340; + --lch-pink-medium: 62% 0.166 342; + --lch-pink-light: 40% 0.085 344; + --lch-pink-lighter: 30% 0.03 346; + --lch-pink-lightest: 25% 0.011 348; + + --color-terminal-bg: var(--color-canvas); + --color-terminal-text-light: oklch(var(--lch-green-dark)); + --color-golden: oklch(var(--lch-blue-medium)); + --color-highlight: oklch(var(--lch-blue-lighter)); + + --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42), + 0 0.2em 1.6em -0.8em oklch(var(--lch-black) / 0.6), + 0 0.4em 2.4em -1em oklch(var(--lch-black) / 0.7), + 0 0.4em 0.8em -1.2em oklch(var(--lch-black) / 0.8), + 0 0.8em 1.2em -1.6em oklch(var(--lch-black) / 0.9), + 0 1.2em 1.6em -2em oklch(var(--lch-black) / 1); +} + +/* Fallback to system preference when no explicit theme is set */ +@media (prefers-color-scheme: dark) { + html:not([data-theme]) { --lch-canvas: 20% 0.0195 232.58; --lch-ink-inverted: var(--lch-black); diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/bar.css index 86183ccb0..e13badb7b 100644 --- a/app/assets/stylesheets/bar.css +++ b/app/assets/stylesheets/bar.css @@ -19,10 +19,16 @@ view-transition-name: bar; z-index: var(--z-bar); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { border-block: 1px solid var(--color-ink-lighter); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + border-block: 1px solid var(--color-ink-lighter); + } + } + &:has(.bar__placeholder[hidden]) { padding-inline: 1ch; } diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css index 31c4180e1..05d2b016e 100644 --- a/app/assets/stylesheets/base.css +++ b/app/assets/stylesheets/base.css @@ -55,9 +55,15 @@ ::selection { background: var(--color-selected); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { background-color: var(--color-selected-dark); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + background-color: var(--color-selected-dark); + } + } } :where(ul, ol):where([role="list"]) { diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 274d80ceb..4ee5f199e 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -27,10 +27,16 @@ } } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --btn-hover-brightness: 1.25; } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --btn-hover-brightness: 1.25; + } + } + &[disabled], &:has([disabled]), [disabled] &[type=submit], diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index ac690ac30..c239e1b9d 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -179,9 +179,15 @@ outline: var(--focus-ring-size) solid var(--color-selected-dark); outline-offset: var(--focus-ring-offset); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { outline-color: oklch(var(--lch-blue-medium)); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + outline-color: oklch(var(--lch-blue-medium)); + } + } } } @@ -658,38 +664,39 @@ /* -------------------------------------------------------------------------- */ /* Surface a mini bubble if there are cards with bubbles inside */ - .cards--considering:has(.bubble:not([hidden])), - .cards--doing.is-collapsed:has(.bubble:not([hidden])) { - .cards__transition-container { - --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); - --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; + .cards--considering:has(.bubble:not([hidden])) .cards__expander-title, + .cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container { + --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); + --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; + + &:before { + background: radial-gradient( + color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%, + color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100% + ); + block-size: 1em; + border-radius: var(--bubble-shape); + content: ""; + inline-size: 1em; + inset: 0 0 auto auto; + position: absolute; + translate: 20% -20%; + z-index: 1; + } + + /* Maybe column: position bubble relative to the title, not the container */ + .cards--considering & { + overflow: visible; + position: relative; &:before { - background: radial-gradient( - color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%, - color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100% - ); - block-size: 1em; - border-radius: var(--bubble-shape); - content: ""; - inline-size: 1em; - inset: 0 0 auto auto; - position: absolute; - translate: 20% -20%; - z-index: 1; - } - - .cards--considering &:before { - inset: 0 auto auto 0; - translate: 100% 75%; + inset-block-start: 50%; + translate: 125% -75%; + z-index: -1; } } } - .cards--considering:has(.bubble:not([hidden])) .cards__expander-title:before { - translate: 120% 50%; - } - /* Card column indicators /* -------------------------------------------------------------------------- */ @@ -758,7 +765,7 @@ block-size: 1px; inline-size: 100%; inset: 50% 0 auto; - translaate: 0 -50%; + translate: 0 -50%; } &:after { diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 9fe9c6601..3fb0a7f4d 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -27,10 +27,16 @@ text-align: start; z-index: 1; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + box-shadow: 0 0 0 1px var(--color-ink-lighter); + } + } + .popup { inline-size: 260px; } diff --git a/app/assets/stylesheets/circled-text.css b/app/assets/stylesheets/circled-text.css index 9c3e87f48..dcb971d51 100644 --- a/app/assets/stylesheets/circled-text.css +++ b/app/assets/stylesheets/circled-text.css @@ -12,9 +12,15 @@ opacity: 0.5; mix-blend-mode: multiply; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { mix-blend-mode: screen; } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + mix-blend-mode: screen; + } + } } span::before, diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 8e9f19e89..2e269b9d0 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -72,6 +72,8 @@ .icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); } .icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); } .icon--minus { --svg: url("minus.svg "); } + .icon--monitor { --svg: url("monitor.svg "); } + .icon--moon { --svg: url("moon.svg "); } .icon--move { --svg: url("move.svg "); } .icon--notification-bell-access-only { --svg: url("bell.svg "); } .icon--notification-bell-watching { --svg: url("bell-off.svg "); } @@ -96,6 +98,7 @@ .icon--settings { --svg: url("settings.svg "); } .icon--share { --svg: url("share.svg "); } .icon--sliders { --svg: url("sliders.svg "); } + .icon--sun { --svg: url("sun.svg "); } .icon--switch { --svg: url("switch.svg "); } .icon--tag { --svg: url("tag.svg "); } .icon--tag-outline { --svg: url("tag-outline.svg "); } diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 109bfbb26..252ef2835 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -104,17 +104,25 @@ .input--select { --input-border-radius: 2em; --input-padding: 0.5em 1.8em 0.5em 1.2em; + --caret-icon: 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"); + --caret-icon-dark: 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"); -webkit-appearance: none; appearance: none; - 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-image: var(--caret-icon); background-size: 0.5em; background-position: center right 0.9em; background-repeat: no-repeat; text-align: start; + html[data-theme="dark"] & { + --caret-icon: var(--caret-icon-dark); + } + @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"); + html:not([data-theme]) & { + --caret-icon: var(--caret-icon-dark); + } } option { diff --git a/app/assets/stylesheets/knobs.css b/app/assets/stylesheets/knobs.css index cf73d217b..0605b12cb 100644 --- a/app/assets/stylesheets/knobs.css +++ b/app/assets/stylesheets/knobs.css @@ -24,6 +24,7 @@ block-size: var(--knob-size); inline-size: var(--knob-size); inset: 50% auto auto 50%; + opacity: 0; position: absolute; translate: -50% -50%; z-index: 1; diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index f89028f02..c90d5f6b9 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -55,10 +55,13 @@ } .lexxy-code-language-picker { + --caret-icon: 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"); + --caret-icon-dark: 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"); + -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-image: var(--caret-icon); background-position: center right 0.9em; background-repeat: no-repeat; background-size: 0.5em; @@ -75,8 +78,14 @@ padding-inline: 1.5ch 1.8em; text-align: start; + html[data-theme="dark"] & { + --caret-icon: var(--caret-icon-dark); + } + @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"); + html:not([data-theme]) & { + --caret-icon: var(--caret-icon-dark); + } } option { diff --git a/app/assets/stylesheets/markdown.css b/app/assets/stylesheets/markdown.css index 5dfac832c..f6aef0e39 100644 --- a/app/assets/stylesheets/markdown.css +++ b/app/assets/stylesheets/markdown.css @@ -24,8 +24,15 @@ } } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { filter: invert(1); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + filter: invert(1); + } + } + } } diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index b50189366..57d1803bd 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -23,9 +23,15 @@ background: var(--color-ink-lighter); } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --input-background: var(--color-ink-lighter); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --input-background: var(--color-ink-lighter); + } + } } } @@ -221,7 +227,7 @@ .nav__blank-slate { font-size: var(--text-small); - margin: 1rem auto; + margin: 2rem auto; .nav:has(.popup__item:not([hidden])) & { display: none; diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index ed6187bae..797e9a7cb 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -95,9 +95,16 @@ &:not(.expanded):hover { filter: brightness(var(--reaction-hover-brightness)); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --reaction-hover-brightness: 1.25; } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --reaction-hover-brightness: 1.25; + } + } + } } } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index c9877f4bf..70f095bca 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -29,6 +29,7 @@ } :where(ol, ul) { + list-style: disc; padding-inline-start: 3ch; } diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index c5390e793..96368841e 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -69,8 +69,8 @@ summary { .search__empty { margin-block: 3em; - opacity: 0.66; - text-align: center; + margin-inline: auto; + inline-size: fit-content; } .search__excerpt { diff --git a/app/assets/stylesheets/syntax.css b/app/assets/stylesheets/syntax.css index 7ddc2cf42..7da03d67b 100644 --- a/app/assets/stylesheets/syntax.css +++ b/app/assets/stylesheets/syntax.css @@ -14,7 +14,7 @@ --markup-deleted: lch(39.64 68.17 31.45); /* Redefine named color values for dark mode */ - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] { --keyword: lch(67.63 58.99 30.64); --entity: lch(75.13 46.73 306.74); --constant: lch(74.9 39.71 255.53); @@ -28,6 +28,22 @@ --markup-deleted: lch(73.8% 65 29.18); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) { + --keyword: lch(67.63 58.99 30.64); + --entity: lch(75.13 46.73 306.74); + --constant: lch(74.9 39.71 255.53); + --string: lch(74.9 39.71 255.53); + --variable: lch(76.17 61.1 61.97); + --comment: lch(60.83 6.66 254.46); + --entity-tag: lch(83.65 59.31 141.61); + --markup-heading: lch(47.93 71.67 280.72); + --markup-list: lch(83.84 57.9 85.03); + --markup-inserted: lch(83.65 59.31 141.61); + --markup-deleted: lch(73.8% 65 29.18); + } + } + color: var(--color-ink); .w { diff --git a/app/assets/stylesheets/theme-switcher.css b/app/assets/stylesheets/theme-switcher.css new file mode 100644 index 000000000..4b3b1bae2 --- /dev/null +++ b/app/assets/stylesheets/theme-switcher.css @@ -0,0 +1,33 @@ +@layer components { + .theme-switcher { + @media (max-width: 479px) { + --row-gap: 1ch; + + flex-direction: column; + } + } + + .theme-switcher__btn { + --btn-background: var(--color-ink-lightest); + --btn-border-radius: 0.4em; + --btn-border-size: 0; + --btn-gap: 0.1lh; + --btn-padding: 1em; + --icon-size: 2em; + + column-gap: var(--inline-space); + flex: 1; + flex-direction: column; + position: relative; + white-space: nowrap; + + &:has(input:checked) { + --btn-background: var(--color-selected); + --btn-color: var(--color-ink); + } + + @media (max-width: 479px) { + flex-direction: row; + } + } +} diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 46c4b74b7..3cefc1ecc 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -300,9 +300,16 @@ border-radius: 0; } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + box-shadow: 0 0 0 1px var(--color-ink-lighter); + } + } + } .card__background { diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 8223d3801..83706ddf4 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -61,6 +61,6 @@ class CardsController < ApplicationController end def card_params - params.expect(card: [ :status, :title, :description, :image, :created_at, tag_ids: [] ]) + params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ]) end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 30f54820b..deb8b3fa3 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -6,6 +6,7 @@ module Authentication before_action :require_authentication after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? + helper_method :email_address_pending_authentication etag { Current.identity.id if authenticated? } @@ -37,7 +38,7 @@ module Authentication def require_account unless Current.account.present? - redirect_to session_menu_url(script_name: nil) + redirect_to main_app.session_menu_path(script_name: nil) end end @@ -78,11 +79,11 @@ module Authentication end def redirect_authenticated_user - redirect_to root_url if authenticated? + redirect_to main_app.root_url if authenticated? end def redirect_tenanted_request - redirect_to root_url if Current.account.present? + redirect_to main_app.root_url if Current.account.present? end def start_new_session_for(identity) @@ -107,10 +108,24 @@ module Authentication end end + def email_address_pending_authentication_matches?(email_address) + if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "") + session.delete(:email_address_pending_authentication) + true + else + false + end + end + + def email_address_pending_authentication + session[:email_address_pending_authentication] + end + def redirect_to_session_magic_link(magic_link, return_to: nil) serve_development_magic_link(magic_link) + session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link session[:return_to_after_authenticating] = return_to if return_to - redirect_to session_magic_link_url(script_name: nil) + redirect_to main_app.session_magic_link_path(script_name: nil) end def serve_development_magic_link(magic_link) diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 81bd0c781..1afb878ef 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -3,7 +3,6 @@ module Authorization included do before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } - before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated? end class_methods do @@ -27,11 +26,7 @@ module Authorization end def ensure_can_access_account - redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? - end - - def ensure_only_staff_can_access_non_production_remote_environments - head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff? + redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active? end def redirect_existing_user diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb index 95fa12892..c8481beb3 100644 --- a/app/controllers/concerns/request_forgery_protection.rb +++ b/app/controllers/concerns/request_forgery_protection.rb @@ -12,16 +12,21 @@ module RequestForgeryProtection end def verified_request? - super || safe_fetch_site? || request.format.json? + request.get? || request.head? || !protect_against_forgery? || + (valid_request_origin? && safe_fetch_site?) end SAFE_FETCH_SITES = %w[ same-origin same-site ] def safe_fetch_site? - SAFE_FETCH_SITES.include?(sec_fetch_site_value) + SAFE_FETCH_SITES.include?(sec_fetch_site_value) || (sec_fetch_site_value.nil? && api_request?) + end + + def api_request? + request.format.json? end def sec_fetch_site_value - request.headers["Sec-Fetch-Site"].to_s.downcase + request.headers["Sec-Fetch-Site"].to_s.downcase.presence end end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 71fa1b9ac..c0632407a 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -2,6 +2,7 @@ class Sessions::MagicLinksController < ApplicationController disallow_account_scope require_unauthenticated_access rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" } + before_action :ensure_that_email_address_pending_authentication_exists layout "public" @@ -10,14 +11,28 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - start_new_session_for magic_link.identity - redirect_to after_sign_in_url(magic_link) + authenticate_with magic_link else redirect_to session_magic_link_path, flash: { shake: true } end end private + def ensure_that_email_address_pending_authentication_exists + unless email_address_pending_authentication.present? + redirect_to new_session_path, alert: "Enter your email address to sign in." + end + end + + def authenticate_with(magic_link) + if email_address_pending_authentication_matches?(magic_link.identity.email_address) + start_new_session_for magic_link.identity + redirect_to after_sign_in_url(magic_link) + else + redirect_to new_session_path, alert: "Authentication failed. Please try again." + end + end + def code params.expect(:code) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index fb9fe0617..f5044bd2a 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -14,7 +14,8 @@ class SessionsController < ApplicationController else signup = Signup.new(email_address: email_address) if signup.valid?(:identity_creation) - redirect_to_session_magic_link signup.create_identity + magic_link = signup.create_identity if Account.accepting_signups? + redirect_to_session_magic_link magic_link else head :unprocessable_entity end diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index 776421201..56614158f 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -3,6 +3,7 @@ class SignupsController < ApplicationController allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } before_action :redirect_authenticated_user + before_action :enforce_tenant_limit layout "public" @@ -24,6 +25,10 @@ class SignupsController < ApplicationController redirect_to new_signup_completion_path if authenticated? end + def enforce_tenant_limit + redirect_to new_session_url unless Account.accepting_signups? + end + def signup_params params.expect signup: :email_address end diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index d85e5f869..420dbe5b6 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -1,6 +1,4 @@ class Users::AvatarsController < ApplicationController - include ActiveStorage::Streaming - allow_unauthenticated_access only: :show before_action :set_user diff --git a/app/helpers/login_helper.rb b/app/helpers/login_helper.rb index f5e89c8d1..6004282a6 100644 --- a/app/helpers/login_helper.rb +++ b/app/helpers/login_helper.rb @@ -1,7 +1,5 @@ module LoginHelper def login_url - # Use main_app because this helper may be invoked from an engine controller - # that inherits from AdminController. main_app.new_session_path(script_name: nil) end diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js new file mode 100644 index 000000000..154bf4fc0 --- /dev/null +++ b/app/javascript/controllers/theme_controller.js @@ -0,0 +1,49 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["lightButton", "darkButton", "autoButton"] + + connect() { + this.#applyStoredTheme() + } + + setLight() { + this.#theme = "light" + } + + setDark() { + this.#theme = "dark" + } + + setAuto() { + this.#theme = "auto" + } + + get #storedTheme() { + return localStorage.getItem("theme") || "auto" + } + + set #theme(theme) { + localStorage.setItem("theme", theme) + + if (theme === "auto") { + document.documentElement.removeAttribute("data-theme") + } else { + document.documentElement.setAttribute("data-theme", theme) + } + + this.#updateButtons() + } + + #applyStoredTheme() { + this.#theme = this.#storedTheme + } + + #updateButtons() { + const storedTheme = this.#storedTheme + + if (this.hasLightButtonTarget) { this.lightButtonTarget.checked = (storedTheme === "light") } + if (this.hasDarkButtonTarget) { this.darkButtonTarget.checked = (storedTheme === "dark") } + if (this.hasAutoButtonTarget) { this.autoButtonTarget.checked = (storedTheme !== "light" && storedTheme !== "dark") } + } +} diff --git a/app/javascript/helpers/http_helpers.js b/app/javascript/helpers/http_helpers.js deleted file mode 100644 index 1fb986f70..000000000 --- a/app/javascript/helpers/http_helpers.js +++ /dev/null @@ -1,4 +0,0 @@ -export const HttpStatus = { - CONFLICT: 409, - UNPROCESSABLE: 422 -} diff --git a/app/models/account.rb b/app/models/account.rb index 34aaf64ae..df0b845b0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Account::Storage, Entropic, Seedeable + include Account::Storage, Entropic, MultiTenantable, Seedeable has_one :join_code has_many :users, dependent: :destroy diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb index 79160cbaa..6438d3ab8 100644 --- a/app/models/account/join_code.rb +++ b/app/models/account/join_code.rb @@ -11,8 +11,8 @@ class Account::JoinCode < ApplicationRecord before_create :generate_code, if: -> { code.blank? } def redeem_if(&block) - transaction do - increment!(:usage_count) if block.call(account) + with_lock do + increment!(:usage_count) if active? && block.call(account) end end diff --git a/app/models/account/multi_tenantable.rb b/app/models/account/multi_tenantable.rb new file mode 100644 index 000000000..7d1ef5d39 --- /dev/null +++ b/app/models/account/multi_tenantable.rb @@ -0,0 +1,13 @@ +module Account::MultiTenantable + extend ActiveSupport::Concern + + included do + cattr_accessor :multi_tenant, default: false + end + + class_methods do + def accepting_signups? + multi_tenant || Account.none? + end + end +end diff --git a/app/models/card/activity_spike/detector.rb b/app/models/card/activity_spike/detector.rb index b533318e3..143561c39 100644 --- a/app/models/card/activity_spike/detector.rb +++ b/app/models/card/activity_spike/detector.rb @@ -27,7 +27,7 @@ class Card::ActivitySpike::Detector def multiple_people_commented?(minimum_comments: 3, minimum_participants: 2) card.comments - .where("created_at >= ?", recent_period.seconds.ago) + .where(created_at: recent_period.seconds.ago..) .group(:card_id) .having("COUNT(*) >= ?", minimum_comments) .having("COUNT(DISTINCT creator_id) >= ?", minimum_participants) @@ -51,6 +51,6 @@ class Card::ActivitySpike::Detector end def last_event - card.events.order(created_at: :desc).first + card.events.order(:created_at).last end end diff --git a/app/models/card/closeable.rb b/app/models/card/closeable.rb index 51852c9a5..e4cea509f 100644 --- a/app/models/card/closeable.rb +++ b/app/models/card/closeable.rb @@ -7,9 +7,9 @@ module Card::Closeable scope :closed, -> { joins(:closure) } scope :open, -> { where.missing(:closure) } - scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) } - scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) } - scope :closed_by, ->(users) { closed.where("closures.user_id": Array(users)) } + scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) } + scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) } + scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) } end def closed? diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb index 520fd95d4..94544656d 100644 --- a/app/models/card/eventable.rb +++ b/app/models/card/eventable.rb @@ -4,7 +4,7 @@ module Card::Eventable include ::Eventable included do - before_create { self.last_active_at = Time.current } + before_create { self.last_active_at ||= created_at || Time.current } after_save :track_title_change, if: :saved_change_to_title? end @@ -12,7 +12,7 @@ module Card::Eventable def event_was_created(event) transaction do create_system_comment_for(event) - touch_last_active_at + touch_last_active_at unless was_just_published? end end diff --git a/app/models/card/stallable.rb b/app/models/card/stallable.rb index 545cc87ee..03348dfde 100644 --- a/app/models/card/stallable.rb +++ b/app/models/card/stallable.rb @@ -7,7 +7,7 @@ module Card::Stallable has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy scope :with_activity_spikes, -> { joins(:activity_spike) } - scope :stalled, -> { open.active.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } + scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } before_update :remember_to_detect_activity_spikes after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes? diff --git a/app/models/comment.rb b/app/models/comment.rb index a396a904f..d0315eea8 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -10,8 +10,8 @@ class Comment < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :desc } scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) } - scope :by_system, -> { joins(:creator).where(creator: { role: "system" }) } - scope :by_user, -> { joins(:creator).where.not(creator: { role: "system" }) } + scope :by_system, -> { joins(:creator).where(creator: { role: :system }) } + scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) } after_create_commit :watch_card_by_creator diff --git a/app/models/filter.rb b/app/models/filter.rb index 9dac977bd..9bfefe176 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -29,7 +29,7 @@ class Filter < ApplicationRecord result = result.where(creator_id: creators.ids) if creators.present? result = result.where(board: boards.ids) if boards.present? result = result.tagged_with(tags.ids) if tags.present? - result = result.where("cards.created_at": creation_window) if creation_window + result = result.where(cards: { created_at: creation_window }) if creation_window result = result.closed_at_window(closure_window) if closure_window result = result.closed_by(closers) if closers.present? result = terms.reduce(result) do |result, term| diff --git a/app/models/magic_link/code.rb b/app/models/magic_link/code.rb index 2bcc0c4e6..0a7d97e59 100644 --- a/app/models/magic_link/code.rb +++ b/app/models/magic_link/code.rb @@ -1,18 +1,17 @@ module MagicLink::Code - CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze class << self def generate(length) - Array.new(length) { CODE_ALPHABET[SecureRandom.random_number(CODE_ALPHABET.length)] }.join + SecureRandom.base32(length) end def sanitize(code) - return nil if code.blank? - - normalize_code(code) - .then { apply_substitutions(_1) } - .then { remove_invalid_characters(_1) } + if code.present? + normalize_code(code) + .then { apply_substitutions(it) } + .then { remove_invalid_characters(it) } + end end private @@ -25,7 +24,7 @@ module MagicLink::Code end def remove_invalid_characters(code) - code.gsub(/[^#{CODE_ALPHABET.join}]/, "") + code.gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, "") end end end diff --git a/app/models/user.rb b/app/models/user.rb index 20ee1c40c..6503881a3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,8 +14,6 @@ class User < ApplicationRecord has_many :pinned_cards, through: :pins, source: :card has_many :exports, class_name: "Account::Export", dependent: :destroy - scope :with_avatars, -> { preload(:account, :avatar_attachment) } - def deactivate transaction do accesses.destroy_all diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index c635edddb..400738cb1 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -2,13 +2,20 @@ module User::Avatar extend ActiveSupport::Concern ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze + MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze included do has_one_attached :avatar do |attachable| attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately end - validate :avatar_content_type_allowed + scope :with_avatars, -> { preload(:account, :avatar_attachment) } + + validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached? + end + + def avatar_attached? + avatar.attached? end def avatar_thumbnail @@ -17,8 +24,23 @@ module User::Avatar private def avatar_content_type_allowed - if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) + if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image") end end + + def avatar_dimensions_allowed + return unless avatar.blob.analyzed? || avatar.blob.analyze + + width = avatar.blob.metadata[:width] + height = avatar.blob.metadata[:height] + + if width && width > MAX_AVATAR_DIMENSIONS[:width] + errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px") + end + + if height && height > MAX_AVATAR_DIMENSIONS[:height] + errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px") + end + end end diff --git a/app/views/account/settings/_entropy.html.erb b/app/views/account/settings/_entropy.html.erb index 53f5064ce..daac70a4a 100644 --- a/app/views/account/settings/_entropy.html.erb +++ b/app/views/account/settings/_entropy.html.erb @@ -1,6 +1,6 @@

Auto close

-

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for specific period of time. This is the default, global setting — you can override it on each board.

+

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. This is the default, global setting — you can override it on each board.

<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %> diff --git a/app/views/cards/container/_gild.html.erb b/app/views/cards/container/_gild.html.erb index e3152eb9a..7647a3b4f 100644 --- a/app/views/cards/container/_gild.html.erb +++ b/app/views/cards/container/_gild.html.erb @@ -6,6 +6,6 @@ <% else %> <%= button_to card_goldness_path(card), class: "btn", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click" } do %> <%= icon_tag "golden-ticket" %> - Promote to golden (shift+g) + Promote to Golden Ticket (shift+g) <% end %> <% end %> diff --git a/app/views/layouts/_theme_preference.html.erb b/app/views/layouts/_theme_preference.html.erb new file mode 100644 index 000000000..0c33750bd --- /dev/null +++ b/app/views/layouts/_theme_preference.html.erb @@ -0,0 +1,6 @@ +<%= javascript_tag nonce: true do %> + const theme = localStorage.getItem("theme") + if (theme && theme !== "auto") { + document.documentElement.dataset.theme = theme + } +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d2af3e959..6aef1f1bc 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -2,7 +2,7 @@ <%= render "layouts/shared/head" %> - +