Merge branch 'main' into theme-switcher-revised
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Checks
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
security:
|
||||
name: Security
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
|
||||
@@ -3,6 +3,9 @@ name: CI (SaaS)
|
||||
on:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test_oss:
|
||||
name: Test (OSS)
|
||||
|
||||
@@ -10,6 +10,9 @@ on:
|
||||
GH_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests (${{ matrix.mode }})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+6
-6
@@ -6,7 +6,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: b22cb0c0cb39b103d816a66d560991acf0a57163
|
||||
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
|
||||
branch: main
|
||||
specs:
|
||||
actioncable (8.2.0.alpha)
|
||||
@@ -216,7 +216,7 @@ GEM
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
json (2.17.1)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.9.0)
|
||||
@@ -237,7 +237,7 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lexxy (0.1.20.beta)
|
||||
lexxy (0.1.23.beta)
|
||||
rails (>= 8.0.2)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
@@ -267,7 +267,7 @@ GEM
|
||||
railties (>= 7.1)
|
||||
stimulus-rails
|
||||
turbo-rails
|
||||
mittens (0.3.0)
|
||||
mittens (0.3.1)
|
||||
mocha (2.8.2)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
@@ -351,7 +351,7 @@ GEM
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (6.16.0)
|
||||
rdoc (6.16.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -443,7 +443,7 @@ GEM
|
||||
ostruct
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.8)
|
||||
stringio (3.1.9)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
|
||||
+5
-5
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -39,4 +39,8 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,61 +1,39 @@
|
||||
@layer components {
|
||||
/* Editor
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
lexxy-editor {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
figure.node--selected {
|
||||
&:not(:has(img)) {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
&:has(img) {
|
||||
img {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Lexical still uses the `lexxy-editor--empty` class if you have a list
|
||||
started with no other characters. Here, we won't show the placeholder if
|
||||
you've clicked the List button in the toolbar. */
|
||||
&.lexxy-editor--empty {
|
||||
.lexxy-editor__content:not(:has(ul, ol))::before {
|
||||
content: attr(placeholder);
|
||||
color: currentColor;
|
||||
cursor: text;
|
||||
opacity: 0.66;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
white-space: pre-line;
|
||||
&:not(:has(img)) {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-dialog-actions {
|
||||
display: flex;
|
||||
font-size: var(--text-x-small);
|
||||
flex: 1 1 0%;
|
||||
gap: var(--inline-space-half);
|
||||
margin-block-start: var(--block-space-half);
|
||||
|
||||
.btn {
|
||||
--radius: 0.3em;
|
||||
|
||||
inline-size: 100%;
|
||||
justify-content: center;
|
||||
|
||||
&:is([type="submit"]) {
|
||||
--btn-background: var(--card-color, var(--color-link));
|
||||
--btn-color: var(--color-ink-inverted);
|
||||
--focus-ring-color: var(--card-color, var(--color-link));
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
inline-size: 100%;
|
||||
/* Lexical uses the `lexxy-editor--empty` class even if you have a list
|
||||
* started but haven't added other characters. Here, we hide the placeholder
|
||||
* when you click the List button in the toolbar. */
|
||||
.lexxy-editor--empty {
|
||||
.lexxy-editor__content:not(:has(ul, ol))::before {
|
||||
content: attr(placeholder);
|
||||
color: currentColor;
|
||||
cursor: text;
|
||||
opacity: 0.66;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,14 +42,9 @@
|
||||
min-block-size: calc(7lh + var(--block-space));
|
||||
outline: 0;
|
||||
|
||||
* {
|
||||
&:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
/* Allow color highlights to show through a bit */
|
||||
::selection {
|
||||
background: oklch(var(--lch-blue-light) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +54,40 @@
|
||||
outline: 2px dashed var(--color-selected-dark);
|
||||
}
|
||||
|
||||
.lexxy-code-language-picker {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: var(--color-canvas);
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
|
||||
background-position: center right 0.9em;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.5em;
|
||||
border: 1px solid var(--color-ink-light);
|
||||
border-radius: 99rem;
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-base);
|
||||
font-size: var(--text-x-small);
|
||||
font-weight: 500;
|
||||
inset-inline-end: 0;
|
||||
line-height: 1.15lh;
|
||||
margin: 0.75ch 0.75ch 0 0;
|
||||
padding-inline: 1.5ch 1.8em;
|
||||
text-align: start;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
lexxy-toolbar {
|
||||
--lexxy-toolbar-icon-size: 1em;
|
||||
|
||||
@@ -96,21 +103,6 @@
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
z-index: 1;
|
||||
|
||||
dialog {
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-ink);
|
||||
padding: var(--block-space) calc(var(--inline-space) * 1.5);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
.input[type="url"] {
|
||||
min-inline-size: 30ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-button {
|
||||
@@ -157,55 +149,130 @@
|
||||
border-radius: 0.5ch;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
inset-inline-end: 0;
|
||||
padding: 4px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:where(.lexxy-editor__toolbar-spacer) {
|
||||
.lexxy-editor__toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Based on .input, .input--select */
|
||||
.lexxy-code-language-picker {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
/* Dropdowns
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-content {
|
||||
--lexxy-dropdown-padding: 0.75rem;
|
||||
--lexxy-dropdown-btn-size: 2rem;
|
||||
|
||||
background-color: var(--color-canvas);
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
|
||||
background-position: center right 0.9em;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.5em;
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.3em;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-base);
|
||||
font-size: var(--text-x-small);
|
||||
font-weight: 500;
|
||||
inset-inline-end: 0;
|
||||
line-height: inherit;
|
||||
margin: 0.3em 0.3em 0 0;
|
||||
padding: 0.5em 1.8em 0.5em 1.2em;
|
||||
text-align: start;
|
||||
font-size: var(--text-small);
|
||||
padding: var(--lexxy-dropdown-padding);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
html[data-theme="dark"] & {
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme]) & {
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
option {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
button {
|
||||
block-size: var(--lexxy-dropdown-btn-size);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt
|
||||
.lexxy-editor__toolbar-dropdown-actions {
|
||||
display: flex;
|
||||
font-size: var(--text-x-small);
|
||||
flex: 1 1 0%;
|
||||
gap: var(--lexxy-dropdown-padding);
|
||||
margin-block-start: var(--lexxy-dropdown-padding);
|
||||
|
||||
.btn {
|
||||
--radius: 99rem;
|
||||
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.btn[type="submit"] {
|
||||
--btn-background: var(--card-color, var(--color-link));
|
||||
--btn-color: var(--color-ink-inverted);
|
||||
--focus-ring-color: var(--card-color, var(--color-link));
|
||||
}
|
||||
|
||||
span {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
lexxy-link-dropdown {
|
||||
.input {
|
||||
min-inline-size: 30ch;
|
||||
}
|
||||
}
|
||||
|
||||
lexxy-highlight-dropdown {
|
||||
--gap: 0.5ch;
|
||||
|
||||
[data-button-group] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap);
|
||||
|
||||
+ & {
|
||||
margin-block-start: var(--gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-highlight-button {
|
||||
--outline-color: oklch(var(--lch-ink-darkest) / 0.2);
|
||||
|
||||
appearance: none;
|
||||
background: var(--color-canvas);
|
||||
border: none;
|
||||
border-radius: 0.5ch;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
font-weight: 500;
|
||||
inline-size: var(--lexxy-dropdown-btn-size);
|
||||
place-content: center;
|
||||
position: relative;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "Aa";
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[aria-pressed="true"] {
|
||||
box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color);
|
||||
}
|
||||
|
||||
&[aria-pressed="true"] {
|
||||
--outline-color: var(--color-link);
|
||||
|
||||
&:after {
|
||||
content: "✓";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-reset {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-light);
|
||||
border-radius: 99rem;
|
||||
inline-size: 100%;
|
||||
margin-block-start: var(--lexxy-dropdown-padding);
|
||||
|
||||
&[disabled] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt ,enu (@mentions, etc.)
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.lexxy-prompt-menu {
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
aspect-ratio: 5/3;
|
||||
background-color: var(--color-ink-lightest);
|
||||
border-radius: 0.5em;
|
||||
container-type: inline-size;
|
||||
flex-basis: calc((100% - var(--gap) * 2) / 3);
|
||||
flex-direction: column;
|
||||
font-size: var(--text-small);
|
||||
@@ -218,6 +219,7 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
font-size: var(--text-x-small);
|
||||
font-size: clamp(var(--text-xx-small), 3.15cqi, var(--text-small));
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
inset: 0 auto auto 50%;
|
||||
max-block-size: 70dvh;
|
||||
max-inline-size: min(55ch, calc(100vw - (var(--panel-padding) * 2)));
|
||||
min-inline-size: min(25ch, calc(100vw - (var(--panel-padding) * 2)));
|
||||
max-inline-size: min(55ch, calc(100vw - (var(--panel-padding))));
|
||||
min-inline-size: min(25ch, calc(100vw - (var(--panel-padding))));
|
||||
overflow: auto;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
|
||||
@@ -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,7 +2,7 @@ class Boards::Columns::NotNowsController < ApplicationController
|
||||
include BoardScoped
|
||||
|
||||
def show
|
||||
set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first.preloaded
|
||||
set_page_and_extract_portion_from @board.cards.postponed.latest.preloaded
|
||||
fresh_when etag: @page.records
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Public::Boards::Columns::NotNowsController < Public::BaseController
|
||||
def show
|
||||
set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first
|
||||
set_page_and_extract_portion_from @board.cards.postponed.latest
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
module AccessesHelper
|
||||
MAX_DISPLAYED_WATCHERS = 8
|
||||
|
||||
def access_menu_tag(board, **options, &)
|
||||
tag.menu class: [ options[:class], { "toggler--toggled": board.all_access? } ], data: {
|
||||
controller: "filter toggle-class navigable-list",
|
||||
@@ -14,7 +12,7 @@ module AccessesHelper
|
||||
render partial: "boards/access_toggle",
|
||||
collection: users, as: :user,
|
||||
locals: { selected: selected, disabled: disabled },
|
||||
cached: ->(user) { [ user, selected ] }
|
||||
cached: ->(user) { [ user, selected, disabled ] }
|
||||
end
|
||||
|
||||
def access_involvement_advance_button(board, user, show_watchers: true, icon_only: false)
|
||||
@@ -27,45 +25,30 @@ module AccessesHelper
|
||||
end
|
||||
|
||||
def board_watchers_list(board)
|
||||
watchers = board.watchers.with_avatars
|
||||
watchers = board.watchers.with_avatars.load
|
||||
|
||||
displayed_watchers = watchers.limit(MAX_DISPLAYED_WATCHERS)
|
||||
overflow_count = watchers.count - MAX_DISPLAYED_WATCHERS
|
||||
displayed_watchers = watchers.first(8)
|
||||
overflow_count = watchers.size - 8
|
||||
|
||||
safe_join([
|
||||
tag.strong(displayed_watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase"),
|
||||
tag.div(class: "board-tools__watching") do
|
||||
safe_join([
|
||||
safe_join(displayed_watchers.map { |watcher| avatar_tag(watcher) }),
|
||||
(tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do
|
||||
concat tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" })
|
||||
concat(tag.dialog(class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" }) do
|
||||
safe_join(watchers.map { |watcher| avatar_tag(watcher) })
|
||||
end)
|
||||
end if overflow_count > 0)
|
||||
].compact)
|
||||
end
|
||||
])
|
||||
tag.strong(watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase") +
|
||||
tag.div(avatar_tags(displayed_watchers), class: "board-tools__watching") do
|
||||
tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do
|
||||
tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" }) +
|
||||
tag.dialog(avatar_tags(watchers), class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" })
|
||||
end if overflow_count > 0
|
||||
end
|
||||
end
|
||||
|
||||
def involvement_button(board, access, show_watchers, icon_only)
|
||||
involvement_label_id = dom_id(board, :involvement_label)
|
||||
|
||||
label_text = access.access_only? ? "Watch this" : "Stop watching"
|
||||
button_to(
|
||||
board_involvement_path(board),
|
||||
method: :put,
|
||||
aria: { labelledby: involvement_label_id },
|
||||
title: involvement_access_label(access),
|
||||
class: class_names("btn", { "btn--reversed": access.watching? && icon_only }),
|
||||
params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }
|
||||
) do
|
||||
safe_join([
|
||||
icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}"),
|
||||
tag.span(
|
||||
class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only),
|
||||
id: involvement_label_id
|
||||
)
|
||||
])
|
||||
board_involvement_path(board), method: :put,
|
||||
params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only },
|
||||
aria: { labelledby: dom_id(board, :involvement_label) },
|
||||
title: (label_text if icon_only),
|
||||
class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do
|
||||
icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") +
|
||||
tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,12 +57,4 @@ module AccessesHelper
|
||||
order = %w[ watching access_only ]
|
||||
order[(order.index(involvement.to_s) + 1) % order.size]
|
||||
end
|
||||
|
||||
def involvement_access_label(access)
|
||||
if access.access_only?
|
||||
"Watch this"
|
||||
else
|
||||
"Stop watching"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,10 @@ module AvatarsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def avatar_tags(users, **options)
|
||||
users.collect { avatar_tag(it, **options) }.join.html_safe
|
||||
end
|
||||
|
||||
def mail_avatar_tag(user, size: 48, **options)
|
||||
if user.avatar.attached?
|
||||
image_tag user_avatar_url(user), alt: user.name, class: "avatar", size: size, **options
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module CardsHelper
|
||||
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
|
||||
def card_article_tag(card, id: dom_id(card, :article), data: {}, **options, &block)
|
||||
classes = [
|
||||
options.delete(:class),
|
||||
("golden-effect" if card.golden?),
|
||||
@@ -7,22 +7,17 @@ module CardsHelper
|
||||
("card--active" if card.active?)
|
||||
].compact.join(" ")
|
||||
|
||||
data[:drag_and_drop_top] = true if card.golden? && !card.closed? && !card.postponed?
|
||||
|
||||
tag.article \
|
||||
id: id,
|
||||
style: "--card-color: #{card.color}; view-transition-name: #{id}",
|
||||
class: classes,
|
||||
data: data,
|
||||
**options,
|
||||
&block
|
||||
end
|
||||
|
||||
def button_to_delete_card(card)
|
||||
button_to card_path(card),
|
||||
method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_frame: "_top", turbo_confirm: "Are you sure you want to permanently delete this card?" } do
|
||||
concat(icon_tag("trash"))
|
||||
concat(tag.span("Delete this card"))
|
||||
end
|
||||
end
|
||||
|
||||
def card_title_tag(card)
|
||||
title = [
|
||||
card.title,
|
||||
|
||||
@@ -10,14 +10,16 @@ module ColumnsHelper
|
||||
data: { turbo_frame: "_top" }
|
||||
end
|
||||
|
||||
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {}, **properties, &block)
|
||||
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block)
|
||||
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
|
||||
|
||||
data = {
|
||||
drag_and_drop_target: "container",
|
||||
navigable_list_target: "item",
|
||||
column_name: name,
|
||||
drag_and_drop_url: drop_url
|
||||
drag_and_drop_url: drop_url,
|
||||
drag_and_drop_css_variable_name: "--card-color",
|
||||
drag_and_drop_css_variable_value: card_color
|
||||
}.merge(data)
|
||||
|
||||
data[:action] = token_list(
|
||||
@@ -28,7 +30,8 @@ module ColumnsHelper
|
||||
|
||||
tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do
|
||||
tag.div(class: "cards__transition-container", data: {
|
||||
controller: "navigable-list",
|
||||
controller: "navigable-list css-variable-counter",
|
||||
css_variable_counter_property_name_value: "--card-count",
|
||||
navigable_list_supports_horizontal_navigation_value: "false",
|
||||
navigable_list_prevent_handled_keys_value: "true",
|
||||
navigable_list_auto_select_value: "false",
|
||||
|
||||
@@ -9,7 +9,7 @@ module HtmlHelper
|
||||
|
||||
private
|
||||
EXCLUDED_ELEMENTS = %w[ a figcaption pre code ]
|
||||
EMAIL_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
|
||||
EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
|
||||
URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https])
|
||||
|
||||
def auto_link(fragment)
|
||||
@@ -48,7 +48,7 @@ module HtmlHelper
|
||||
end
|
||||
|
||||
def auto_link_emails(text)
|
||||
text.gsub!(EMAIL_REGEXP) do |match|
|
||||
text.gsub!(EMAIL_AUTOLINK_REGEXP) do |match|
|
||||
%(<a href="mailto:#{match}">#{match}</a>)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
get #entropyCleanupInDays() {
|
||||
this.entropyCleanupInDays ??= signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt))
|
||||
return this.entropyCleanupInDays
|
||||
return signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt))
|
||||
}
|
||||
|
||||
#showEntropy() {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { debounce } from "helpers/timing_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "item", "counter" ]
|
||||
static values = {
|
||||
propertyName: String,
|
||||
maxValue: { type: Number, default: 20 }
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.#updateCounter = debounce(this.#updateCounter.bind(this), 50)
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.#updateCounter()
|
||||
}
|
||||
|
||||
itemTargetConnected() {
|
||||
this.#updateCounter()
|
||||
}
|
||||
|
||||
itemTargetDisconnected() {
|
||||
this.#updateCounter()
|
||||
}
|
||||
|
||||
#updateCounter = () => {
|
||||
if (!this.hasCounterTarget) return
|
||||
|
||||
const count = Math.min(this.itemTargets.length, this.maxValueValue)
|
||||
this.counterTarget.style.setProperty(this.propertyNameValue, count)
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,14 @@ export default class extends Controller {
|
||||
await nextFrame()
|
||||
this.dragItem = this.#itemContaining(event.target)
|
||||
this.sourceContainer = this.#containerContaining(this.dragItem)
|
||||
this.originalDraggedItemCssVariable = this.#containerCssVariableFor(this.sourceContainer)
|
||||
this.dragItem.classList.add(this.draggedItemClass)
|
||||
}
|
||||
|
||||
dragOver(event) {
|
||||
event.preventDefault()
|
||||
if (!this.dragItem) { return }
|
||||
|
||||
const container = this.#containerContaining(event.target)
|
||||
this.#clearContainerHoverClasses()
|
||||
|
||||
@@ -28,32 +31,39 @@ export default class extends Controller {
|
||||
|
||||
if (container !== this.sourceContainer) {
|
||||
container.classList.add(this.hoverContainerClass)
|
||||
this.#applyContainerCssVariableToDraggedItem(container)
|
||||
} else {
|
||||
this.#restoreOriginalDraggedItemCssVariable()
|
||||
}
|
||||
}
|
||||
|
||||
async drop(event) {
|
||||
const container = this.#containerContaining(event.target)
|
||||
const targetContainer = this.#containerContaining(event.target)
|
||||
|
||||
if (!container || container === this.sourceContainer) { return }
|
||||
if (!targetContainer || targetContainer === this.sourceContainer) { return }
|
||||
|
||||
this.wasDropped = true
|
||||
this.#increaseCounter(targetContainer)
|
||||
this.#decreaseCounter(this.sourceContainer)
|
||||
|
||||
const sourceContainer = this.sourceContainer
|
||||
await this.#submitDropRequest(this.dragItem, container)
|
||||
this.#reloadSourceFrame(sourceContainer);
|
||||
this.#insertDraggedItem(targetContainer, this.dragItem)
|
||||
await this.#submitDropRequest(this.dragItem, targetContainer)
|
||||
this.#reloadSourceFrame(sourceContainer)
|
||||
}
|
||||
|
||||
dragEnd() {
|
||||
this.dragItem.classList.remove(this.draggedItemClass)
|
||||
this.#clearContainerHoverClasses()
|
||||
|
||||
if (this.wasDropped) {
|
||||
this.dragItem.remove()
|
||||
if (!this.wasDropped) {
|
||||
this.#restoreOriginalDraggedItemCssVariable()
|
||||
}
|
||||
|
||||
this.sourceContainer = null
|
||||
this.dragItem = null
|
||||
this.wasDropped = false
|
||||
this.originalDraggedItemCssVariable = null
|
||||
}
|
||||
|
||||
#itemContaining(element) {
|
||||
@@ -68,6 +78,63 @@ export default class extends Controller {
|
||||
this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass))
|
||||
}
|
||||
|
||||
#applyContainerCssVariableToDraggedItem(container) {
|
||||
const cssVariable = this.#containerCssVariableFor(container)
|
||||
if (cssVariable) {
|
||||
this.dragItem.style.setProperty(cssVariable.name, cssVariable.value)
|
||||
}
|
||||
}
|
||||
|
||||
#restoreOriginalDraggedItemCssVariable() {
|
||||
if (this.originalDraggedItemCssVariable) {
|
||||
const { name, value } = this.originalDraggedItemCssVariable
|
||||
this.dragItem.style.setProperty(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
#containerCssVariableFor(container) {
|
||||
const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = container.dataset
|
||||
if (dragAndDropCssVariableName && dragAndDropCssVariableValue) {
|
||||
return { name: dragAndDropCssVariableName, value: dragAndDropCssVariableValue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
#increaseCounter(container) {
|
||||
this.#modifyCounter(container, count => count + 1)
|
||||
}
|
||||
|
||||
#decreaseCounter(container) {
|
||||
this.#modifyCounter(container, count => Math.max(0, count - 1))
|
||||
}
|
||||
|
||||
#modifyCounter(container, fn) {
|
||||
const counterElement = container.querySelector("[data-drag-and-drop-counter]")
|
||||
if (counterElement) {
|
||||
const currentValue = counterElement.textContent.trim()
|
||||
|
||||
if (!/^\d+$/.test(currentValue)) return
|
||||
|
||||
counterElement.textContent = fn(parseInt(currentValue))
|
||||
}
|
||||
}
|
||||
|
||||
#insertDraggedItem(container, item) {
|
||||
const itemContainer = container.querySelector("[data-drag-drop-item-container]")
|
||||
const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]")
|
||||
const firstTopItem = topItems[0]
|
||||
const lastTopItem = topItems[topItems.length - 1]
|
||||
|
||||
const isTopItem = item.hasAttribute("data-drag-and-drop-top")
|
||||
const referenceItem = isTopItem ? firstTopItem : lastTopItem
|
||||
|
||||
if (referenceItem) {
|
||||
referenceItem[isTopItem ? "before" : "after"](item)
|
||||
} else {
|
||||
itemContainer.prepend(item)
|
||||
}
|
||||
}
|
||||
|
||||
async #submitDropRequest(item, container) {
|
||||
const body = new FormData()
|
||||
const id = item.dataset.id
|
||||
@@ -80,18 +147,4 @@ export default class extends Controller {
|
||||
const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]")
|
||||
if (frame) frame.reload()
|
||||
}
|
||||
|
||||
#decreaseCounter(sourceContainer) {
|
||||
const counterElement = sourceContainer.querySelector("[data-drag-and-drop-counter]")
|
||||
if (counterElement) {
|
||||
const currentValue = counterElement.textContent.trim()
|
||||
|
||||
if (!/^\d+$/.test(currentValue)) return
|
||||
|
||||
const count = parseInt(currentValue)
|
||||
if (count > 0) {
|
||||
counterElement.textContent = count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,21 @@ export default class extends Controller {
|
||||
debounceTimeout: { type: Number, default: 300 }
|
||||
}
|
||||
|
||||
#isComposing = false
|
||||
|
||||
initialize() {
|
||||
this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), this.debounceTimeoutValue)
|
||||
}
|
||||
|
||||
// IME Composition tracking
|
||||
compositionStart() {
|
||||
this.#isComposing = true
|
||||
}
|
||||
|
||||
compositionEnd() {
|
||||
this.#isComposing = false
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.element.requestSubmit()
|
||||
}
|
||||
@@ -21,12 +32,23 @@ export default class extends Controller {
|
||||
|
||||
if (input) {
|
||||
const value = (input.value || "").trim()
|
||||
if (value.length === 0) {
|
||||
const isEmpty = value.length === 0
|
||||
|
||||
if (isEmpty) {
|
||||
event.preventDefault()
|
||||
input.setCustomValidity(input.dataset.validationMessage || "Please fill out this field")
|
||||
input.reportValidity()
|
||||
input.addEventListener("input", () => input.setCustomValidity(""), { once: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preventComposingSubmit(event) {
|
||||
if (this.#isComposing) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
debouncedSubmit(event) {
|
||||
this.submit(event)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,5 +3,16 @@ module Card::Broadcastable
|
||||
|
||||
included do
|
||||
broadcasts_refreshes
|
||||
|
||||
before_update :remember_if_preview_changed
|
||||
end
|
||||
|
||||
private
|
||||
def remember_if_preview_changed
|
||||
@preview_changed ||= title_changed? || column_id_changed? || board_id_changed?
|
||||
end
|
||||
|
||||
def preview_changed?
|
||||
@preview_changed
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,6 @@ module Card::Eventable
|
||||
def touch_last_active_at
|
||||
# Not using touch so that we can detect attribute change on callbacks
|
||||
update!(last_active_at: Time.current)
|
||||
broadcast_activity
|
||||
end
|
||||
|
||||
private
|
||||
@@ -36,8 +35,4 @@ module Card::Eventable
|
||||
def create_system_comment_for(event)
|
||||
SystemCommenter.new(self, event).comment
|
||||
end
|
||||
|
||||
def broadcast_activity
|
||||
broadcast_render_later_to self, :activity, partial: "card/display/refresh_activity", locals: { card: self }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,8 @@ module Card::Pinnable
|
||||
|
||||
included do
|
||||
has_many :pins, dependent: :destroy
|
||||
|
||||
after_update_commit :broadcast_pin_updates, if: :preview_changed?
|
||||
end
|
||||
|
||||
def pinned_by?(user)
|
||||
@@ -20,4 +22,11 @@ module Card::Pinnable
|
||||
def unpin_by(user)
|
||||
pins.find_by(user: user).tap { it.destroy }
|
||||
end
|
||||
|
||||
private
|
||||
def broadcast_pin_updates
|
||||
pins.find_each do |pin|
|
||||
pin.broadcast_replace_later_to [ pin.user, :pins_tray ], partial: "my/pins/pin"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,8 +8,9 @@ class Identity < ApplicationRecord
|
||||
|
||||
has_one_attached :avatar
|
||||
|
||||
before_destroy :deactivate_users
|
||||
before_destroy :deactivate_users, prepend: true
|
||||
|
||||
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
|
||||
|
||||
def send_magic_link(**attributes)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="cards__list" data-drag-drop-item-container>
|
||||
<div class="blank-slate blank-slate--drag card card--hide-unless-empty">
|
||||
<p>Drag cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty card--hide-unless-empty">No cards here</div>
|
||||
</div>
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class="cards__list">
|
||||
<div class="cards__list" data-drag-drop-item-container >
|
||||
<%= render "cards/display/previews", cards: cards, draggable: draggable %>
|
||||
</div>
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="cards__list">
|
||||
<div class="blank-slate blank-slate--drag card">
|
||||
<p>Drag cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty">No cards here</div>
|
||||
</div>
|
||||
<%= render "boards/columns/empty_placeholder" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="cards__list">
|
||||
<div class="blank-slate blank-slate--drag card">
|
||||
<p>Drag cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty">No cards here</div>
|
||||
</div>
|
||||
<%= render "boards/columns/empty_placeholder" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="cards__list">
|
||||
<div class="blank-slate blank-slate--drag card">
|
||||
<p>Drag cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty">No cards here</div>
|
||||
</div>
|
||||
<%= render "boards/columns/empty_placeholder" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="blank-slate blank-slate--empty">No cards here</div>
|
||||
<%= render "boards/columns/empty_placeholder" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">·</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 %>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<% @page_title = "Create a new board" %>
|
||||
|
||||
<div class="panel panel--centered">
|
||||
<%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form" } do |form| %>
|
||||
<%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>
|
||||
<h1 class="txt-x-large margin-none font-weight-black"><%= @page_title %></h1>
|
||||
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off",placeholder: "Name it…", data: { action: "keydown.esc@document->form#cancel" } %>
|
||||
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can‘t be blank" } %>
|
||||
|
||||
<button type="submit" class="btn btn--link center">
|
||||
<span>Create board</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<%= column_tag id: dom_id(column), name: column.name, drop_url: columns_card_drops_column_path("__id__", column_id: column.id), class: "cards--doing", style: "--card-color: #{column.color};",
|
||||
<%= column_tag id: dom_id(column), name: column.name, drop_url: columns_card_drops_column_path("__id__", column_id: column.id), card_color: column.color.to_s,
|
||||
class: "cards--doing", style: "--card-color: #{column.color};",
|
||||
data: {
|
||||
drag_and_strum_target: "container",
|
||||
collapsible_columns_target: "column",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-action="click->collapsible-columns#toggle click->navigable-list#selectCurrentOrReset"
|
||||
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-css-variable-counter-target="counter" data-action="click->collapsible-columns#toggle click->navigable-list#selectCurrentOrReset"
|
||||
style="--card-count: <%= [ count, 20 ].min %>" aria-controls="<%= column_id %>" aria-expanded="false">
|
||||
<span class="cards__expander-count" data-drag-and-drop-counter="true"><%= count > 99 ? "99+" : count %></span>
|
||||
<h2 class="cards__expander-title" data-collapsible-columns-target="title">
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
<%= with_automatic_pagination "the-stream", @page do %>
|
||||
<%= render "boards/columns/list", cards: page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="cards__list" data-drag-drop-item-container>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<%= turbo_stream.remove dom_id(card, "bubble") %>
|
||||
<%= turbo_stream.replace dom_id(card, :card_closure_toggle) do %>
|
||||
<%= render "cards/container/closure", card: card %>
|
||||
<% end %>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% draggable = local_assigns.fetch(:draggable, false) && card.published? %>
|
||||
|
||||
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item" }, tabindex: 0 do %>
|
||||
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item", css_variable_counter_target: "item" }, tabindex: 0 do %>
|
||||
<div class="flex flex-column flex-item-grow max-inline-size">
|
||||
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
|
||||
<span class="for-screen-reader"><%= card.title %></span>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class: "bubble",
|
||||
data: {
|
||||
controller: "bubble",
|
||||
action: "turbo:morph-element->bubble#update",
|
||||
action: "turbo:morph-element->bubble#update:self",
|
||||
bubble_entropy_value: entropy_bubble_options_for(card).to_json,
|
||||
bubble_stalled_value: stalled_bubble_options_for(card)&.to_json
|
||||
} do %>
|
||||
|
||||
@@ -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,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,5 +1,10 @@
|
||||
<%= turbo_frame_tag :cards_container do %>
|
||||
<div class="card-columns hide-scrollbar" data-controller="collapsible-columns" data-collapsible-columns-board-value="<%= board.id %>" data-collapsible-columns-collapsed-class="is-collapsed" data-collapsible-columns-no-transitions-class="no-transitions">
|
||||
<div class="card-columns hide-scrollbar"
|
||||
data-controller="collapsible-columns"
|
||||
data-collapsible-columns-board-value="<%= board.id %>"
|
||||
data-collapsible-columns-collapsed-class="is-collapsed"
|
||||
data-collapsible-columns-no-transitions-class="no-transitions"
|
||||
data-collapsible-columns-title-not-visible-class="is-off-screen">
|
||||
|
||||
<div class="card-columns__left">
|
||||
<%= render "public/boards/show/not_now", board: board %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -49,4 +54,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 %>
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
+27
-86
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user