Merge branch 'main' into mobile-app/scoped-stylesheets

* main: (70 commits)
  bin/ci: run OSS suite in SAAS mode for full coverage (#2029)
  Fix flaky tests caused by leaky show_exceptions twiddling (#2028)
  Fix ActiveStorage FileNotFoundError with immediate variant processing (#2022)
  Immediate avatar and embed variants (#2002)
  Should be in global gitignore
  Account for browser button styled and adjust mobile padding for perma BG
  Wrap the overflow menu
  doc: update style doc reference for LLM agents
  Fix Identity destruction callback ordering
  Ensure user toggles on board edit page are cached properly
  Adopt a cooldown period for dependency updates
  Add lazy error context for Rails error reporter (#2014)
  Disable board edit buttons for select all/none when not privileged
  Autotuner: use structured logging instead of Sentry (#2011)
  Add datetime to the search results
  Fix involvement button label
  Bump mittens to 0.3.1 (#2006)
  No need to pass Currentl.user since it is the default value for the param
  Validate email before creating identity during join code redemption
  Ignore RubyMine project files in .gitignore
  ...
This commit is contained in:
Adrien Maston
2025-12-09 09:11:51 +01:00
96 changed files with 1434 additions and 425 deletions
+6
View File
@@ -20,8 +20,14 @@ updates:
dependency-type: "development"
schedule:
interval: weekly
cooldown:
default-days: 7
semver-major-days: 14
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
cooldown:
default-days: 7
semver-major-days: 14
+1 -1
View File
@@ -148,4 +148,4 @@ Use Chrome MCP tools to interact with the running dev app for UI testing and deb
## Coding style
Please read the separate file `STYLE.md` for some guidance on coding style.
@STYLE.md
+1 -1
View File
@@ -24,7 +24,7 @@ RUN apt-get update -qq && \
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
BUNDLE_WITHOUT="development:test" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
+3 -3
View File
@@ -6,7 +6,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 3c3f6c8a253c8a0f346695374f927c9ab32fbefb
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -237,7 +237,7 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lexxy (0.1.20.beta)
lexxy (0.1.23.beta)
rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
@@ -267,7 +267,7 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
mittens (0.3.0)
mittens (0.3.1)
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
+5 -5
View File
@@ -21,7 +21,7 @@ GIT
GIT
remote: https://github.com/basecamp/fizzy-saas
revision: 97d126e0a6084905aceaadf4facaa2a48b44e124
revision: 43dbc896ce7b6a08194a92ddd1695d3f1ebf554b
specs:
fizzy-saas (0.1.0)
audits1984
@@ -75,7 +75,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 690ec8898318b8f50714e86676353ebe1551261e
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -292,7 +292,7 @@ GEM
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
jmespath (1.6.2)
json (2.16.0)
json (2.17.1)
jwt (3.1.2)
base64
kamal (2.9.0)
@@ -313,7 +313,7 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lexxy (0.1.20.beta)
lexxy (0.1.23.beta)
rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
@@ -552,7 +552,7 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
stringio (3.1.9)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
+118 -25
View File
@@ -2,6 +2,99 @@
This is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com).
## Deploying Fizzy
If you'd like to run Fizzy on your own server, we recommend deploying it with [Kamal](https://kamal-deploy.org/).
Kamal makes it easier to set up a bare server, copy the application to it, and manage the configuration settings that it uses.
(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).)
This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it.
The steps to configure your very own Fizzy are:
1. Fork the repo
2. Edit few things in config/deploy.yml, .kamal/secrets, and config/environments/production.rb
3. Run `kamal setup` to do your first deploy.
We'll go through each of these in turn.
### Fork the repo
To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo.
That allows you to commit your changes, and track them over time.
You can always re-sync your fork to pick up new changes from the main repo over time.
Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed.
### Editing the configuration
The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance.
To get started, the parts you need to change are all in the "About your deployment" section.
We've added comments to that file to highlight what each setting needs to be, but the main ones are:
- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`.
- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here.
- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off.
- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.
Fizzy also requires a few environment variables to be set up, some of which contain secrets.
The simplest way to do this is to put them in a file called `.kamal/secrets`.
Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally.
If you use a password manager like 1Password, you can also opt to keep your secrets there instead.
Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that.
To store your secrets, create the file `.kamal/secrets` and enter something like the following:
```ini
SECRET_KEY_BASE=12345
VAPID_PUBLIC_KEY=something
VAPID_PRIVATE_KEY=somethingelse
SMTP_USERNAME=email-provider-username
SMTP_PASSWORD=email-provider-password
```
The values you enter here will be specific to you, and you can get or create them as follows:
- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this.
- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with:
```sh
bin/rails c
```
And then run the following to create a new pair of keys:
```ruby
vapid_key = WebPush.generate_key
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
- `SMTP_USERNAME`/`SMTP_PASSWORD` are credentials you should get from your email provider.
Lastly, you'll need to set up the rest of your email configuration in `config/environments/production.rb`. There is an example configuration in comments at the top of that file. The actual settings you use here will depend on your email provider, but in most cases will look similar to that section, so you can uncomment it and edit to suit. Note that it will use the `SMTP_USERNAME` and `SMTP_PASSWORD` values you entered in your secrets.
Once you've made all those changes, commit them to your fork so they're saved.
### Deploy Fizzy!
You can now do your first deploy by running:
```sh
bin/kamal setup
```
This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running.
After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run:
```sh
bin/kamal deploy
```
## Development
### Setting up
@@ -23,15 +116,35 @@ You'll be able to access the app in development at http://fizzy.localhost:3006.
To login, enter `david@example.com` and grab the verification code from the browser console to sign in.
### Web Push Notifications
Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:
- `VAPID_PRIVATE_KEY`
- `VAPID_PUBLIC_KEY`
Generate them with the `web-push` gem:
```ruby
vapid_key = WebPush.generate_key
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
### Running tests
For fast feedback loops, unit tests can be run with:
bin/rails test
```sh
bin/rails test
```
The full continuous integration tests can be run with:
bin/ci
```sh
bin/ci
```
### Database configuration
@@ -50,37 +163,18 @@ You can view email previews at http://fizzy.localhost:3006/rails/mailers.
You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with:
bin/rails dev:email
```sh
bin/rails dev:email
```
Under the hood, this will create or remove `tmp/email-dev.txt`.
## Deployment
We recommend [Kamal](https://kamal-deploy.org/) for deploying Fizzy. This project comes with a vanilla Rails template. You can find our production setup in [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).
### Web Push Notifications
Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. You'll need to generate a key pair and set these environment variables:
- `VAPID_PRIVATE_KEY`
- `VAPID_PUBLIC_KEY`
Generate them with the `web-push` gem:
```ruby
vapid_key = WebPush.generate_key
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
## SaaS gem
37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup.
This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure.
## Contributing
We welcome contributions! Please read our [style guide](STYLE.md) before submitting code.
@@ -88,4 +182,3 @@ We welcome contributions! Please read our [style guide](STYLE.md) before submitt
## License
Fizzy is released under the [O'Saasy License](LICENSE.md).
+22 -1
View File
@@ -10,7 +10,7 @@
--block-space-double: calc(var(--block-space) * 2);
/* Text */
--font-sans: system-ui;
--font-sans: -apple-system, BlinkMacSystemFont, sans-serif;
--font-serif: ui-serif, serif;
--font-mono: ui-monospace, monospace;
@@ -219,6 +219,27 @@
--color-card-7: oklch(var(--lch-purple-medium));
--color-card-8: oklch(var(--lch-pink-medium));
/* Colors: Highlighter */
--highlight-1: rgb(136, 118, 38);
--highlight-2: rgb(185, 94, 6);
--highlight-3: rgb(207, 0, 0);
--highlight-4: rgb(216, 28, 170);
--highlight-5: rgb(144, 19, 254);
--highlight-6: rgb(5, 98, 185);
--highlight-7: rgb(17, 138, 15);
--highlight-8: rgb(148, 82, 22);
--highlight-9: rgb(102, 102, 102);
--highlight-bg-1: rgba(229, 223, 6, 0.3);
--highlight-bg-2: rgba(255, 185, 87, 0.3);
--highlight-bg-3: rgba(255, 118, 118, 0.3);
--highlight-bg-4: rgba(248, 137, 216, 0.3);
--highlight-bg-5: rgba(190, 165, 255, 0.3);
--highlight-bg-6: rgba(124, 192, 252, 0.3);
--highlight-bg-7: rgba(140, 255, 129, 0.3);
--highlight-bg-8: rgba(221, 170, 123, 0.3);
--highlight-bg-9: rgba(200, 200, 200, 0.3);
/* Colors: Syntax highlighting */
--color-code-token__att: oklch(var(--lch-blue-dark));
--color-code-token__comment: oklch(var(--lch-ink-medium));
+5
View File
@@ -44,6 +44,11 @@
to { transform: translateY(0); }
}
@keyframes slide-up-fade-in {
from { transform: translateY(2rem); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slide-down {
from { transform: translateY(0); }
to { transform: translateY(2rem); }
+5 -1
View File
@@ -127,7 +127,7 @@
}
@media (max-width: 799px) {
--padding-inline: var(--main-padding);
--padding-inline: 1.5ch;
column-gap: 0;
grid-template-areas:
@@ -150,6 +150,10 @@
border-radius: 0.2em;
grid-area: card;
padding: clamp(2rem, 4vw, var(--padding-block));
@media (max-width: 799px) {
padding-inline: var(--padding-inline);
}
}
.card-perma__actions {
+163 -89
View File
@@ -1,61 +1,39 @@
@layer components {
/* Editor
/* ------------------------------------------------------------------------ */
lexxy-editor {
display: block;
position: relative;
overflow: visible;
figure.node--selected {
&:not(:has(img)) {
outline: var(--focus-ring-size) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
&:has(img) {
img {
outline: var(--focus-ring-size) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
}
}
/* Lexical still uses the `lexxy-editor--empty` class if you have a list
started with no other characters. Here, we won't show the placeholder if
you've clicked the List button in the toolbar. */
&.lexxy-editor--empty {
.lexxy-editor__content:not(:has(ul, ol))::before {
content: attr(placeholder);
color: currentColor;
cursor: text;
opacity: 0.66;
pointer-events: none;
position: absolute;
white-space: pre-line;
&:not(:has(img)) {
outline: var(--focus-ring-size) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
}
}
.lexxy-dialog-actions {
display: flex;
font-size: var(--text-x-small);
flex: 1 1 0%;
gap: var(--inline-space-half);
margin-block-start: var(--block-space-half);
.btn {
--radius: 0.3em;
inline-size: 100%;
justify-content: center;
&:is([type="submit"]) {
--btn-background: var(--card-color, var(--color-link));
--btn-color: var(--color-ink-inverted);
--focus-ring-color: var(--card-color, var(--color-link));
}
}
span {
inline-size: 100%;
/* Lexical uses the `lexxy-editor--empty` class even if you have a list
* started but haven't added other characters. Here, we hide the placeholder
* when you click the List button in the toolbar. */
.lexxy-editor--empty {
.lexxy-editor__content:not(:has(ul, ol))::before {
content: attr(placeholder);
color: currentColor;
cursor: text;
opacity: 0.66;
pointer-events: none;
position: absolute;
white-space: pre-line;
}
}
@@ -64,14 +42,9 @@
min-block-size: calc(7lh + var(--block-space));
outline: 0;
* {
&:first-child {
margin-block-start: 0;
}
&:last-child {
margin-block-end: 0;
}
/* Allow color highlights to show through a bit */
::selection {
background: oklch(var(--lch-blue-light) / 0.5);
}
}
@@ -81,6 +54,40 @@
outline: 2px dashed var(--color-selected-dark);
}
.lexxy-code-language-picker {
-webkit-appearance: none;
appearance: none;
background-color: var(--color-canvas);
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
background-position: center right 0.9em;
background-repeat: no-repeat;
background-size: 0.5em;
border: 1px solid var(--color-ink-light);
border-radius: 99rem;
color: var(--color-ink);
cursor: pointer;
font-family: var(--font-base);
font-size: var(--text-x-small);
font-weight: 500;
inset-inline-end: 0;
line-height: 1.15lh;
margin: 0.75ch 0.75ch 0 0;
padding-inline: 1.5ch 1.8em;
text-align: start;
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
}
option {
background-color: var(--color-canvas);
color: var(--color-ink);
}
}
/* Toolbar
/* ------------------------------------------------------------------------ */
lexxy-toolbar {
--lexxy-toolbar-icon-size: 1em;
@@ -96,21 +103,6 @@
position: sticky;
inset-block-start: 0;
z-index: 1;
dialog {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.5em;
box-shadow: var(--shadow);
color: var(--color-ink);
padding: var(--block-space) calc(var(--inline-space) * 1.5);
position: absolute;
z-index: 1;
.input[type="url"] {
min-inline-size: 30ch;
}
}
}
.lexxy-editor__toolbar-button {
@@ -157,48 +149,130 @@
border-radius: 0.5ch;
box-shadow: var(--shadow);
display: flex;
flex-wrap: wrap;
inset-inline-end: 0;
padding: 4px;
position: absolute;
z-index: 1;
}
:where(.lexxy-editor__toolbar-spacer) {
.lexxy-editor__toolbar-spacer {
flex: 1;
}
/* Based on .input, .input--select */
.lexxy-code-language-picker {
-webkit-appearance: none;
appearance: none;
/* Dropdowns
/* ------------------------------------------------------------------------ */
.lexxy-editor__toolbar-dropdown-content {
--lexxy-dropdown-padding: 0.75rem;
--lexxy-dropdown-btn-size: 2rem;
background-color: var(--color-canvas);
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
background-position: center right 0.9em;
background-repeat: no-repeat;
background-size: 0.5em;
border: 1px solid var(--color-ink-lighter);
border-radius: 0.3em;
border-radius: 0.5em;
box-shadow: var(--shadow);
color: var(--color-ink);
font-family: var(--font-base);
font-size: var(--text-x-small);
font-weight: 500;
inset-inline-end: 0;
line-height: inherit;
margin: 0.3em 0.3em 0 0;
padding: 0.5em 1.8em 0.5em 1.2em;
text-align: start;
font-size: var(--text-small);
padding: var(--lexxy-dropdown-padding);
position: absolute;
z-index: 1;
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
}
option {
background-color: var(--color-canvas);
color: var(--color-ink);
button {
block-size: var(--lexxy-dropdown-btn-size);
}
}
/* Prompt
.lexxy-editor__toolbar-dropdown-actions {
display: flex;
font-size: var(--text-x-small);
flex: 1 1 0%;
gap: var(--lexxy-dropdown-padding);
margin-block-start: var(--lexxy-dropdown-padding);
.btn {
--radius: 99rem;
inline-size: 100%;
}
.btn[type="submit"] {
--btn-background: var(--card-color, var(--color-link));
--btn-color: var(--color-ink-inverted);
--focus-ring-color: var(--card-color, var(--color-link));
}
span {
inline-size: 100%;
}
}
lexxy-link-dropdown {
.input {
min-inline-size: 30ch;
}
}
lexxy-highlight-dropdown {
--gap: 0.5ch;
[data-button-group] {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--gap);
+ & {
margin-block-start: var(--gap);
}
}
}
.lexxy-highlight-button {
--outline-color: oklch(var(--lch-ink-darkest) / 0.2);
appearance: none;
background: var(--color-canvas);
border: none;
border-radius: 0.5ch;
color: inherit;
display: grid;
font-weight: 500;
inline-size: var(--lexxy-dropdown-btn-size);
place-content: center;
position: relative;
outline: none;
&:after {
content: "Aa";
}
&:hover,
&[aria-pressed="true"] {
box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color);
}
&[aria-pressed="true"] {
--outline-color: var(--color-link);
&:after {
content: "✓";
}
}
}
.lexxy-editor__toolbar-dropdown-reset {
background: var(--color-canvas);
border: 1px solid var(--color-ink-light);
border-radius: 99rem;
inline-size: 100%;
margin-block-start: var(--lexxy-dropdown-padding);
&[disabled] {
display: none;
}
}
/* Prompt ,enu (@mentions, etc.)
/* ------------------------------------------------------------------------ */
.lexxy-prompt-menu {
@@ -28,6 +28,19 @@
}
}
:where(ol, ul) {
padding-inline-start: 3ch;
}
:where(li:has(li)) {
list-style: none;
:where(ol, ul) {
margin: 0;
padding-inline-start: 2ch;
}
}
:where(b, strong, .lexxy-content__bold) {
font-weight: 700;
}
@@ -40,6 +53,10 @@
text-decoration: line-through;
}
:where(mark, .lexxy-content__highlight) {
background-color: transparent;
color: inherit;
}
:where(p, blockquote) {
letter-spacing: -0.005ch;
@@ -201,14 +218,17 @@
color: color-mix(in oklch, var(--color-ink) 66%, transparent);
font-size: var(--text-small);
.input {
textarea {
--input-border-radius: 0.3em;
--input-border-size: 0;
--input-padding: 0;
background-color: var(--input-background, transparent);
border: none;
color: inherit;
inline-size: 100%;
max-inline-size: 100%;
resize: none;
text-align: center;
&:focus {
@@ -274,7 +294,8 @@
display: flex;
flex-wrap: wrap;
gap: 1ch;
inline-size: auto;
inline-size: 100%;
margin-inline: 0;
.attachment__caption {
display: grid;
+2 -2
View File
@@ -2,12 +2,12 @@ class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close(user: Current.user)
@card.close
render_card_replacement
end
def destroy
@card.reopen(user: Current.user)
@card.reopen
render_card_replacement
end
end
@@ -97,6 +97,12 @@ module Authentication
end
end
def redirect_to_session_magic_link(magic_link, return_to: nil)
serve_development_magic_link(magic_link)
session[:return_to_after_authenticating] = return_to if return_to
redirect_to session_magic_link_url(script_name: nil)
end
def serve_development_magic_link(magic_link)
if Rails.env.development?
flash[:magic_link_code] = magic_link&.code
+23 -18
View File
@@ -3,6 +3,7 @@ class JoinCodesController < ApplicationController
before_action :set_join_code
before_action :ensure_join_code_is_valid
before_action :set_identity, only: :create
layout "public"
@@ -10,22 +11,35 @@ class JoinCodesController < ApplicationController
end
def create
identity = Identity.find_or_create_by!(email_address: params.expect(:email_address))
@join_code.redeem_if { |account| @identity.join(account) }
user = User.active.find_by!(account: @join_code.account, identity: @identity)
@join_code.redeem_if { |account| identity.join(account) }
user = User.active.find_by!(account: @join_code.account, identity: identity)
if identity == Current.identity && user.setup?
if @identity == Current.identity && user.setup?
redirect_to landing_url(script_name: @join_code.account.slug)
elsif identity == Current.identity
redirect_to new_users_join_url(script_name: @join_code.account.slug)
elsif @identity == Current.identity
redirect_to new_users_verification_url(script_name: @join_code.account.slug)
else
logout_and_send_new_magic_link(identity)
redirect_to session_magic_link_url(script_name: nil)
terminate_session if Current.identity
redirect_to_session_magic_link \
@identity.send_magic_link,
return_to: new_users_verification_url(script_name: @join_code.account.slug)
end
end
private
def set_identity
@identity = Identity.find_or_initialize_by(email_address: params.expect(:email_address))
if @identity.new_record?
if @identity.invalid?
head :unprocessable_entity
else
@identity.save!
end
end
end
def set_join_code
@join_code ||= Account::JoinCode.find_by(code: params.expect(:code), account: Current.account)
end
@@ -37,13 +51,4 @@ class JoinCodesController < ApplicationController
render :inactive, status: :gone
end
end
def logout_and_send_new_magic_link(identity)
terminate_session if Current.identity
magic_link = identity.send_magic_link
serve_development_magic_link(magic_link)
session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug)
end
end
+8 -9
View File
@@ -9,17 +9,16 @@ class SessionsController < ApplicationController
end
def create
identity = Identity.find_by_email_address(email_address)
magic_link = if identity
identity.send_magic_link
if identity = Identity.find_by_email_address(email_address)
redirect_to_session_magic_link identity.send_magic_link
else
Signup.new(email_address: email_address).create_identity
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
redirect_to_session_magic_link signup.create_identity
else
head :unprocessable_entity
end
end
serve_development_magic_link(magic_link)
redirect_to session_magic_link_path
end
def destroy
+6 -2
View File
@@ -11,8 +11,12 @@ class SignupsController < ApplicationController
end
def create
Signup.new(signup_params).create_identity
redirect_to session_magic_link_path
signup = Signup.new(signup_params)
if signup.valid?(:identity_creation)
redirect_to_session_magic_link signup.create_identity
else
head :unprocessable_entity
end
end
private
@@ -0,0 +1,11 @@
class Users::VerificationsController < ApplicationController
layout "public"
def new
end
def create
Current.user.verify
redirect_to new_users_join_path
end
end
+5 -2
View File
@@ -9,8 +9,11 @@ class UsersController < ApplicationController
end
def update
@user.update! user_params
redirect_to @user
if @user.update(user_params)
redirect_to @user
else
render :edit, status: :unprocessable_entity
end
end
def destroy
+5 -3
View File
@@ -12,7 +12,7 @@ module AccessesHelper
render partial: "boards/access_toggle",
collection: users, as: :user,
locals: { selected: selected, disabled: disabled },
cached: ->(user) { [ user, selected ] }
cached: ->(user) { [ user, selected, disabled ] }
end
def access_involvement_advance_button(board, user, show_watchers: true, icon_only: false)
@@ -40,13 +40,15 @@ module AccessesHelper
end
def involvement_button(board, access, show_watchers, icon_only)
label_text = access.access_only? ? "Watch this" : "Stop watching"
button_to(
board_involvement_path(board), method: :put,
params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only },
aria: { labelledby: dom_id(board, :involvement_label) }, title: access.access_only? ? "Watch this" : "Stop watching",
aria: { labelledby: dom_id(board, :involvement_label) },
title: (label_text if icon_only),
class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do
icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") +
tag.span(class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label))
tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label))
end
end
-8
View File
@@ -18,14 +18,6 @@ module CardsHelper
&block
end
def button_to_delete_card(card)
button_to card_path(card),
method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_frame: "_top", turbo_confirm: "Are you sure you want to permanently delete this card?" } do
concat(icon_tag("trash"))
concat(tag.span("Delete this card"))
end
end
def card_title_tag(card)
title = [
card.title,
@@ -1,13 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { fallbackDestination: String }
connect() {
if (history.state.turbo?.restorationIndex > 0) {
this.element.href = "javascript:history.back()"
} else {
this.element.href = this.fallbackDestinationValue
}
}
}
@@ -8,10 +8,21 @@ export default class extends Controller {
debounceTimeout: { type: Number, default: 300 }
}
#isComposing = false
initialize() {
this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), this.debounceTimeoutValue)
}
// IME Composition tracking
compositionStart() {
this.#isComposing = true
}
compositionEnd() {
this.#isComposing = false
}
submit() {
this.element.requestSubmit()
}
@@ -32,6 +43,12 @@ export default class extends Controller {
}
}
preventComposingSubmit(event) {
if (this.#isComposing) {
event.preventDefault()
}
}
debouncedSubmit(event) {
this.submit(event)
}
@@ -3,14 +3,6 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "caption", "image", "dialog", "zoomedImage" ]
connect() {
this.dialogTarget.addEventListener('transitionend', this.handleTransitionEnd.bind(this))
}
disconnect() {
this.dialogTarget.removeEventListener('transitionend', this.handleTransitionEnd.bind(this))
}
open(event) {
this.dialogTarget.showModal()
this.#set(event.target.closest("a"))
@@ -4,12 +4,18 @@ import { onNextEventLoopTick } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "input" ]
submitOnEnter(event) {
event.preventDefault()
this.submit()
}
submitOnPaste() {
onNextEventLoopTick(() => this.submit())
}
submit() {
onNextEventLoopTick(() => {
if (!this.inputTarget.disabled) {
this.element.submit()
this.inputTarget.disabled = true
}
})
if (this.inputTarget.disabled) return
this.element.submit()
this.inputTarget.disabled = true
}
}
@@ -245,6 +245,9 @@ export default class extends Controller {
}
},
Enter(event) {
// Skip handling during IME composition (e.g., Japanese input)
if (event.isComposing) { return }
if (event.shiftKey) {
this.#toggleCurrentItem(event)
} else {
+1 -1
View File
@@ -21,7 +21,7 @@ class Account < ApplicationRecord
def create_with_owner(account:, owner:)
create!(**account).tap do |account|
account.users.create!(role: :system, name: "System")
account.users.create!(**owner.reverse_merge(role: "owner"))
account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current))
end
end
end
+3 -6
View File
@@ -1,13 +1,10 @@
module Attachments
extend ActiveSupport::Concern
# The variants we use must all declared here so that we can preprocess them.
# Variants used by ActionText embeds. Processed immediately on attachment to avoid
# read replica issues (lazy variants would attempt writes on read replicas).
#
# If they are not preprocessed, then Rails will attempt to transform the image on-the-fly when
# they are first viewed, which may be on the read replica where writing to the database is not
# allowed. Chaos will ensue if that happens.
#
# These variants are patched into ActionText::RichText in config/initializers/action_text.rb
# Patched into ActionText::RichText in config/initializers/action_text.rb
VARIANTS = {
# vipsthumbnail used to create sized image variants has a intent setting to manage colors during
# resize. By setting an invalid intent value the gif-incompatible intent filtering is skipped and
+1 -1
View File
@@ -8,7 +8,7 @@ class Identity < ApplicationRecord
has_one_attached :avatar
before_destroy :deactivate_users
before_destroy :deactivate_users, prepend: true
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
+40
View File
@@ -1,14 +1,54 @@
class Push::Subscription < ApplicationRecord
PERMITTED_ENDPOINT_HOSTS = %w[
fcm.googleapis.com
updates.push.services.mozilla.com
web.push.apple.com
notify.windows.com
].freeze
belongs_to :account, default: -> { user.account }
belongs_to :user
validates :endpoint, presence: true
validate :validate_endpoint_url
def notification(**params)
WebPush::Notification.new(
**params,
badge: user.notifications.unread.count,
endpoint: endpoint,
endpoint_ip: resolved_endpoint_ip,
p256dh_key: p256dh_key,
auth_key: auth_key
)
end
def resolved_endpoint_ip
return @resolved_endpoint_ip if defined?(@resolved_endpoint_ip)
@resolved_endpoint_ip = SsrfProtection.resolve_public_ip(endpoint_uri&.host)
end
private
def endpoint_uri
@endpoint_uri ||= URI.parse(endpoint) if endpoint.present?
rescue URI::InvalidURIError
nil
end
def validate_endpoint_url
if endpoint_uri.nil?
errors.add(:endpoint, "is not a valid URL")
elsif endpoint_uri.scheme != "https"
errors.add(:endpoint, "must use HTTPS")
elsif !permitted_endpoint_host?
errors.add(:endpoint, "is not a permitted push service")
elsif resolved_endpoint_ip.nil?
errors.add(:endpoint, "resolves to a private or invalid IP address")
end
end
def permitted_endpoint_host?
host = endpoint_uri&.host&.downcase
PERMITTED_ENDPOINT_HOSTS.any? { |permitted| host&.end_with?(permitted) }
end
end
+2 -3
View File
@@ -6,9 +6,8 @@ class Signup
attr_accessor :full_name, :email_address, :identity
attr_reader :account, :user
with_options on: :completion do
validates_presence_of :full_name, :identity
end
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation
validates :full_name, :identity, presence: true, on: :completion
def initialize(...)
super
+10 -3
View File
@@ -3,14 +3,21 @@ module SsrfProtection
DNS_RESOLUTION_TIMEOUT = 2
DNS_NAMESERVERS = %w[
1.1.1.1
8.8.8.8
]
DISALLOWED_IP_RANGES = [
IPAddr.new("0.0.0.0/8") # Broadcasts
IPAddr.new("0.0.0.0/8"), # "This" network (RFC1700)
IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598)
IPAddr.new("198.18.0.0/15") # Benchmark testing (RFC2544)
].freeze
def resolve_public_ip(hostname)
ip_addresses = resolve_dns(hostname)
public_ips = ip_addresses.reject { |ip| private_address?(ip) }
public_ips.first&.to_s
public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s
end
def private_address?(ip)
@@ -22,7 +29,7 @@ module SsrfProtection
def resolve_dns(hostname)
ip_addresses = []
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
Resolv::DNS.open(nameserver: DNS_NAMESERVERS, timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
dns.each_address(hostname) do |ip_address|
ip_addresses << IPAddr.new(ip_address.to_s)
end
+8
View File
@@ -28,6 +28,14 @@ class User < ApplicationRecord
name != identity.email_address
end
def verified?
verified_at.present?
end
def verify
update!(verified_at: Time.current) unless verified?
end
private
def close_remote_connections
ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false)
+1 -1
View File
@@ -5,7 +5,7 @@ module User::Avatar
included do
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_fill: [ 256, 256 ]
attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately
end
validate :avatar_content_type_allowed
+5 -5
View File
@@ -30,15 +30,15 @@ class User::DayTimeline
end
def added_column
@added_column ||= build_column("Added", 1, events.where(action: %w[card_published card_reopened]))
@added_column ||= build_column(:added, "Added", 1, events.where(action: %w[card_published card_reopened]))
end
def updated_column
@updated_column ||= build_column("Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened]))
@updated_column ||= build_column(:updated, "Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened]))
end
def closed_column
@closed_column ||= build_column("Done", 3, events.where(action: "card_closed"))
@closed_column ||= build_column(:closed, "Done", 3, events.where(action: "card_closed"))
end
def cache_key
@@ -84,8 +84,8 @@ class User::DayTimeline
filtered_events.where(created_at: ...day.beginning_of_day).chronologically.last
end
def build_column(base_title, index, events)
Column.new(self, base_title, index, events)
def build_column(id, base_title, index, events)
Column.new(self, id, base_title, index, events)
end
def window
+7 -11
View File
@@ -1,9 +1,10 @@
class User::DayTimeline::Column
include ActionView::Helpers::TagHelper, ActionView::Helpers::OutputSafetyHelper, TimeHelper
attr_reader :index, :base_title, :day_timeline, :events
attr_reader :index, :id, :base_title, :day_timeline, :events
def initialize(day_timeline, base_title, index, events)
def initialize(day_timeline, id, base_title, index, events)
@id = id
@day_timeline = day_timeline
@base_title = base_title
@index = index
@@ -17,15 +18,6 @@ class User::DayTimeline::Column
safe_join(parts, " ")
end
def base_title_for_route
case base_title
when "Done"
"closed"
else
base_title.downcase
end
end
def events_by_hour
limited_events.group_by { it.created_at.hour }
end
@@ -38,6 +30,10 @@ class User::DayTimeline::Column
full_events_count - limited_events.count
end
def to_param
id
end
private
def limited_events
@limited_events ||= events.limit(100).load
+1 -1
View File
@@ -21,7 +21,7 @@ class User::Settings < ApplicationRecord
end
def bundling_emails?
!bundle_email_never? && !user.system? && user.active?
!bundle_email_never? && !user.system? && user.active? && user.verified?
end
def timezone
+2 -1
View File
@@ -78,7 +78,8 @@ class Webhook::Delivery < ApplicationRecord
end
def http
Net::HTTP.new(uri.host, uri.port, ipaddr: resolved_ip).tap do |http|
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.ipaddr = resolved_ip
http.use_ssl = (uri.scheme == "https")
http.open_timeout = ENDPOINT_TIMEOUT
http.read_timeout = ENDPOINT_TIMEOUT
+14 -5
View File
@@ -1,7 +1,16 @@
<%= form_with model: board, class: "txt-align-center margin-block-start-auto", method: :delete do |form| %>
<%= form.button class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to permanently delete this board and all the cards on it? This can't be undone." } do %>
<div class="txt-align-center margin-block-start-auto" data-controller="dialog" data-dialog-modal-value="true" data-action="keydown.esc->dialog#close:stop">
<button type="button" class="btn txt-negative borderless txt-small" data-action="dialog#open">
<%= icon_tag "trash" %>
<span>Delete this board</span>
<% end %>
<% end %>
</button>
<dialog class="dialog panel fill-white shadow gap flex-column" style="max-width: 40ch" data-dialog-target="dialog">
<h3 class="txt-large txt-bold">Delete this board?</h3>
<p class="txt-medium margin-block-half">Are you sure you want to permanently delete this board and all the cards on it? This can't be undone.</p>
<div class="flex gap-half justify-center margin-block-start">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to board_path(board), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %>
<span>Delete board</span>
<% end %>
</div>
</dialog>
</div>
+6 -5
View File
@@ -1,3 +1,4 @@
<% disabled = !Current.user.can_administer_board?(board) %>
<header>
<h2 class="divider txt-medium margin-block-start">Who can access this board?</h2>
</header>
@@ -16,7 +17,7 @@
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-ink-medium); --border-style: dashed" aria-hidden="true" />
<label for="board_all_access" class="switch">
<%= form.check_box :all_access, class: "switch__input", checked: board.all_access?, disabled: !Current.user.can_administer_board?(board), data: { action: "change->toggle-class#toggle" } %>
<%= form.check_box :all_access, class: "switch__input", checked: board.all_access?, disabled:, data: { action: "change->toggle-class#toggle" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Give everyone access to this board</span>
</label>
@@ -26,14 +27,14 @@
<div class="toggler__visible-when-off">
<div class="flex align-center justify-end gap-half">
<button type="button" class="btn btn--plain txt-x-small txt-link font-weight-normal" data-action="click->toggle-class#checkAll">Select all</button>
<%= button_tag "Select all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkAll" }, disabled: %>
<span class="txt-subtle">&middot;</span>
<button type="button" class="btn btn--plain txt-x-small txt-link font-weight-normal" data-action="click->toggle-class#checkNone">Select none</button>
<%= button_tag "Select none", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkNone" }, disabled: %>
</div>
</div>
<ul class="settings__user-list" data-filter-target="list">
<%= access_toggles_for selected_users, selected: true, disabled: !Current.user.can_administer_board?(board) %>
<%= access_toggles_for unselected_users, selected: false, disabled: !Current.user.can_administer_board?(board) %>
<%= access_toggles_for selected_users, selected: true, disabled: %>
<%= access_toggles_for unselected_users, selected: false, disabled: %>
</ul>
<% end %>
+16
View File
@@ -0,0 +1,16 @@
<div data-controller="dialog" data-dialog-modal-value="true" data-action="keydown.esc->dialog#close:stop">
<button type="button" class="btn txt-negative borderless txt-small" data-action="dialog#open">
<%= icon_tag "trash" %>
<span>Delete this card</span>
</button>
<dialog class="dialog panel fill-white shadow gap flex-column" style="max-width: 40ch" data-dialog-target="dialog">
<h3 class="txt-large txt-bold">Delete this card?</h3>
<p class="txt-medium margin-block-half">Are you sure you want to permanently delete this card?</p>
<div class="flex gap-half justify-center margin-block-start">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to card_path(card), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %>
<span>Delete card</span>
<% end %>
</div>
</dialog>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
<% if Current.user.can_administer_card?(card) %>
<footer class="delete-card txt-align-center full-width flex flex-column margin-block-start-double">
<hr class="separator--horizontal full-width margin-block" style="--border-color: var(--color-ink-lighter)">
<%= button_to_delete_card(card) %>
<%= render "cards/delete", card: card %>
</footer>
<% end %>
<% end %>
@@ -2,14 +2,14 @@
<%= form_with model: [ @comment.card, @comment, Reaction.new ],
class: "reaction reaction__form expanded",
html: { aria: { label: "New reaction" } },
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit" } do |form| %>
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
<label class="reaction__form-label flex gap" style="--column-gap: 0.4ch;">
<figure class="reaction__avatar margin-none flex-item-no-shrink">
<%= avatar_tag Current.user %>
</figure>
<%= form.text_field :content, autofocus: true, autocomplete: "off", autocorrect: "off", maxlength: 16,
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input" }, aria: { label: "Add a reaction" } %>
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a reaction" } %>
</label>
<%= render "cards/comments/reactions/menu", comment: @comment %>
@@ -4,8 +4,8 @@
<% unless card.closed? %>
<li id="<%= dom_id(card, :new_step) %>" class="step">
<input type="checkbox" class="step__checkbox" disabled>
<%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit turbo:submit-end->form#reset" } do |form| %>
<%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true" }, aria: { label: "Add a step" } %>
<%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit turbo:submit-end->form#reset" } do |form| %>
<%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a step" } %>
<% end %>
</li>
<% end %>
+2 -2
View File
@@ -11,10 +11,10 @@
<%= form_with url: card_taggings_path(@card),
id: dom_id(@card, :tags_form),
data: { controller: "form", action: "submit->form#preventEmptySubmit" },
data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit" },
class: "flex flex-column gap-half full-width margin-block-half" do |form| %>
<%= form.text_field :tag_title, placeholder: @tags.any? ? "Add a new tag or filter…" : "Name this tag…", class: "input txt-small full-width",
autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter" } %>
autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter compositionstart->form#compositionStart compositionend->form#compositionEnd" } %>
<% end %>
<ul class="popup__list" data-filter-target="list">
@@ -3,7 +3,7 @@
<%= column.title %>
<% if column.events_by_hour.any? %>
<%= link_to events_day_timeline_column_path(id: column.base_title_for_route, day: column.day_timeline.day.to_date), class: "events__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= link_to events_day_timeline_column_path(column, day: column.day_timeline.day.to_date), class: "events__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= icon_tag "grid", class: "translucent" %>
<span class="for-screen-reader">Expand column</span>
<% end %>
@@ -2,7 +2,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Actvity", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
<%= back_link_to "Activity", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title">
+1 -1
View File
@@ -1,5 +1,5 @@
<%= tag.dialog class:"lightbox", aria: { label: "Image Viewer (Press escape to close)" },
data: { controller: "dialog", dialog_target: "dialog", dialog_modal_value: "true", lightbox_target: "dialog", action: "keydown.esc->dialog#close:stop" } do %>
data: { controller: "dialog", dialog_target: "dialog", dialog_modal_value: "true", lightbox_target: "dialog", action: "keydown.esc->dialog#close:stop transitionend->lightbox#handleTransitionEnd" } do %>
<figure class="lightbox__figure">
<img src="" class="lightbox__image" data-lightbox-target="zoomedImage" />
+1 -1
View File
@@ -15,7 +15,7 @@
<% end %>
</div>
<div class="overflow-ellipsis translucent txt-ink">
<%= result.card.board.name %>
<%= result.card.board.name %> · <%= local_datetime_tag(result.created_at, style: :timeordate) %>
</div>
<% end %>
</li>
+7 -5
View File
@@ -10,16 +10,18 @@
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large",
autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true,
autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code],
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submit paste->magic-link#submit" } %>
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>
</div>
<% if Rails.env.development? %>
<script>
console.log("Magic link code: <%= flash[:magic_link_code].presence || "not generated" %>");
</script>
<% if Rails.env.development? && flash[:magic_link_code].present? %>
<div class="flex align-center justify-center gap fill-shade border-radius pad margin-block-start full-width position-relative" style="animation: slide-up-fade-in 400ms ease-out both; max-width: 42ch; overflow: hidden;">
<span class="txt-uppercase translucent txt-tight-lines">Psst, here's your code:</span>
<code class="txt-large font-weight-black txt-tight-lines"><%= flash[:magic_link_code] %></code>
<span class="txt-xx-small txt-uppercase font-weight-black txt-align-center flex align-center justify-center" style="position: absolute; top: 0; bottom: 0; left: 0; background: var(--color-ink-lighter); padding: 0 0.5em; writing-mode: vertical-rl; transform: rotate(180deg); line-height: 1;">DEV</span>
</div>
<% end %>
<% content_for :footer do %>
+3 -2
View File
@@ -9,8 +9,9 @@
<p>You're one step away. Just enter your name to get your own Fizzy account.</p>
<% if @signup.errors.any? %>
<div class="margin-block-half">
<ul class="margin-block-none txt-negative txt-small">
<div class="margin-block-half txt-negative txt-small" style="text-align: left;">
<p class="margin-block-none font-weight-bold">Your changes couldn't be saved:</p>
<ul class="margin-block-none">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
+13 -1
View File
@@ -14,7 +14,7 @@
<label class="avatar btn btn--circle input--file txt-xx-large center fill-white">
<%= image_tag user_avatar_path(@user), aria: { hidden: "true" }, class: "avatar", size: 128, data: { upload_preview_target: "image" } %>
<%= form.file_field :avatar, id: "file", class: "input", accept: "image/jpeg, image/png, image/gif, image/webp",
<%= form.file_field :avatar, id: "file", class: "input", accept: User::Avatar::ALLOWED_AVATAR_CONTENT_TYPES.join(","),
data: { upload_preview_target: "input", action: "upload-preview#previewImage" } %>
<span class="for-screen-reader">Profile avatar for <%= @user.name %></span>
</label>
@@ -38,6 +38,18 @@
<%= link_to "Change email", new_user_email_address_path(user_id: Current.user.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
</div>
</div>
<% if @user.errors.any? %>
<div class="margin-block-half txt-negative txt-small" style="text-align: left;">
<p class="margin-block-none font-weight-bold">Your changes couldn't be saved:</p>
<ul class="margin-block-none">
<% @user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" id="log_in" class="btn btn--reversed center" data-form-target="submit">
<span>Save changes</span>
</button>
+17 -10
View File
@@ -18,20 +18,25 @@
<div class="flex flex-column gap-half margin-block-end">
<h1 class="txt-x-large margin-none"><%= @user.name %></h1>
<div class="txt-medium">
<% if @user.active? %>
<%= mail_to @user.identity.email_address %>
<% else %>
<% if !@user.active? %>
<%= @user.name %> is no longer on this account
<% elsif !@user.verified? %>
Unverified
<div class="txt-small txt-tinted">A sign-in code has been sent to this email address, but the user has not yet logged in to confirm their identity.</div>
<% else %>
<%= mail_to @user.identity.email_address %>
<% end %>
</div>
</div>
<div class="flex-inline center justify-center flex-wrap gap">
<%= link_to "Which cards are assigned to #{me_or_you}?",
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
<%= link_to "Which cards were added by #{me_or_you}?",
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
</div>
<% if @user.verified? %>
<div class="flex-inline center justify-center flex-wrap gap">
<%= link_to "Which cards are assigned to #{me_or_you}?",
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
<%= link_to "Which cards were added by #{me_or_you}?",
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
</div>
<% end %>
</div>
</section>
@@ -48,4 +53,6 @@
<% end %>
</div>
<%= turbo_frame_tag "user_events", src: user_events_path(@user) %>
<% if @user.verified? %>
<%= turbo_frame_tag "user_events", src: user_events_path(@user) %>
<% end %>
@@ -0,0 +1 @@
<%= auto_submit_form_with url: users_verifications_path, method: :post %>
+1 -1
View File
@@ -22,7 +22,7 @@
</header>
<% unless @webhook.active? %>
<div>
<%= button_to "Reactivate", board_webhook_activation_path(@webhook), method: :post %>
<%= button_to "Reactivate", board_webhook_activation_path(@webhook.board_id, @webhook), method: :post %>
</div>
<% end %>
+2
View File
@@ -19,6 +19,8 @@ CI.run do
if Fizzy.saas?
step "Tests: SaaS", "#{SAAS_ENV} bin/rails test"
step "Tests: SaaS System", "#{SAAS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system"
step "Tests: OSS", "#{OSS_ENV} bin/rails test"
step "Tests: OSS System", "#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system"
else
step "Tests: SQLite", "#{OSS_ENV} bin/rails test"
step "Tests: SQLite System", "#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system"
+27 -86
View File
@@ -1,65 +1,45 @@
# Name of your application. Used to uniquely configure containers.
# Name of this app
service: fizzy
# Name of the container image (use your-user/app-name on external registries).
image: fizzy
# Deploy to these servers.
#-- About your deployment --#
# Where to deploy fizzy
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
- fizzy.example.com # Set your server name here
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: app.example.com
# How you connect to your server
ssh:
user: root # If you use a different username to SSH to your server, specify it here
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Automatic SSL
proxy:
ssl: true # Set this to false if you *don't* want SSL
host: fizzy.example.com # Set your server name here to use automatic SSL
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
# Your application configuration (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
- SECRET_KEY_BASE
- VAPID_PUBLIC_KEY
- VAPID_PRIVATE_KEY
- SMTP_USERNAME
- SMTP_PASSWORD
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
MAILER_FROM_ADDRESS: support@example.com # The email address that Fizzy sends email from
SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
#-- General configuration --#
# Match this to any external database server to configure Active Record correctly
# Use fizzy-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Use a local registry to deploy
registry:
server: localhost:5555
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
# Handy aliases for interacting with your deployment. For eaxmple: `bin/kamal console` will connect to a
# Rails console in production.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
@@ -76,45 +56,6 @@ volumes:
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.7
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data
+15
View File
@@ -3,6 +3,21 @@ require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Email provider Settings
#
# Configure these according to whichever email provider you use. An example setup
# using SMTP looks like the following:
#
# config.action_mailer.smtp_settings = {
# address: 'smtp.example.com', # The address of your email provider's SMTP server
# port: 2525,
# domain: 'example.com', # Your domain, which Fizzy will send email from
# user_name: ENV["SMTP_USERNAME"],
# password: ENV["SMTP_PASSWORD"],
# authentication: :plain,
# enable_starttls_auto: true
# }
# Code is not reloaded between requests.
config.enable_reloading = false
+1 -1
View File
@@ -7,7 +7,7 @@ module ActionText
# This overrides the default :embeds association!
has_many_attached :embeds do |attachable|
::Attachments::VARIANTS.each do |variant_name, variant_options|
attachable.variant variant_name, variant_options.merge(preprocessed: true)
attachable.variant variant_name, **variant_options, process: :immediately
end
end
end
+11 -3
View File
@@ -4,9 +4,17 @@ ActiveSupport.on_load(:active_storage_blob) do
end
end
ActiveSupport.on_load(:active_storage_record) do
configure_replica_connections
end
# Don't configure replica connections for ActiveStorage::Record.
# When ActiveStorage uses `connects_to`, it creates a separate connection pool
# from ApplicationRecord. This causes after_commit callbacks to fire in
# non-deterministic order - the Attachment's create_variants callback can fire
# before the User model's upload callback, causing FileNotFoundError when
# using `process: :immediately` for variants.
# See: https://github.com/rails/rails/issues/53694
#
# ActiveSupport.on_load(:active_storage_record) do
# configure_replica_connections
# end
module ActiveStorageControllerExtensions
extend ActiveSupport::Concern
+8 -6
View File
@@ -3,12 +3,14 @@
Autotuner.enabled = true
# This callback is called whenever a suggestion is provided by this gem.
# You can output this report to your logging pipeline, stdout, a file,
# or somewhere else!
Autotuner.reporter = proc do |report|
# Log to structured logging for query/analysis in Loki. This is called
# once per autotuner heuristic.
Autotuner.reporter = proc do |heuristic_report|
report = heuristic_report.to_s
Rails.logger.info "GCAUTOTUNE: #{report}"
if Fizzy.saas?
Sentry.capture_message "Autotuner suggestion", level: :info, extra: { report: report.to_s }
end
RailsStructuredLogging.instrument_script "autotuner" do
Rails.logger.info report.to_s
end if defined? RailsStructuredLogging
end
+42 -22
View File
@@ -1,25 +1,45 @@
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Define an application-wide Content Security Policy.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
Rails.application.configure do
# Configure with environment variables with fallback to config.x values (via fizzy-sass)
report_uri = ENV.fetch("CSP_REPORT_URI") { config.x.content_security_policy.report_uri }
report_only =
if ENV.key?("CSP_REPORT_ONLY")
ENV["CSP_REPORT_ONLY"] == "true"
else
config.x.content_security_policy.report_only
end
# Generate nonces for importmap and inline scripts
config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[ script-src ]
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self, "https://challenges.cloudflare.com"
policy.connect_src :self, "https://storage.basecamp.com"
policy.frame_src :self, "https://challenges.cloudflare.com"
# Don't fight user tools: permit inline styles, data:/https: sources, and
# blob: workers for accessibility extensions, privacy tools, and custom fonts.
policy.style_src :self, :unsafe_inline
policy.img_src :self, "blob:", "data:", "https:"
policy.font_src :self, "data:", "https:"
policy.media_src :self, "blob:", "data:", "https:"
policy.worker_src :self, "blob:"
policy.object_src :none
policy.base_uri :none
policy.form_action :self
policy.frame_ancestors :self
# Specify URI for violation reports (e.g., Sentry CSP endpoint)
policy.report_uri report_uri if report_uri
end
# Report violations without enforcing the policy.
config.content_security_policy_report_only = report_only
end unless ENV["DISABLE_CSP"]
+7
View File
@@ -0,0 +1,7 @@
# Lazily add identity and account context to error reports.
# Only evaluated when an error is actually reported.
Rails.error.add_middleware ->(error, context:, **) do
context.merge \
identity_id: Current.identity&.id,
account_id: Current.account&.external_account_id
end
+9 -1
View File
@@ -17,7 +17,15 @@ end
module WebPush::PersistentRequest
def perform
if @options[:connection]
endpoint_ip = @options[:endpoint_ip]
if endpoint_ip
http = Net::HTTP.new(uri.host, uri.port, ipaddr: endpoint_ip)
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
elsif @options[:connection]
http = @options[:connection]
else
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
+1 -1
View File
@@ -25,7 +25,7 @@ if !Rails.env.local?
# worker per CPU, 1 thread per worker and tune it from there.
#
# https://edgeguides.rubyonrails.org/tuning_performance_for_deployment.html#puma
workers Concurrent.physical_processor_count
workers Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
threads 1, 1
# Tell the Ruby VM that we're finished booting up.
+1 -1
View File
@@ -5,7 +5,7 @@ default: &default
workers:
- queues: [ "default", "solid_queue_recurring", "backend", "webhooks" ]
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", Concurrent.physical_processor_count) %>
processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %>
polling_interval: 0.1
development: *default
+1
View File
@@ -138,6 +138,7 @@ Rails.application.routes.draw do
namespace :users do
resources :joins
resources :verifications, only: %i[ new create ]
end
resource :session do
@@ -0,0 +1,5 @@
class AddVerifiedAtToUsers < ActiveRecord::Migration[8.2]
def change
add_column :users, :verified_at, :datetime
end
end
Generated
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -726,6 +726,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
t.string "name", null: false
t.string "role", default: "member", null: false
t.datetime "updated_at", null: false
t.datetime "verified_at"
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
t.index ["identity_id"], name: "index_users_on_identity_id"
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -499,6 +499,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
t.string "name", limit: 255, null: false
t.string "role", limit: 255, default: "member", null: false
t.datetime "updated_at", null: false
t.datetime "verified_at"
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
t.index ["identity_id"], name: "index_users_on_identity_id"
+1 -1
View File
@@ -36,7 +36,7 @@ else
if user = identity.users.find_by(account: Current.account)
user
else
User.create!(name: full_name, identity: identity, account: Current.account)
User.create!(name: full_name, identity: identity, account: Current.account, verified_at: Time.current)
end
end
+3 -3
View File
@@ -1,13 +1,13 @@
class WebPush::Notification
def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:)
def initialize(title:, body:, path:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:)
@title, @body, @path, @badge = title, body, path, badge
@endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key
@endpoint, @endpoint_ip, @p256dh_key, @auth_key = endpoint, endpoint_ip, p256dh_key, auth_key
end
def deliver(connection: nil)
WebPush.payload_send \
message: encoded_message,
endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key,
endpoint: @endpoint, endpoint_ip: @endpoint_ip, p256dh: @p256dh_key, auth: @auth_key,
vapid: vapid_identification,
connection: connection,
urgency: "high"
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env ruby
require_relative "../../config/environment"
BACKFILL_TIMESTAMP = Time.parse("2025-12-02 12:00:00 UTC")
def collect_verified_user_ids
verified_ids = Set.new
# Owners (they created the account)
verified_ids.merge(User.where(role: :owner).pluck(:id))
puts "After owners: #{verified_ids.size} users"
# Card creators
verified_ids.merge(Card.distinct.pluck(:creator_id).compact)
puts "After card creators: #{verified_ids.size} users"
# Comment creators
verified_ids.merge(Comment.distinct.pluck(:creator_id).compact)
puts "After comment creators: #{verified_ids.size} users"
# Board creators
verified_ids.merge(Board.distinct.pluck(:creator_id).compact)
puts "After board creators: #{verified_ids.size} users"
# Event creators
verified_ids.merge(Event.distinct.pluck(:creator_id).compact)
puts "After event creators: #{verified_ids.size} users"
# Assigners (not assignees - they could be assigned without logging in)
verified_ids.merge(Assignment.distinct.pluck(:assigner_id).compact)
puts "After assigners: #{verified_ids.size} users"
# Manual closers (user_id is nil for automatic closures)
verified_ids.merge(Closure.where.not(user_id: nil).distinct.pluck(:user_id).compact)
puts "After closers: #{verified_ids.size} users"
# Manual postponers (user_id is nil for automatic entropy postponements)
verified_ids.merge(Card::NotNow.where.not(user_id: nil).distinct.pluck(:user_id).compact)
puts "After postponers: #{verified_ids.size} users"
# Reactors
verified_ids.merge(Reaction.distinct.pluck(:reacter_id).compact)
puts "After reactors: #{verified_ids.size} users"
# Filter creators
verified_ids.merge(Filter.distinct.pluck(:creator_id).compact)
puts "After filter creators: #{verified_ids.size} users"
# Pinners
verified_ids.merge(Pin.distinct.pluck(:user_id).compact)
puts "After pinners: #{verified_ids.size} users"
# Board accessors (accessed_at is touched when viewing boards)
verified_ids.merge(Access.where.not(accessed_at: nil).distinct.pluck(:user_id).compact)
puts "After board accessors: #{verified_ids.size} users"
# Export requesters
verified_ids.merge(Account::Export.distinct.pluck(:user_id).compact)
puts "After export requesters: #{verified_ids.size} users"
# Push subscribers
verified_ids.merge(Push::Subscription.distinct.pluck(:user_id).compact)
puts "After push subscribers: #{verified_ids.size} users"
# Users who completed setup (name != email)
verified_ids.merge(
User.joins(:identity)
.where.not("users.name = identities.email_address")
.pluck(:id)
)
puts "After setup completers: #{verified_ids.size} users"
# Users whose identity has at least one session
verified_ids.merge(
User.where(identity_id: Session.distinct.select(:identity_id)).pluck(:id)
)
puts "After identity sessions: #{verified_ids.size} users"
verified_ids
end
puts "Collecting verified user IDs..."
verified_user_ids = collect_verified_user_ids
puts "\nFiltering to unverified users only..."
users_to_update = User.where(id: verified_user_ids.to_a)
.where(verified_at: nil)
.where(active: true)
.where.not(identity_id: nil)
.where.not(role: :system)
update_count = users_to_update.count
puts "Found #{update_count} users to backfill"
# Report remaining unverified users (before update)
remaining_before = User.where(verified_at: nil, active: true)
.where.not(identity_id: nil)
.where.not(role: :system)
.count
remaining_after = remaining_before - update_count
puts "\nCurrently unverified active users: #{remaining_before}"
puts "After backfill, remaining unverified: #{remaining_after}"
puts "These users will need to verify on next login."
if update_count > 0
puts "\nBackfilling verified_at..."
updated = users_to_update.update_all(verified_at: BACKFILL_TIMESTAMP)
puts "Updated #{updated} users"
end
puts "\nDone!"
@@ -145,4 +145,49 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
assert_response :forbidden
end
test "disables select all/none buttons for non-privileged user" do
logout_and_sign_in_as :jz
assert_not users(:jz).can_administer_board?(boards(:writebook))
get edit_board_path(boards(:writebook))
assert_response :success
assert_select "button[disabled]", text: "Select all"
assert_select "button[disabled]", text: "Select none"
end
test "enables select all/none buttons for privileged user" do
assert users(:kevin).can_administer_board?(boards(:writebook))
get edit_board_path(boards(:writebook))
assert_response :success
assert_select "button:not([disabled])", text: "Select all"
assert_select "button:not([disabled])", text: "Select none"
end
test "access toggle disabled state is cached correctly" do
board = boards(:writebook)
david = users(:david)
with_actionview_partial_caching do
# privileged user
assert users(:kevin).can_administer_board?(board)
get edit_board_path(board)
assert_response :success
assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}']:not([disabled])"
# unprivileged user
logout_and_sign_in_as :jz
assert_not users(:jz).can_administer_board?(board)
get edit_board_path(board)
assert_response :success
assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]"
end
end
end
+28 -3
View File
@@ -36,7 +36,7 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
end
assert_redirected_to session_magic_link_url(script_name: nil)
assert_equal new_users_join_url(script_name: @account.slug), session[:return_to_after_authenticating]
assert_equal new_users_verification_url(script_name: @account.slug), session[:return_to_after_authenticating]
end
test "create for existing identity" do
@@ -55,7 +55,7 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to landing_url(script_name: @account.slug)
end
test "create for signed-in identity without a user in the account redirects to user setup" do
test "create for signed-in identity without a user in the account redirects to verification" do
identity = identities(:mike)
sign_in_as :mike
@@ -67,6 +67,31 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
end
end
assert_redirected_to new_users_join_url(script_name: @account.slug)
assert_redirected_to new_users_verification_url(script_name: @account.slug)
end
test "create for different identity terminates existing session" do
sign_in_as :kevin
assert_difference -> { Identity.count }, 1 do
assert_difference -> { User.count }, 1 do
post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "new_user@example.com" }
end
end
assert_redirected_to session_magic_link_url(script_name: nil)
assert_not_predicate cookies[:session_token], :present?
end
test "create with invalid email address" do
# Avoid Sentry exceptions when attackers try to stuff invalid emails into the system
without_action_dispatch_exception_handling do
assert_no_difference -> { Identity.count } do
assert_no_difference -> { User.count } do
post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "not-a-valid-email" }
end
end
assert_response :unprocessable_entity
end
end
end
@@ -0,0 +1,24 @@
require "test_helper"
class Notifications::SettingsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:david)
sign_in_as @user
end
test "show" do
get notifications_settings_path
assert_response :success
end
test "update email frequency" do
assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do
put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } }
end
assert_redirected_to notifications_settings_path
assert_equal "Settings updated", flash[:notice]
end
end
+21 -7
View File
@@ -36,15 +36,29 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end
end
private
test "destroy" do
sign_in_as :kevin
test "create with invalid email address" do
# Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form
# field validation that should normally prevent this from occurring, so I'm not worried about
# returning proper validation errors.
without_action_dispatch_exception_handling do
untenanted do
delete session_path
assert_no_difference -> { Identity.count } do
post session_path, params: { email_address: "not-a-valid-email" }
end
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
assert_response :unprocessable_entity
end
end
end
test "destroy" do
sign_in_as :kevin
untenanted do
delete session_path
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
end
end
end
+9 -7
View File
@@ -34,15 +34,17 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
end
end
test "create with email address containing blanks" do
untenanted do
assert_no_difference -> { Identity.count } do
assert_no_difference -> { MagicLink.count } do
post signup_path, params: { signup: { email_address: "sam smith@example.com" } }
test "create with invalid email address" do
without_action_dispatch_exception_handling do
untenanted do
assert_no_difference -> { Identity.count } do
assert_no_difference -> { MagicLink.count } do
post signup_path, params: { signup: { email_address: "not-a-valid-email" } }
end
end
end
assert_response :unprocessable_entity
assert_response :unprocessable_entity
end
end
end
@@ -1,12 +1,15 @@
require "test_helper"
class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
PUBLIC_TEST_IP = "142.250.185.206"
setup do
sign_in_as :david
stub_dns_resolution(PUBLIC_TEST_IP)
end
test "create new push subscription" do
subscription_params = { "endpoint" => "https://apple", "p256dh_key" => "123", "auth_key" => "456" }
subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
post user_push_subscriptions_path(users(:david)),
params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" }
@@ -19,7 +22,7 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
test "touch existing subscription" do
existing_subscription = users(:david).push_subscriptions.create!(
endpoint: "https://apple",
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "123",
auth_key: "456"
)
@@ -37,7 +40,7 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
test "destroy a push subscription" do
subscription = users(:david).push_subscriptions.create!(
endpoint: "https://apple",
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "123",
auth_key: "456"
)
@@ -47,4 +50,35 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to user_push_subscriptions_path(users(:david))
end
end
test "rejects subscription with non-permitted endpoint" do
subscription_params = { "endpoint" => "https://attacker.example.com/steal", "p256dh_key" => "123", "auth_key" => "456" }
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_path(users(:david)),
params: { push_subscription: subscription_params }
end
assert_response :unprocessable_entity
end
test "rejects subscription with endpoint resolving to private IP" do
stub_dns_resolution("192.168.1.1")
subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
assert_no_difference -> { Push::Subscription.count } do
post user_push_subscriptions_path(users(:david)),
params: { push_subscription: subscription_params }
end
assert_response :unprocessable_entity
end
private
def stub_dns_resolution(*ips)
dns_mock = mock("dns")
dns_mock.stubs(:each_address).multiple_yields(*ips)
Resolv::DNS.stubs(:open).yields(dns_mock)
end
end
@@ -0,0 +1,24 @@
require "test_helper"
class Users::VerificationsControllerTest < ActionDispatch::IntegrationTest
test "new renders the auto-submit form" do
sign_in_as :david
get new_users_verification_path
assert_response :ok
end
test "create verifies the user and redirects to join" do
sign_in_as :david
user = users(:david)
user.update_column(:verified_at, nil)
assert_not user.verified?
post users_verifications_path
assert_redirected_to new_users_join_path
assert user.reload.verified?
end
end
+22
View File
@@ -64,4 +64,26 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
delete user_path(users(:david))
assert_response :forbidden
end
test "update with invalid avatar shows validation error" do
sign_in_as :kevin
svg_file = fixture_file_upload("avatar.svg", "image/svg+xml")
put user_path(users(:kevin)), params: { user: { avatar: svg_file } }
assert_response :unprocessable_entity
assert_select "form[action='#{user_path(users(:kevin))}']"
assert_select ".txt-negative", text: /must be a JPEG, PNG, GIF, or WebP image/
end
test "update with valid avatar" do
sign_in_as :kevin
png_file = fixture_file_upload("avatar.png", "image/png")
put user_path(users(:kevin)), params: { user: { avatar: png_file } }
assert_redirected_to user_path(users(:kevin))
assert users(:kevin).reload.avatar.attached?
assert_equal "image/png", users(:kevin).avatar.content_type
end
end
@@ -14,6 +14,10 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
webhook = webhooks(:active)
get board_webhook_path(webhook.board, webhook)
assert_response :success
webhook = webhooks(:inactive)
get board_webhook_path(webhook.board, webhook)
assert_response :success
end
test "new" do
@@ -63,6 +67,11 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
get edit_board_webhook_path(webhook.board, webhook)
assert_response :success
assert_select "form"
webhook = webhooks(:inactive)
get edit_board_webhook_path(webhook.board, webhook)
assert_response :success
assert_select "form"
end
test "update with valid params" do
+5
View File
@@ -4,6 +4,7 @@ david:
role: member
identity: david
account: 37s_uuid
verified_at: <%= Time.current.to_fs(:db) %>
jz:
id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %>
@@ -11,6 +12,7 @@ jz:
role: member
identity: jz
account: 37s_uuid
verified_at: <%= Time.current.to_fs(:db) %>
jason:
id: <%= ActiveRecord::FixtureSet.identify("jason", :uuid) %>
@@ -18,6 +20,7 @@ jason:
role: owner
identity: jason
account: 37s_uuid
verified_at: <%= Time.current.to_fs(:db) %>
kevin:
id: <%= ActiveRecord::FixtureSet.identify("kevin", :uuid) %>
@@ -25,6 +28,7 @@ kevin:
role: admin
identity: kevin
account: 37s_uuid
verified_at: <%= Time.current.to_fs(:db) %>
system:
id: <%= ActiveRecord::FixtureSet.identify("system", :uuid) %>
@@ -38,6 +42,7 @@ mike:
role: admin
identity: mike
account: initech_uuid
verified_at: <%= Time.current.to_fs(:db) %>
system_initech:
id: <%= ActiveRecord::FixtureSet.identify("system_initech", :uuid) %>
+2
View File
@@ -44,6 +44,8 @@ class AccountTest < ActiveSupport::TestCase
assert owner.admin?, "owner should also be considered an admin"
assert_predicate account.system_user, :present?
assert owner.verified?, "owner should be verified on account creation"
end
end
+20
View File
@@ -4,4 +4,24 @@ class CommentTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "rich text embed variants are processed immediately on attachment" do
comment = cards(:logo).comments.create!(body: "Check this out")
comment.body.body.attachables # force load
blob = ActiveStorage::Blob.create_and_upload! \
io: File.open(file_fixture("moon.jpg")),
filename: "moon.jpg",
content_type: "image/jpeg"
comment.body.body = ActionText::Content.new(comment.body.body.to_html).append_attachables(blob)
comment.save!
embed = comment.body.embeds.sole
Attachments::VARIANTS.each_key do |variant_name|
variant = embed.variant(variant_name)
assert variant.processed?, "Expected #{variant_name} variant to be processed immediately"
end
end
end
+15
View File
@@ -46,4 +46,19 @@ class IdentityTest < ActiveSupport::TestCase
assert_equal identity.email_address, user.name
end
end
test "destroy deactivates users before nullifying identity" do
identity = identities(:kevin)
user = users(:kevin)
assert_predicate user, :active?
assert_predicate user.accesses, :any?
identity.destroy!
user.reload
assert_nil user.identity_id, "identity should be nullified"
assert_not_predicate user, :active?
assert_empty user.accesses, "user accesses should be removed"
end
end
+124
View File
@@ -0,0 +1,124 @@
require "test_helper"
class Push::SubscriptionTest < ActiveSupport::TestCase
PUBLIC_TEST_IP = "142.250.185.206" # google.com IP
setup do
stub_dns_resolution(PUBLIC_TEST_IP)
end
test "valid subscription with permitted endpoint" do
subscription = Push::Subscription.new(
user: users(:david),
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "test_key",
auth_key: "test_auth"
)
assert subscription.valid?
end
test "rejects endpoint with non-https scheme" do
subscription = Push::Subscription.new(
user: users(:david),
endpoint: "http://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "test_key",
auth_key: "test_auth"
)
assert_not subscription.valid?
assert_includes subscription.errors[:endpoint], "must use HTTPS"
end
test "rejects endpoint with non-permitted host" do
subscription = Push::Subscription.new(
user: users(:david),
endpoint: "https://attacker.example.com/webhook",
p256dh_key: "test_key",
auth_key: "test_auth"
)
assert_not subscription.valid?
assert_includes subscription.errors[:endpoint], "is not a permitted push service"
end
test "rejects endpoint that resolves to private IP" do
stub_dns_resolution("192.168.1.1")
subscription = Push::Subscription.new(
user: users(:david),
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "test_key",
auth_key: "test_auth"
)
assert_not subscription.valid?
assert_includes subscription.errors[:endpoint], "resolves to a private or invalid IP address"
end
test "rejects endpoint that resolves to loopback IP" do
stub_dns_resolution("127.0.0.1")
subscription = Push::Subscription.new(
user: users(:david),
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "test_key",
auth_key: "test_auth"
)
assert_not subscription.valid?
assert_includes subscription.errors[:endpoint], "resolves to a private or invalid IP address"
end
test "rejects endpoint that resolves to link-local IP (AWS IMDS)" do
stub_dns_resolution("169.254.169.254")
subscription = Push::Subscription.new(
user: users(:david),
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "test_key",
auth_key: "test_auth"
)
assert_not subscription.valid?
assert_includes subscription.errors[:endpoint], "resolves to a private or invalid IP address"
end
test "resolved_endpoint_ip returns pinned public IP" do
subscription = Push::Subscription.new(
user: users(:david),
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
p256dh_key: "test_key",
auth_key: "test_auth"
)
assert_equal PUBLIC_TEST_IP, subscription.resolved_endpoint_ip
end
test "accepts all permitted push service domains" do
permitted_endpoints = [
"https://fcm.googleapis.com/fcm/send/token123",
"https://updates.push.services.mozilla.com/wpush/v2/token123",
"https://web.push.apple.com/QaBC123",
"https://wns2-db5p.notify.windows.com/w/?token=abc123"
]
permitted_endpoints.each do |endpoint|
subscription = Push::Subscription.new(
user: users(:david),
endpoint: endpoint,
p256dh_key: "test_key",
auth_key: "test_auth"
)
assert subscription.valid?, "Expected #{endpoint} to be valid, got errors: #{subscription.errors.full_messages}"
end
end
private
def stub_dns_resolution(*ips)
dns_mock = mock("dns")
dns_mock.stubs(:each_address).multiple_yields(*ips)
Resolv::DNS.stubs(:open).yields(dns_mock)
end
end
+26 -2
View File
@@ -1,15 +1,26 @@
require "test_helper"
class SignupTest < ActiveSupport::TestCase
test "validates email format for identity creation" do
signup = Signup.new(email_address: "not-an-email")
assert_not signup.valid?(:identity_creation)
assert signup.errors[:email_address].any?
signup = Signup.new(email_address: "valid@example.com")
assert signup.valid?(:identity_creation)
end
test "#create_identity" do
signup = Signup.new(email_address: "brian@example.com")
magic_link = nil
assert_difference -> { Identity.count }, 1 do
assert_difference -> { MagicLink.count }, 1 do
assert signup.create_identity
magic_link = signup.create_identity
end
end
assert_kind_of MagicLink, magic_link
assert_empty signup.errors
assert signup.identity
assert signup.identity.persisted?
@@ -18,10 +29,12 @@ class SignupTest < ActiveSupport::TestCase
assert_no_difference -> { Identity.count } do
assert_difference -> { MagicLink.count }, 1 do
assert signup_existing.create_identity, "Should send magic link for existing identity"
magic_link = signup_existing.create_identity
end
end
assert_kind_of MagicLink, magic_link
signup_invalid = Signup.new(email_address: "")
assert_raises do
signup_invalid.create_identity
@@ -48,4 +61,15 @@ class SignupTest < ActiveSupport::TestCase
assert_not_empty signup_invalid.errors[:full_name]
end
end
test "#complete with invalid data" do
Current.without_account do
signup = Signup.new
assert_not signup.complete
assert signup.errors[:full_name].any?
assert signup.errors[:identity].any?
assert_nil signup.account
assert_nil signup.user
end
end
end
+60
View File
@@ -0,0 +1,60 @@
require "test_helper"
class SsrfProtectionTest < ActiveSupport::TestCase
test "blocks loopback addresses" do
stub_dns_resolution("127.0.0.1")
assert_nil SsrfProtection.resolve_public_ip("localhost")
end
test "blocks private 10.x.x.x addresses" do
stub_dns_resolution("10.0.0.1")
assert_nil SsrfProtection.resolve_public_ip("internal.example.com")
end
test "blocks private 172.16.x.x addresses" do
stub_dns_resolution("172.16.0.1")
assert_nil SsrfProtection.resolve_public_ip("internal.example.com")
end
test "blocks private 192.168.x.x addresses" do
stub_dns_resolution("192.168.1.1")
assert_nil SsrfProtection.resolve_public_ip("internal.example.com")
end
test "blocks link-local addresses (AWS metadata endpoint)" do
stub_dns_resolution("169.254.169.254")
assert_nil SsrfProtection.resolve_public_ip("metadata.example.com")
end
test "blocks carrier-grade NAT addresses" do
stub_dns_resolution("100.64.0.1")
assert_nil SsrfProtection.resolve_public_ip("cgnat.example.com")
end
test "blocks benchmark testing addresses" do
stub_dns_resolution("198.18.0.1")
assert_nil SsrfProtection.resolve_public_ip("benchmark.example.com")
end
test "blocks broadcast addresses" do
stub_dns_resolution("0.0.0.1")
assert_nil SsrfProtection.resolve_public_ip("broadcast.example.com")
end
test "allows public addresses" do
stub_dns_resolution("93.184.216.34")
assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("example.com")
end
test "returns first public IP when multiple addresses resolve" do
stub_dns_resolution("10.0.0.1", "93.184.216.34", "192.168.1.1")
assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("multi.example.com")
end
private
def stub_dns_resolution(*ips)
dns_mock = mock("dns")
dns_mock.stubs(:each_address).multiple_yields(*ips)
Resolv::DNS.stubs(:open).yields(dns_mock)
end
end
+7 -1
View File
@@ -16,7 +16,7 @@ class User::AvatarTest < ActiveSupport::TestCase
end
test "allows valid image content types" do
users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "test.jpg")
users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "test.jpg", content_type: "image/jpeg")
assert users(:david).valid?
end
@@ -27,4 +27,10 @@ class User::AvatarTest < ActiveSupport::TestCase
assert_not users(:david).valid?
assert_includes users(:david).errors[:avatar], "must be a JPEG, PNG, GIF, or WebP image"
end
test "thumb variant is processed immediately on attachment" do
users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png")
assert users(:david).avatar.variant(:thumb).processed?
end
end
+4
View File
@@ -49,5 +49,9 @@ class User::SettingsTest < ActiveSupport::TestCase
@user.update!(role: :member, active: false)
assert_not @user.settings.bundling_emails?, "Inactive users should not receive bundled emails"
@user.update!(active: true)
@user.update_column(:verified_at, nil)
assert_not @user.settings.bundling_emails?, "Unverified users should not receive bundled emails"
end
end
+32
View File
@@ -48,4 +48,36 @@ class UserTest < ActiveSupport::TestCase
user.update!(name: "Kevin")
assert user.setup?
end
test "verified? returns true when verified_at is present" do
user = users(:david)
user.update_column(:verified_at, Time.current)
assert user.verified?
end
test "verified? returns false when verified_at is nil" do
user = users(:david)
user.update_column(:verified_at, nil)
assert_not user.verified?
end
test "verify sets verified_at when not already verified" do
user = users(:david)
user.update_column(:verified_at, nil)
assert_nil user.verified_at
user.verify
assert_not_nil user.reload.verified_at
end
test "verify does not update verified_at when already verified" do
user = users(:david)
original_time = 1.day.ago
user.update_column(:verified_at, original_time)
user.verify
assert_equal original_time.to_i, user.reload.verified_at.to_i
end
end
+2 -1
View File
@@ -298,11 +298,12 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
# Verify Net::HTTP.new is called with the pinned IP
http_mock = mock("http")
http_mock.stubs(:use_ssl=)
http_mock.stubs(:ipaddr=)
http_mock.stubs(:open_timeout=)
http_mock.stubs(:read_timeout=)
http_mock.stubs(:request).returns(stub(code: "200"))
Net::HTTP.expects(:new).with("example.com", 443, ipaddr: PUBLIC_TEST_IP).returns(http_mock)
Net::HTTP.expects(:new).with("example.com", 443).returns(http_mock)
delivery.deliver
+22 -1
View File
@@ -1,6 +1,27 @@
require "application_system_test_case"
class SmokeTest < ApplicationSystemTestCase
test "joining an account" do
account = accounts("37s")
visit join_url(code: account.join_code.code, script_name: account.slug)
fill_in "Email address", with: "newbie@example.com"
click_on "Continue"
assert_selector "h1", text: "Check your email"
identity = Identity.find_by!(email_address: "newbie@example.com")
code = identity.magic_links.active.first.code
fill_in "code", with: code
send_keys :enter
assert_selector "input[id=user_name]"
assert account.users.find_by!(identity:).verified?, "User was not properly verified"
fill_in "Full name", with: "New Bee"
click_on "Continue"
assert_selector "h1", text: "Writebook"
end
test "create a card" do
sign_in_as(users(:david))
@@ -24,7 +45,7 @@ class SmokeTest < ApplicationSystemTestCase
within("form lexxy-editor figure.attachment[data-content-type='image/jpeg']") do
assert_selector "img[src*='/rails/active_storage']"
assert_selector "figcaption input[placeholder='moon.jpg']"
assert_selector "figcaption textarea[placeholder='moon.jpg']"
end
click_on "Post"
+12 -1
View File
@@ -44,7 +44,7 @@ module ActiveSupport
fixtures :all
include ActiveJob::TestHelper
include ActionTextTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
include ActionTextTestHelper, CachingTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
include Turbo::Broadcastable::TestHelper
setup do
@@ -61,6 +61,17 @@ class ActionDispatch::IntegrationTest
setup do
integration_session.default_url_options[:script_name] = "/#{ActiveRecord::FixtureSet.identify("37signals")}"
end
private
def without_action_dispatch_exception_handling
original = Rails.application.config.action_dispatch.show_exceptions
Rails.application.config.action_dispatch.show_exceptions = :none
Rails.application.instance_variable_set(:@app_env_config, nil) # Clear memoized env_config
yield
ensure
Rails.application.config.action_dispatch.show_exceptions = original
Rails.application.instance_variable_set(:@app_env_config, nil) # Reset env_config
end
end
class ActionDispatch::SystemTestCase
+14
View File
@@ -0,0 +1,14 @@
module CachingTestHelper
def with_actionview_partial_caching
was_cache = ActionView::PartialRenderer.collection_cache
was_perform_caching = ApplicationController.perform_caching
begin
ActionView::PartialRenderer.collection_cache = ActiveSupport::Cache.lookup_store(:memory_store)
ApplicationController.perform_caching = true
yield
ensure
ActionView::PartialRenderer.collection_cache = was_cache
ApplicationController.perform_caching = was_perform_caching
end
end
end