Fix conflict and move the update from README to docs/kamal-deployment.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,4 +18,4 @@ jobs:
|
||||
with:
|
||||
saas: true
|
||||
secrets:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,5 +41,3 @@
|
||||
|
||||
/config/credentials/*.key
|
||||
.DS_Store
|
||||
|
||||
.kamal/*
|
||||
@@ -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"
|
||||
|
||||
+18
-39
@@ -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
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h5v2H7v2h10v-2h-2v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/></svg>
|
||||
|
After Width: | Height: | Size: 190 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M21.64 13.24A9 9 0 1 1 10.76 2.36a9.07 9.07 0 0 0 10.88 10.88z"/></svg>
|
||||
|
After Width: | Height: | Size: 141 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
}
|
||||
|
||||
:where(ol, ul) {
|
||||
list-style: disc;
|
||||
padding-inline-start: 3ch;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Users::AvatarsController < ApplicationController
|
||||
include ActiveStorage::Streaming
|
||||
|
||||
allow_unauthenticated_access only: :show
|
||||
|
||||
before_action :set_user
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const HttpStatus = {
|
||||
CONFLICT: 409,
|
||||
UNPROCESSABLE: 422
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<header>
|
||||
<h2 class="divider txt-large">Auto close</h2>
|
||||
<p class="margin-none-block-start">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. <em>This is the default, global setting — you can override it on each board.</em></p>
|
||||
<p class="margin-none-block-start">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. <em>This is the default, global setting — you can override it on each board.</em></p>
|
||||
</header>
|
||||
|
||||
<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>
|
||||
|
||||
@@ -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" %>
|
||||
<span class="for-screen-reader">Promote to golden (shift+g)</span>
|
||||
<span class="for-screen-reader">Promote to Golden Ticket (shift+g)</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<%= javascript_tag nonce: true do %>
|
||||
const theme = localStorage.getItem("theme")
|
||||
if (theme && theme !== "auto") {
|
||||
document.documentElement.dataset.theme = theme
|
||||
}
|
||||
<% end %>
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<%= render "layouts/shared/head" %>
|
||||
|
||||
<body data-controller="local-time timezone-cookie turbo-navigation" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation">
|
||||
<body data-controller="local-time timezone-cookie turbo-navigation theme" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation">
|
||||
<header class="header <%= @header_class %>" id="header">
|
||||
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
|
||||
<%= render "my/menu" if Current.user %>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
<% turbo_refreshes_with method: :morph, scroll: :preserve %>
|
||||
|
||||
<%= render "layouts/theme_preference" %>
|
||||
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<section class="notifications-list notifications-list--read panel panel--wide center borderless unpad flex flex-column gap-half margin-block-start">
|
||||
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
|
||||
<% if page.records.any? %>
|
||||
<section class="notifications-list notifications-list--read panel panel--wide center borderless unpad flex flex-column gap-half margin-block-start">
|
||||
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
|
||||
|
||||
<div id="notifications_list_read" contents>
|
||||
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
|
||||
</div>
|
||||
</section>
|
||||
<div id="notifications_list_read" contents>
|
||||
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="notifications-list__empty-message pad border-radius border translucent" style="--border-style: dashed; --border-color: var(--color-ink); --border-radius: 0.2em;">
|
||||
<strong>Nothing new for you.</strong>
|
||||
<div class="notifications-list__empty-message blank-slate blank-slate--empty align-self-center">
|
||||
Nothing new for you
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click", style: "view-transistion-name: card-board-title;" %>
|
||||
<%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click", style: "view-transition-name: card-board-title;" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="search">
|
||||
<div class="search__results">
|
||||
<% unless page.used? %>
|
||||
<div class="search__empty">
|
||||
<div class="search__empty blank-slate blank-slate--empty">
|
||||
No matches
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] || flash[:shake] %>">
|
||||
<header>
|
||||
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
|
||||
<p class="margin-none-block-start txt-medium">Then enter the verification code included in the email below:</p>
|
||||
<p class="margin-none-block-start txt-medium">
|
||||
Then enter the verification code included in the email below:
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %>
|
||||
@@ -13,7 +15,7 @@
|
||||
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %>
|
||||
<% end %>
|
||||
|
||||
<p class="txt-small">The code you receive will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
|
||||
<p class="txt-small">The code sent to <strong><%= email_address_pending_authentication %></strong> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
|
||||
</div>
|
||||
|
||||
<% if Rails.env.development? && flash[:magic_link_code].present? %>
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we’ll get you signed in.</p>
|
||||
<% if Account.accepting_signups? %>
|
||||
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we’ll get you signed in.</p>
|
||||
<% else %>
|
||||
<p>Enter your email and we’ll get you signed in.</p>
|
||||
<% end %>
|
||||
|
||||
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
|
||||
<span>Let’s go</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p class="subtitle">Confirm your email address change</p>
|
||||
|
||||
<%= link_to "Yes use use this email address", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: "btn" %>
|
||||
<%= link_to "Yes use this email address", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: "btn" %>
|
||||
|
||||
<p class="margin-block-start-double">If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="flex flex-column align-center">
|
||||
<h2 class="divider txt-large margin-none-block full-width">Developer</h2>
|
||||
<p class="margin-none">Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<section>
|
||||
<strong class="divider txt-large">Appearance</strong>
|
||||
|
||||
<div class="theme-switcher flex gap max-width justify-center txt-small margin-block-start-half">
|
||||
<label class="btn theme-switcher__btn">
|
||||
<%= icon_tag "sun" %>
|
||||
<span class="overflow-ellipsis">Always light</span>
|
||||
<input type="radio" name="theme" data-action="click->theme#setLight" data-theme-target="lightButton">
|
||||
</label>
|
||||
|
||||
<label class="btn theme-switcher__btn" >
|
||||
<%= icon_tag "moon" %>
|
||||
<span class="overflow-ellipsis">Always dark</span>
|
||||
<input type="radio" name="theme" data-action="click->theme#setDark" data-theme-target="darkButton">
|
||||
</label>
|
||||
|
||||
<label class="btn theme-switcher__btn">
|
||||
<%= icon_tag "monitor" %>
|
||||
<span class="overflow-ellipsis">Same as OS</span>
|
||||
<input type="radio" name="theme" data-action="click->theme#setAuto" data-theme-target="autoButton">
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,13 +1,14 @@
|
||||
<div class="flex flex-column align-center gap">
|
||||
<div class="flex flex-column gap align-center margin-block-double">
|
||||
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
|
||||
|
||||
<header class="full-width">
|
||||
<h2 class="divider txt-large">Link a device</h2>
|
||||
<p class="margin-none-block" id="session_transfer_label">Use this link to sign-in on another device</p>
|
||||
</header>
|
||||
|
||||
<label class="flex flex-column gap full-width">
|
||||
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
|
||||
<header>
|
||||
<strong class="divider txt-large">Devices</strong>
|
||||
<p class="margin-none" id="session_transfer_label">Link to automatically log in on another device.</p>
|
||||
</header>
|
||||
<span class="flex align-center gap margin-inline">
|
||||
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="flex align-center gap">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<%= @page_title %>
|
||||
</h1>
|
||||
<p class="margin-none-block-start margin-block-end-half">
|
||||
That email confirmation link is no longer valid—they expire after 30 minues. You’ll have to try again.
|
||||
That email confirmation link is no longer valid—they expire after 30 minutes. You’ll have to try again.
|
||||
</p>
|
||||
|
||||
<%= link_to "Change my email address", new_user_email_address_path(user_id: @user, script_name: @user.account.slug), class: "btn btn--link center" %>
|
||||
|
||||
@@ -37,28 +37,22 @@
|
||||
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if Current.user == @user %>
|
||||
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
|
||||
|
||||
<%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
|
||||
<span>Sign out of Fizzy on this device</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<% if Current.user == @user %>
|
||||
<section class="panel shadow" style="--panel-size: 45ch;">
|
||||
<%= render "users/theme" %>
|
||||
<%= render "users/transfer", user: @user %>
|
||||
|
||||
<div class="flex flex-column align-center gap margin-block-start-double">
|
||||
<header class="full-width">
|
||||
<h2 class="divider txt-large margin-none-block">API</h2>
|
||||
</header>
|
||||
|
||||
<div class="flex align-center gap txt-normal">
|
||||
<%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="center margin-block-start-double">
|
||||
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %>
|
||||
<span>Sign out of Fizzy</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render "users/access_tokens" %>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo
|
||||
gum style --foreground 135 --bold "▸ OSS: Gemfile"
|
||||
gum style --foreground 240 "bundle $*"
|
||||
BUNDLE_GEMFILE=Gemfile bundle "$@"
|
||||
|
||||
echo
|
||||
gum style --foreground 135 --bold "▸ SaaS: Gemfile.saas"
|
||||
gum style --foreground 240 "bundle $*"
|
||||
BUNDLE_GEMFILE=Gemfile.saas bundle "$@"
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Checks that Gemfile.lock and Gemfile.saas.lock are in sync for shared dependencies.
|
||||
# Since Gemfile.saas evals Gemfile, shared gems should have identical versions.
|
||||
#
|
||||
# Usage:
|
||||
# bin/bundle-drift [check] # check for drift (default subcommand)
|
||||
# bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative)
|
||||
require "bundler"
|
||||
require "fileutils"
|
||||
|
||||
GEMFILE_LOCK = "Gemfile.lock"
|
||||
GEMFILE_SAAS_LOCK = "Gemfile.saas.lock"
|
||||
|
||||
class GemfileDriftChecker
|
||||
def initialize
|
||||
@oss_lockfile = parse_lockfile(GEMFILE_LOCK)
|
||||
@saas_lockfile = parse_lockfile(GEMFILE_SAAS_LOCK)
|
||||
end
|
||||
|
||||
def check
|
||||
find_drift.tap do
|
||||
report it
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def parse_lockfile(path)
|
||||
Bundler::LockfileParser.new(File.read(path))
|
||||
end
|
||||
|
||||
def find_drift
|
||||
oss_specs, saas_specs = specs_hash(@oss_lockfile), specs_hash(@saas_lockfile)
|
||||
shared_gems = oss_specs.keys & saas_specs.keys
|
||||
|
||||
shared_gems.filter_map do |name|
|
||||
oss_version, saas_version = oss_specs[name], saas_specs[name]
|
||||
if oss_version != saas_version
|
||||
{ name: name, oss: oss_version, saas: saas_version }
|
||||
end
|
||||
end.sort_by { |d| d[:name] }
|
||||
end
|
||||
|
||||
def specs_hash(lockfile)
|
||||
lockfile.specs.to_h { |spec| [ spec.name, spec.version.to_s ] }
|
||||
end
|
||||
|
||||
def report(drift)
|
||||
if drift.empty?
|
||||
puts "✓ Gemfile.lock and Gemfile.saas.lock are in sync"
|
||||
else
|
||||
puts "✗ Gemfile lock files have drifted!\n\n"
|
||||
|
||||
name_width = [ drift.map { |d| d[:name].length }.max, "Gem".length ].max
|
||||
oss_width = [ drift.map { |d| d[:oss].length }.max, "Gemfile.lock".length ].max
|
||||
saas_width = [ drift.map { |d| d[:saas].length }.max, "Gemfile.saas.lock".length ].max
|
||||
|
||||
puts " #{"Gem".ljust(name_width)} #{"Gemfile.lock".ljust(oss_width)} Gemfile.saas.lock"
|
||||
puts " #{"-" * name_width} #{"-" * oss_width} #{"-" * saas_width}"
|
||||
|
||||
drift.each do |d|
|
||||
puts " #{d[:name].ljust(name_width)} #{d[:oss].ljust(oss_width)} #{d[:saas]}"
|
||||
end
|
||||
|
||||
puts "\nRun 'bin/bundle-drift correct' to restore alignment."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class GemfileDriftCorrector
|
||||
def correct
|
||||
drift = GemfileDriftChecker.new.check
|
||||
return puts "\nNothing to correct." if drift.empty?
|
||||
|
||||
puts "\nRestoring alignment (Gemfile.saas.lock is authoritative)...\n\n"
|
||||
|
||||
# Save original for diff
|
||||
original_content = File.read(GEMFILE_LOCK)
|
||||
|
||||
# Seed Gemfile.lock with Gemfile.saas.lock - Bundler will use these as version hints
|
||||
FileUtils.cp(GEMFILE_SAAS_LOCK, GEMFILE_LOCK)
|
||||
|
||||
# Re-lock: Bundler prunes SaaS-only gems while preserving shared versions
|
||||
puts "▸ Re-locking Gemfile (seeded from Gemfile.saas.lock)"
|
||||
unless system("BUNDLE_GEMFILE=Gemfile bundle lock")
|
||||
File.write(GEMFILE_LOCK, original_content)
|
||||
abort("Failed to lock Gemfile. Restored original.")
|
||||
end
|
||||
|
||||
puts "\n▸ Verifying alignment"
|
||||
new_drift = GemfileDriftChecker.new.check
|
||||
|
||||
if new_drift.empty?
|
||||
puts "\n✓ Lock files are now in sync"
|
||||
show_diff(original_content, File.read(GEMFILE_LOCK))
|
||||
else
|
||||
puts "\n✗ Lock files still have drift after correction."
|
||||
puts " Bundler couldn't resolve to matching versions."
|
||||
puts " Restoring original Gemfile.lock."
|
||||
File.write(GEMFILE_LOCK, original_content)
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def show_diff(original, corrected)
|
||||
require "tempfile"
|
||||
|
||||
Tempfile.create("gemfile-lock-original") do |f|
|
||||
f.write(original)
|
||||
f.flush
|
||||
|
||||
diff = `diff -u #{f.path} #{GEMFILE_LOCK} 2>/dev/null`
|
||||
unless diff.empty?
|
||||
puts "\nChanges made to Gemfile.lock:"
|
||||
puts diff
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case command = ARGV[0] || "check"
|
||||
when "check"
|
||||
exit 1 unless GemfileDriftChecker.new.check.empty?
|
||||
when "correct"
|
||||
GemfileDriftCorrector.new.correct
|
||||
else
|
||||
abort "Usage: bin/bundle-drift [check|correct]"
|
||||
end
|
||||
@@ -11,6 +11,7 @@ CI.run do
|
||||
|
||||
step "Style: Ruby", "bin/rubocop"
|
||||
|
||||
step "Gemfile: Drift check", "bin/bundle-drift check"
|
||||
step "Security: Gem audit", "bin/bundler-audit check --update"
|
||||
step "Security: Importmap audit", "bin/importmap audit"
|
||||
step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
|
||||
|
||||
@@ -30,6 +30,7 @@ env:
|
||||
clear:
|
||||
MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from
|
||||
SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email
|
||||
MULTI_TENANT: false # Set to true to allow multiple accounts to sign up
|
||||
SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ Rails.application.configure do
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
if Rails.root.join("tmp/minio-dev.txt").exist?
|
||||
config.active_storage.service = :devminio
|
||||
config.x.content_security_policy.connect_src = "http://minio.localhost:39000"
|
||||
config.x.content_security_policy.img_src = "http://minio.localhost:39000"
|
||||
else
|
||||
config.active_storage.service = :local
|
||||
end
|
||||
@@ -83,9 +85,14 @@ Rails.application.configure do
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
end
|
||||
|
||||
config.hosts = %w[ fizzy.localhost localhost 127.0.0.1 ] + [ /^fizzy-\d+(:\d+)$/ ]
|
||||
config.hosts = [
|
||||
"fizzy.localhost",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
/^fizzy-\d+(:\d+)?$/, # review apps: fizzy-123:3000
|
||||
/\.ts\.net$/ # tailscale serve: hostname.tail1234.ts.net
|
||||
]
|
||||
|
||||
# Set host to be used by links generated in mailer and notification view templates.
|
||||
config.action_controller.default_url_options = { host: config.hosts.first, port: 3006 }
|
||||
config.action_mailer.default_url_options = { host: config.hosts.first, port: 3006 }
|
||||
# Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs)
|
||||
config.action_mailer.default_url_options = { host: "#{config.hosts.first}:3006" }
|
||||
end
|
||||
|
||||
@@ -11,12 +11,13 @@ Rails.application.configure do
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = {
|
||||
address: smtp_address,
|
||||
port: ENV.fetch("SMTP_PORT", "587").to_i,
|
||||
port: ENV.fetch("SMTP_PORT", ENV["SMTP_TLS"] == "true" ? "465" : "587").to_i,
|
||||
domain: ENV.fetch("SMTP_DOMAIN", nil),
|
||||
user_name: ENV.fetch("SMTP_USERNAME", nil),
|
||||
password: ENV.fetch("SMTP_PASSWORD", nil),
|
||||
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"),
|
||||
enable_starttls_auto: ENV.fetch("SMTP_ENABLE_STARTTLS_AUTO", "true") == "true"
|
||||
tls: ENV["SMTP_TLS"] == "true",
|
||||
openssl_verify_mode: ENV["SMTP_SSL_VERIFY_MODE"]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -60,12 +61,15 @@ Rails.application.configure do
|
||||
# config.action_cable.url = "wss://example.com/cable"
|
||||
# config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
|
||||
|
||||
# Set DISABLE_SSL=true to disable all SSL options, rather than specify each individually
|
||||
ssl_enabled = "true" unless ENV["DISABLE_SSL"] == "true"
|
||||
|
||||
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
|
||||
# Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.
|
||||
config.assume_ssl = ENV.fetch("ASSUME_SSL", "true") == "true"
|
||||
config.assume_ssl = ENV.fetch("ASSUME_SSL", ssl_enabled) == "true"
|
||||
|
||||
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
|
||||
config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true"
|
||||
config.force_ssl = ENV.fetch("FORCE_SSL", ssl_enabled) == "true"
|
||||
|
||||
# Log to STDOUT by default
|
||||
config.logger = ActiveSupport::Logger.new(STDOUT)
|
||||
|
||||
@@ -67,4 +67,7 @@ Rails.application.configure do
|
||||
|
||||
# Load test helpers
|
||||
config.autoload_paths += %w[ test/test_helpers ]
|
||||
|
||||
# Enable multi-tenant mode for tests
|
||||
config.x.multi_tenant.enabled = true
|
||||
end
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# FIXME: Upstream to Rails
|
||||
Rails.application.config.after_initialize do
|
||||
ActionController.add_renderer :svg do |svg, options|
|
||||
self.content_type = Mime[:svg] if media_type.nil?
|
||||
svg
|
||||
end
|
||||
end
|
||||
@@ -35,6 +35,16 @@ module ActiveStorageControllerExtensions
|
||||
end
|
||||
end
|
||||
|
||||
module ActiveStorageDirectUploadsControllerExtensions
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Authentication
|
||||
skip_forgery_protection if: :authenticate_by_bearer_token
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
ActiveStorage::BaseController.include ActiveStorageControllerExtensions
|
||||
ActiveStorage::DirectUploadsController.include ActiveStorageDirectUploadsControllerExtensions
|
||||
end
|
||||
|
||||
@@ -2,9 +2,9 @@ require "deployment"
|
||||
require_relative "extensions"
|
||||
|
||||
if ActiveRecord::Base.replica_configured?
|
||||
Rails.application.configure do
|
||||
config.active_record.database_selector = { delay: 0.seconds }
|
||||
config.active_record.database_resolver = Deployment::DatabaseResolver
|
||||
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
|
||||
Rails.application.configure do
|
||||
config.active_record.database_selector = { delay: 0.seconds }
|
||||
config.active_record.database_resolver = Deployment::DatabaseResolver
|
||||
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Rails.application.configure do
|
||||
config.after_initialize do
|
||||
Account.multi_tenant = ENV["MULTI_TENANT"] == "true" || config.x.multi_tenant.enabled == true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# Disable Openslide to prevent sqlite segfault in forked parallel workers
|
||||
# Requires libvips 8.13+
|
||||
Vips.block "VipsForeignLoadOpenslide", true if Vips.respond_to?(:block)
|
||||
|
||||
# Limit libvips to 4 threads for each thread pool. Default is #CPUs.
|
||||
Vips.concurrency_set 4
|
||||
|
||||
# Limit libvips caches to reduce memory pressure.
|
||||
#
|
||||
# Do not disable entirely since libvips relies on some caching internally.
|
||||
# (When we disabled caches, we hit a ton of JPEG out of order read errors.)
|
||||
Vips.cache_set_max 10 # Default 100
|
||||
Vips.cache_set_max_mem 10.megabytes # Default 100MB
|
||||
Vips.cache_set_max_files 10 # Default 100
|
||||
+4
-1
@@ -178,10 +178,11 @@ curl -X POST \
|
||||
"content_type": "image/png"
|
||||
}
|
||||
}' \
|
||||
https://app.fizzy.do/rails/active_storage/direct_uploads
|
||||
https://app.fizzy.do/123456/rails/active_storage/direct_uploads
|
||||
```
|
||||
|
||||
The `checksum` is a Base64-encoded MD5 hash of the file content.
|
||||
The direct upload endpoint is scoped to your account (replace `/123456` with your account slug).
|
||||
|
||||
__Response:__
|
||||
|
||||
@@ -495,6 +496,7 @@ Creates a new card in a board.
|
||||
| `image` | file | No | Header image for the card |
|
||||
| `tag_ids` | array | No | Array of tag IDs to apply to the card |
|
||||
| `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) |
|
||||
| `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) |
|
||||
|
||||
__Request:__
|
||||
|
||||
@@ -522,6 +524,7 @@ Updates a card.
|
||||
| `status` | string | No | Card status: `drafted`, `published` |
|
||||
| `image` | file | No | Header image for the card |
|
||||
| `tag_ids` | array | No | Array of tag IDs to apply to the card |
|
||||
| `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) |
|
||||
|
||||
__Request:__
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
## Development
|
||||
|
||||
### Setting up
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
## Deploying with Docker
|
||||
|
||||
We provide pre-built Docker images that can be used to run Fizzy on your own server.
|
||||
|
||||
If you don't need to change the source code, and just want the out-of-the-box Fizzy experience, this can be a great way to get started.
|
||||
|
||||
You'll find the latest version of Fizzy's Docker image at `ghcr.io/basecamp/fizzy:main`.
|
||||
To run it you'll need three things: a machine that runs Docker; a mounted volume (so that your database is stored somewhere that is kept around between restarts); and some environment variables for configuration.
|
||||
|
||||
### Mounting a storage volume
|
||||
|
||||
The standard Fizzy setup keeps all of its storage inside the path `/rails/storage`.
|
||||
By default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location.
|
||||
|
||||
The simplest way to do this is with the `--volume` flag with `docker run`. For example:
|
||||
|
||||
```sh
|
||||
docker run --volume fizzy:/rails/storage ghcr.io/basecamp/fizzy:main
|
||||
```
|
||||
|
||||
That will create a named volume (called `fizzy`) and mount it into the correct path.
|
||||
Docker will manage where that volume is actually stored on your server.
|
||||
|
||||
You can also specify the data location yourself, mount a network drive, and more.
|
||||
Check the Docker documentation to find out more about what's available.
|
||||
|
||||
### Configuring with environment variables
|
||||
|
||||
To configure your Fizzy installation, you can use environment variables.
|
||||
Fizzy has several of them.
|
||||
Many of these are optional, but at a minimum you'll want to configure your secret key, your SSL domain, and your SMTP email settings.
|
||||
|
||||
#### Secret Key Base
|
||||
|
||||
Various features inside Fizzy rely on cryptography to work (such as secure links).
|
||||
To set this up, you need to provide a secret value that will be used as the basis of those secrets.
|
||||
This value can be anything, but it should be unguessable, and specific to your instance.
|
||||
|
||||
You can use any long random string for this, or you can have the Fizzy codebase generate one for you by running:
|
||||
|
||||
```sh
|
||||
bin/rails secret
|
||||
```
|
||||
|
||||
Once you have one, set it in the `SECRET_KEY_BASE` environment variable:
|
||||
|
||||
```sh
|
||||
docker run --environment SECRET_KEY_BASE=abcdefabcdef ...
|
||||
```
|
||||
|
||||
#### SSL
|
||||
|
||||
If you want the Fizzy container to handle its own SSL automatically, you just need to specify the domain name that you're running it on.
|
||||
You can do that with the `TLS_DOMAIN` environment variable.
|
||||
Note that if you're using SSL, you'll want to allow traffic on ports 80 and 443.
|
||||
So if you were running on `fizzy.example.com` you could enable SSL like this:
|
||||
|
||||
```sh
|
||||
docker run --publish 80:80 --publish 443:443 --environment TLS_DOMAIN=fizzy.example.com ...
|
||||
```
|
||||
|
||||
If you are terminating SSL in some other proxy in front of Fizzy, then you don't need to set `TLS_DOMAIN`, and can just publish port 80:
|
||||
```sh
|
||||
docker run --publish 80:80 ...
|
||||
```
|
||||
|
||||
If you aren't using SSL at all (for example, if you want to run it locally on your laptop) then you should specify `DISABLE_SSL=true` instead:
|
||||
|
||||
```sh
|
||||
docker run --publish 80:80 --environment DISABLE_SSL=true ...
|
||||
```
|
||||
|
||||
#### SMTP Email
|
||||
|
||||
Fizzy needs to be able to send email for its sign up/sign in flow, and for its regular summary emails.
|
||||
The easiest way to set this up is to use a 3rd-party email provider (such as Postmark, Sendgrid, and so on).
|
||||
You can then plug all your SMTP settings from that provider into Fizzy via the following environment variables:
|
||||
|
||||
- `MAILER_FROM_ADDRESS` - the "from" address that Fizzy should use to send email
|
||||
- `SMTP_ADDRESS` - the address of the SMTP server you'll send through
|
||||
- `SMTP_PORT` - the port number (defaults to 465 when `SMTP_TLS` is set, 587 otherwise)
|
||||
- `SMTP_USERNAME`/`SMTP_PASSWORD` - the credentials for logging in to the SMTP server
|
||||
|
||||
Less commonly, you might also need to set some of the following:
|
||||
|
||||
- `SMTP_TLS` - set to `true` only for servers requiring implicit TLS (SMTPS on port 465); STARTTLS is used automatically by default so most servers don't need this
|
||||
- `SMTP_DOMAIN` - the domain name advertised to the server when connecting
|
||||
- `SMTP_AUTHENTICATION` - if you need an authentication method other than the default `plain`
|
||||
- `SMTP_SSL_VERIFY_MODE` - set to `none` to skip certificate verification (for self-signed certs)
|
||||
|
||||
You can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration).
|
||||
|
||||
#### VAPID keys
|
||||
|
||||
Fizzy can also send Web Push notifications.
|
||||
To do this it needs a VAPID key pair.
|
||||
|
||||
You can create your own keys by starting a development console with:
|
||||
|
||||
```sh
|
||||
bin/rails c
|
||||
```
|
||||
|
||||
And then run the following to create the keypair:
|
||||
|
||||
```ruby
|
||||
vapid_key = WebPush.generate_key
|
||||
|
||||
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
|
||||
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
|
||||
```
|
||||
|
||||
Set those in the `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` environment variables.
|
||||
|
||||
#### S3 storage (optional)
|
||||
|
||||
If you're rather that uploaded files were stored in an S3 bucket, rather than your mounted volume, you can set that up.
|
||||
|
||||
First set `ACTIVE_STORAGE_SERVICE` to `s3`.
|
||||
Then set the following as appropriate for your S3 bucket:
|
||||
|
||||
- `S3_BUCKET`
|
||||
- `S3_REGION`
|
||||
- `S3_ACCESS_KEY_ID`
|
||||
- `S3_SECRET_ACCESS_KEY`
|
||||
|
||||
If you're using a provider other than AWS, you will also need some of the following:
|
||||
|
||||
- `S3_ENDPOINT`
|
||||
- `S3_FORCE_PATH_STYLE`
|
||||
- `S3_REQUEST_CHECKSUM_CALCULATION`
|
||||
- `S3_RESPONSE_CHECKSUM_VALIDATION`
|
||||
|
||||
#### Multi-tenant mode
|
||||
|
||||
By default, when you run the Fizzy Docker image you'll be limited to creating a single account (although that account can have as many users as you like).
|
||||
This is for convenience: typically when you self-host you'll be running a single account, so in this mode new account signups are automatically disabled as soon as you've created your first account.
|
||||
|
||||
If you do want to allow multiple accounts to be created in your instance, set `MULTI_TENANT=true`
|
||||
|
||||
## Example
|
||||
|
||||
Here's an example of a `docker-compose.yml` that you could use to run Fizzy via `docker compose up`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: ghcr.io/basecamp/fizzy:main
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
- SECRET_KEY_BASE=abcdefabcdef
|
||||
- TLS_DOMAIN=fizzy.example.com
|
||||
- MAILER_FROM_ADDRESS=fizzy@example.com
|
||||
- SMTP_ADDRESS=mail.example.com
|
||||
- SMTP_USERNAME=user
|
||||
- SMTP_PASSWORD=pass
|
||||
- VAPID_PRIVATE_KEY=myvapidprivatekey
|
||||
- VAPID_PUBLIC_KEY=myvapidpublickey
|
||||
volumes:
|
||||
- fizzy:/rails/storage
|
||||
|
||||
volumes:
|
||||
fizzy:
|
||||
```
|
||||
@@ -0,0 +1,112 @@
|
||||
## Deploying Fizzy with Kamal
|
||||
|
||||
If you'd like to run Fizzy on your own server while having the freedom to easily make changes to its code, we recommend deploying it with [Kamal](https://kamal-deploy.org/).
|
||||
Kamal makes it easy 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. 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.
|
||||
- `env/clear/MULTI_TENANT`: Set to `true` if you want to allow multiple accounts to sign up on your server (by default, Fizzy will allow you to create a single account).
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Configuring 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`)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActiveStorage::DirectUploadsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@blob_params = {
|
||||
blob: {
|
||||
filename: "screenshot.png",
|
||||
byte_size: 12345,
|
||||
checksum: "GQ5SqLsM7ylnji0Wgd9wNC==",
|
||||
content_type: "image/png"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
test "create" do
|
||||
sign_in_as :david
|
||||
|
||||
post rails_direct_uploads_path,
|
||||
params: @blob_params,
|
||||
headers: bearer_token_header(identity_access_tokens(:davids_api_token).token),
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.parsed_body.keys, "direct_upload"
|
||||
end
|
||||
|
||||
test "create with valid access token" do
|
||||
post rails_direct_uploads_path,
|
||||
params: @blob_params,
|
||||
headers: bearer_token_header(identity_access_tokens(:davids_api_token).token),
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.parsed_body.keys, "direct_upload"
|
||||
end
|
||||
|
||||
test "create with read-only access token" do
|
||||
post rails_direct_uploads_path,
|
||||
params: @blob_params,
|
||||
headers: bearer_token_header(identity_access_tokens(:jasons_api_token).token),
|
||||
as: :json
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "create with invalid access token" do
|
||||
post rails_direct_uploads_path,
|
||||
params: @blob_params,
|
||||
headers: bearer_token_header("invalid_token"),
|
||||
as: :json
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "create unauthenticated" do
|
||||
post rails_direct_uploads_path,
|
||||
params: @blob_params.merge(authenticity_token: csrf_token),
|
||||
as: :json
|
||||
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
private
|
||||
def bearer_token_header(token)
|
||||
{ "Authorization" => "Bearer #{token}" }
|
||||
end
|
||||
|
||||
def csrf_token
|
||||
get new_session_url
|
||||
response.body[/name="csrf-token" content="([^"]+)"/, 1]
|
||||
end
|
||||
end
|
||||
@@ -21,8 +21,9 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
card = Card.last
|
||||
assert card.drafted?
|
||||
assert_redirected_to card
|
||||
|
||||
assert card.drafted?
|
||||
end
|
||||
|
||||
test "create resumes existing draft if it exists" do
|
||||
@@ -30,9 +31,8 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_no_difference -> { Card.count } do
|
||||
post board_cards_path(boards(:writebook))
|
||||
assert_redirected_to draft
|
||||
end
|
||||
|
||||
assert_redirected_to draft
|
||||
end
|
||||
|
||||
test "show" do
|
||||
@@ -68,6 +68,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
boards(:writebook).update! all_access: false
|
||||
boards(:writebook).accesses.revoke_from users(:kevin)
|
||||
|
||||
get card_path(cards(:logo))
|
||||
assert_response :not_found
|
||||
end
|
||||
@@ -75,6 +76,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "admins can see delete button on any card" do
|
||||
get card_path(cards(:logo))
|
||||
assert_response :success
|
||||
|
||||
assert_match "Delete this card", response.body
|
||||
end
|
||||
|
||||
@@ -83,6 +85,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
get card_path(cards(:logo))
|
||||
assert_response :success
|
||||
|
||||
assert_match "Delete this card", response.body
|
||||
end
|
||||
|
||||
@@ -91,6 +94,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
get card_path(cards(:logo))
|
||||
assert_response :success
|
||||
|
||||
assert_no_match "Delete this card", response.body
|
||||
end
|
||||
|
||||
@@ -124,10 +128,9 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "show card with comment containing malformed remote image attachment" do
|
||||
card = cards(:logo)
|
||||
card.comments.create!(
|
||||
card.comments.create! \
|
||||
creator: users(:kevin),
|
||||
body: '<action-text-attachment url="image.png" content-type="image/*" presentation="gallery"></action-text-attachment>'
|
||||
)
|
||||
|
||||
get card_path(card)
|
||||
assert_response :success
|
||||
@@ -140,6 +143,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
get card_path(card), as: :json
|
||||
assert_response :success
|
||||
|
||||
assert_equal card.title, @response.parsed_body["title"]
|
||||
assert_equal 2, @response.parsed_body["steps"].size
|
||||
end
|
||||
@@ -149,12 +153,12 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
post board_cards_path(boards(:writebook)),
|
||||
params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } },
|
||||
as: :json
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
assert_response :created
|
||||
assert_equal card_path(Card.last, format: :json), @response.headers["Location"]
|
||||
|
||||
card = Card.last
|
||||
assert_equal card_path(card, format: :json), @response.headers["Location"]
|
||||
|
||||
assert_equal "My new card", card.title
|
||||
assert_equal "Big if true", card.description.to_plain_text
|
||||
assert_equal [ tags(:mobile), tags(:web) ].sort, card.tags.sort
|
||||
@@ -167,25 +171,91 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
post board_cards_path(boards(:writebook)),
|
||||
params: { card: { title: "Backdated card", created_at: custom_time } },
|
||||
as: :json
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
assert_response :created
|
||||
assert_equal custom_time, Card.last.created_at
|
||||
end
|
||||
|
||||
test "create as JSON with custom last_active_at" do
|
||||
created_time = Time.utc(2024, 1, 15, 10, 30, 0)
|
||||
last_active_time = Time.utc(2024, 6, 1, 12, 0, 0)
|
||||
|
||||
assert_difference -> { Card.count }, +1 do
|
||||
post board_cards_path(boards(:writebook)),
|
||||
params: { card: { title: "Card with activity", created_at: created_time, last_active_at: last_active_time } },
|
||||
as: :json
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
card = Card.last
|
||||
assert_equal created_time, card.created_at
|
||||
assert_equal last_active_time, card.last_active_at
|
||||
end
|
||||
|
||||
test "create as JSON defaults last_active_at to created_at when not provided" do
|
||||
created_time = Time.utc(2024, 1, 15, 10, 30, 0)
|
||||
|
||||
assert_difference -> { Card.count }, +1 do
|
||||
post board_cards_path(boards(:writebook)),
|
||||
params: { card: { title: "Backdated card without last_active_at", created_at: created_time } },
|
||||
as: :json
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
card = Card.last
|
||||
assert_equal created_time, card.created_at
|
||||
assert_equal created_time, card.last_active_at
|
||||
end
|
||||
|
||||
test "update as JSON with custom last_active_at" do
|
||||
card = cards(:logo)
|
||||
custom_time = Time.utc(2024, 3, 15, 14, 0, 0)
|
||||
|
||||
put card_path(card, format: :json), params: { card: { last_active_at: custom_time } }
|
||||
|
||||
assert_response :success
|
||||
assert_equal custom_time, card.reload.last_active_at
|
||||
end
|
||||
|
||||
test "update as JSON can restore last_active_at after comments overwrite it" do
|
||||
created_time = Time.utc(2024, 1, 15, 10, 30, 0)
|
||||
last_active_time = Time.utc(2024, 6, 1, 12, 0, 0)
|
||||
|
||||
# Create a card with custom timestamps (simulating import)
|
||||
post board_cards_path(boards(:writebook)),
|
||||
params: { card: { title: "Imported card", created_at: created_time, last_active_at: last_active_time } },
|
||||
as: :json
|
||||
assert_response :created
|
||||
|
||||
card = Card.last
|
||||
|
||||
# Adding a comment overwrites last_active_at (this is expected)
|
||||
card.comments.create!(creator: users(:kevin), body: "Imported comment")
|
||||
assert_not_equal last_active_time, card.reload.last_active_at
|
||||
|
||||
# After import, restore the correct last_active_at
|
||||
put card_path(card, format: :json), params: { card: { last_active_at: last_active_time } }
|
||||
assert_response :success
|
||||
|
||||
assert_equal last_active_time, card.reload.last_active_at
|
||||
end
|
||||
|
||||
test "update as JSON" do
|
||||
card = cards(:logo)
|
||||
put card_path(card, format: :json), params: { card: { title: "Update test" } }
|
||||
|
||||
put card_path(card, format: :json), params: { card: { title: "Update test" } }
|
||||
assert_response :success
|
||||
|
||||
assert_equal "Update test", card.reload.title
|
||||
end
|
||||
|
||||
test "delete as JSON" do
|
||||
card = cards(:logo)
|
||||
delete card_path(card, format: :json)
|
||||
|
||||
delete card_path(card, format: :json)
|
||||
assert_response :no_content
|
||||
|
||||
assert_not Card.exists?(card.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,17 +12,17 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
|
||||
ActionController::Base.allow_forgery_protection = @original_allow_forgery_protection
|
||||
end
|
||||
|
||||
test "succeeds when CSRF token is valid" do
|
||||
assert_difference -> { Board.count }, +1 do
|
||||
test "fails if Sec-Fetch-Site is cross-site" do
|
||||
assert_no_difference -> { Board.count } do
|
||||
post boards_path,
|
||||
params: { board: { name: "Test Board" }, authenticity_token: csrf_token },
|
||||
params: { board: { name: "Test Board" } },
|
||||
headers: { "Sec-Fetch-Site" => "cross-site" }
|
||||
end
|
||||
|
||||
assert_response :redirect
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "succeeds with same-origin Sec-Fetch-Site even without CSRF token" do
|
||||
test "succeeds with same-origin Sec-Fetch-Site" do
|
||||
assert_difference -> { Board.count }, +1 do
|
||||
post boards_path,
|
||||
params: { board: { name: "Test Board" } },
|
||||
@@ -32,7 +32,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "succeeds with same-site Sec-Fetch-Site even without CSRF token" do
|
||||
test "succeeds with same-site Sec-Fetch-Site" do
|
||||
assert_difference -> { Board.count }, +1 do
|
||||
post boards_path,
|
||||
params: { board: { name: "Test Board" } },
|
||||
@@ -42,17 +42,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "fails with cross-site Sec-Fetch-Site and no CSRF token" do
|
||||
assert_no_difference -> { Board.count } do
|
||||
post boards_path,
|
||||
params: { board: { name: "Test Board" } },
|
||||
headers: { "Sec-Fetch-Site" => "cross-site" }
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "fails with none Sec-Fetch-Site and no CSRF token" do
|
||||
test "fails with none Sec-Fetch-Site" do
|
||||
assert_no_difference -> { Board.count } do
|
||||
post boards_path,
|
||||
params: { board: { name: "Test Board" } },
|
||||
@@ -62,7 +52,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "fails when Sec-Fetch-Site header is missing and no CSRF token" do
|
||||
test "fails when Sec-Fetch-Site header is missing" do
|
||||
assert_no_difference -> { Board.count } do
|
||||
post boards_path, params: { board: { name: "Test Board" } }
|
||||
end
|
||||
@@ -70,16 +60,6 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "fails with invalid CSRF token and cross-site Sec-Fetch-Site" do
|
||||
assert_no_difference -> { Board.count } do
|
||||
post boards_path,
|
||||
params: { board: { name: "Test Board" }, authenticity_token: "invalid-token" },
|
||||
headers: { "Sec-Fetch-Site" => "cross-site" }
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "GET requests succeed regardless of Sec-Fetch-Site header" do
|
||||
get board_path(boards(:writebook)), headers: { "Sec-Fetch-Site" => "cross-site" }
|
||||
|
||||
@@ -102,6 +82,27 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
|
||||
assert_includes response.headers["Vary"], "Sec-Fetch-Site"
|
||||
end
|
||||
|
||||
test "JSON request succeeds with missing Sec-Fetch-Site" do
|
||||
assert_difference -> { Board.count }, +1 do
|
||||
post boards_path,
|
||||
params: { board: { name: "Test Board" } },
|
||||
as: :json
|
||||
end
|
||||
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
test "JSON request fails with cross-site Sec-Fetch-Site" do
|
||||
assert_no_difference -> { Board.count } do
|
||||
post boards_path,
|
||||
params: { board: { name: "Test Board" } },
|
||||
headers: { "Sec-Fetch-Site" => "cross-site" },
|
||||
as: :json
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
def csrf_token
|
||||
@csrf_token ||= begin
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest
|
||||
test "staff can access in staging environment" do
|
||||
sign_in_as :david
|
||||
|
||||
Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging"))
|
||||
get cards_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "non-staff cannot access in staging environment" do
|
||||
sign_in_as :jz
|
||||
|
||||
Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging"))
|
||||
get cards_path
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "non-staff can access in production environment" do
|
||||
sign_in_as :jz
|
||||
|
||||
Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("production"))
|
||||
get cards_path
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "non-staff can access in local environment" do
|
||||
sign_in_as :jz
|
||||
|
||||
get cards_path
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
require "test_helper"
|
||||
|
||||
class QrCodesControllerTest < ActionDispatch::IntegrationTest
|
||||
test "show" do
|
||||
join_code = account_join_codes(:"37s")
|
||||
account = accounts("37s")
|
||||
url = join_url(code: join_code.code, script_name: account.slug, host: "app.fizzy.do")
|
||||
signed_token = QrCodeLink.new(url).signed
|
||||
|
||||
get qr_code_path(signed_token)
|
||||
|
||||
assert_response :success
|
||||
assert_match %r{image/svg\+xml}, response.content_type
|
||||
assert_includes response.body, "<svg"
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,14 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
|
||||
untenanted do
|
||||
get session_magic_link_url
|
||||
|
||||
assert_response :redirect, "Without an email address pending authentication, should redirect"
|
||||
assert_redirected_to new_session_path
|
||||
end
|
||||
|
||||
untenanted do
|
||||
post session_path, params: { email_address: "test@example.com" }
|
||||
get session_magic_link_url
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
@@ -14,6 +22,7 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
|
||||
magic_link = MagicLink.create!(identity: identity)
|
||||
|
||||
untenanted do
|
||||
post session_path, params: { email_address: identity.email_address }
|
||||
post session_magic_link_url, params: { code: magic_link.code }
|
||||
|
||||
assert_response :redirect
|
||||
@@ -28,6 +37,7 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
|
||||
magic_link = MagicLink.create!(identity: identity, purpose: :sign_up)
|
||||
|
||||
untenanted do
|
||||
post session_path, params: { email_address: identity.email_address }
|
||||
post session_magic_link_url, params: { code: magic_link.code }
|
||||
|
||||
assert_response :redirect
|
||||
@@ -37,6 +47,20 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "create with cross-user code" do
|
||||
identity = identities(:kevin)
|
||||
other_identity = identities(:jason)
|
||||
magic_link = MagicLink.create!(identity: other_identity)
|
||||
|
||||
untenanted do
|
||||
post session_path, params: { email_address: identity.email_address }
|
||||
post session_magic_link_url, params: { code: magic_link.code }
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_not cookies[:session_token].present?
|
||||
end
|
||||
end
|
||||
|
||||
test "create with invalid code" do
|
||||
identity = identities(:kevin)
|
||||
magic_link = MagicLink.create!(identity: identity)
|
||||
|
||||
@@ -9,6 +9,15 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "new redirects authenticated users" do
|
||||
sign_in_as :kevin
|
||||
|
||||
untenanted do
|
||||
get new_session_path
|
||||
assert_redirected_to root_url
|
||||
end
|
||||
end
|
||||
|
||||
test "create" do
|
||||
identity = identities(:kevin)
|
||||
|
||||
@@ -36,6 +45,21 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "create for a new user when single tenant mode already has a tenant" do
|
||||
with_multi_tenant_mode(false) do
|
||||
untenanted do
|
||||
assert_no_difference -> { MagicLink.count } do
|
||||
assert_no_difference -> { Identity.count } do
|
||||
post session_path,
|
||||
params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to session_magic_link_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -63,4 +63,16 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_redirected_to new_signup_completion_path
|
||||
end
|
||||
end
|
||||
|
||||
test "redirects to session#new when single_tenant and user exists" do
|
||||
users(:david)
|
||||
|
||||
with_multi_tenant_mode(false) do
|
||||
untenanted do
|
||||
get new_signup_path
|
||||
|
||||
assert_redirected_to new_session_url
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,7 +65,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "update with invalid avatar shows validation error" do
|
||||
test "update with invalid avatar content type shows validation error" do
|
||||
sign_in_as :kevin
|
||||
|
||||
svg_file = fixture_file_upload("avatar.svg", "image/svg+xml")
|
||||
@@ -76,6 +76,18 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_select ".txt-negative", text: /must be a JPEG, PNG, GIF, or WebP image/
|
||||
end
|
||||
|
||||
test "update with oversized avatar shows validation error" do
|
||||
sign_in_as :kevin
|
||||
|
||||
png_file = fixture_file_upload("avatar.png", "image/png")
|
||||
|
||||
ActiveStorage::Analyzer::ImageAnalyzer::Vips.any_instance.stubs(:metadata).returns({ width: 5000, height: 100 })
|
||||
|
||||
put user_path(users(:kevin)), params: { user: { avatar: png_file } }
|
||||
assert_response :unprocessable_entity
|
||||
assert_select ".txt-negative", text: /width must be less than 4096px/
|
||||
end
|
||||
|
||||
test "update with valid avatar" do
|
||||
sign_in_as :kevin
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::MultiTenantableTest < ActiveSupport::TestCase
|
||||
test "accepting_signups? is true when multi_tenant is enabled" do
|
||||
with_multi_tenant_mode(true) do
|
||||
assert Account.accepting_signups?
|
||||
end
|
||||
end
|
||||
|
||||
test "accepting_signups? is false when multi_tenant is disabled and accounts exist" do
|
||||
with_multi_tenant_mode(false) do
|
||||
assert_not Account.accepting_signups?
|
||||
end
|
||||
end
|
||||
|
||||
test "accepting_signups? is true when multi_tenant is disabled but no accounts exist" do
|
||||
with_multi_tenant_mode(false) do
|
||||
Account.delete_all
|
||||
assert Account.accepting_signups?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,11 +5,47 @@ class Card::EventableTest < ActiveSupport::TestCase
|
||||
Current.session = sessions(:david)
|
||||
end
|
||||
|
||||
test "new cards get the current time as the last activity time" do
|
||||
test "new cards default last_active_at to created_at" do
|
||||
freeze_time
|
||||
|
||||
card = boards(:writebook).cards.create!(title: "Some card card", creator: users(:david))
|
||||
assert_equal Time.current, card.last_active_at
|
||||
card = boards(:writebook).cards.create!(title: "Some card", creator: users(:david))
|
||||
assert_equal card.created_at, card.last_active_at
|
||||
end
|
||||
|
||||
test "new cards with custom created_at default last_active_at to that time" do
|
||||
custom_time = 1.week.ago.change(usec: 0)
|
||||
|
||||
card = boards(:writebook).cards.create!(title: "Backdated card", creator: users(:david), created_at: custom_time)
|
||||
assert_equal custom_time, card.created_at
|
||||
assert_equal custom_time, card.last_active_at
|
||||
end
|
||||
|
||||
test "new cards preserve explicit last_active_at" do
|
||||
created_time = 2.weeks.ago.change(usec: 0)
|
||||
last_active_time = 1.week.ago.change(usec: 0)
|
||||
|
||||
card = boards(:writebook).cards.create! \
|
||||
title: "Card with explicit timestamps",
|
||||
creator: users(:david),
|
||||
created_at: created_time,
|
||||
last_active_at: last_active_time
|
||||
|
||||
assert_equal created_time, card.created_at
|
||||
assert_equal last_active_time, card.last_active_at
|
||||
end
|
||||
|
||||
test "publishing a card does not overwrite last_active_at" do
|
||||
created_time = 2.weeks.ago.change(usec: 0)
|
||||
last_active_time = 1.week.ago.change(usec: 0)
|
||||
|
||||
card = boards(:writebook).cards.create! \
|
||||
title: "Published card",
|
||||
creator: users(:david),
|
||||
status: :published,
|
||||
created_at: created_time,
|
||||
last_active_at: last_active_time
|
||||
|
||||
assert_equal last_active_time, card.last_active_at
|
||||
end
|
||||
|
||||
test "tracking events update the last activity time" do
|
||||
|
||||
@@ -3,40 +3,51 @@ require "test_helper"
|
||||
class Card::GoldenTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
Current.session = sessions(:david)
|
||||
@golden, @non_golden = cards(:logo), cards(:text)
|
||||
end
|
||||
|
||||
test "check whether a card is golden" do
|
||||
assert cards(:logo).golden?
|
||||
assert_not cards(:text).golden?
|
||||
assert @golden.golden?
|
||||
assert_not @non_golden.golden?
|
||||
end
|
||||
|
||||
test "promote and demote from golden" do
|
||||
assert_changes -> { cards(:text).reload.golden? }, to: true do
|
||||
cards(:text).gild
|
||||
assert_changes -> { @non_golden.reload.golden? }, to: true do
|
||||
@non_golden.gild
|
||||
end
|
||||
|
||||
assert_changes -> { cards(:logo).reload.golden? }, to: false do
|
||||
cards(:logo).ungild
|
||||
assert_changes -> { @golden.reload.golden? }, to: false do
|
||||
@golden.ungild
|
||||
end
|
||||
end
|
||||
|
||||
test "scopes" do
|
||||
assert_includes Card.golden, cards(:logo)
|
||||
assert_not_includes Card.golden, cards(:text)
|
||||
assert_includes Card.golden, @golden
|
||||
assert_not_includes Card.golden, @non_golden
|
||||
end
|
||||
|
||||
test "with_golden_first orders golden first" do
|
||||
ordered = Card.where(id: [ @golden.id, @non_golden.id ]).with_golden_first
|
||||
assert_equal [ @golden, @non_golden ], ordered.to_a
|
||||
|
||||
# Preserves base ordering as subordering
|
||||
@non_golden.gild
|
||||
base_ordered = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).to_a
|
||||
with_golden = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).with_golden_first.to_a
|
||||
assert_equal base_ordered, with_golden
|
||||
end
|
||||
|
||||
test "gilding a card touches both the card and the board" do
|
||||
card = cards(:text)
|
||||
board = card.board
|
||||
board = @non_golden.board
|
||||
|
||||
card_updated_at = card.updated_at
|
||||
card_updated_at = @non_golden.updated_at
|
||||
board_updated_at = board.updated_at
|
||||
|
||||
travel 1.minute do
|
||||
card.gild
|
||||
@non_golden.gild
|
||||
end
|
||||
|
||||
assert card.reload.updated_at > card_updated_at
|
||||
assert @non_golden.reload.updated_at > card_updated_at
|
||||
assert board.reload.updated_at > board_updated_at
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user