Merge branch 'main' into mobile-columns-pt-iii

* main: (318 commits)
  Document the new sign in method
  Replace handle_ naming
  Use same constant for fake magic links
  Replace FakeMagicLink with a temporary object
  Tidy up session_token
  Clean up interfaces
  Split tests by controller or responsibility
  Simplify auth logic
  Fix due to unit test when creating with invalid emails
  Restore sessions_controller test on creating invalid email address
  Move magic link api tests to their own files
  Rename test to clarify what they're about
  Cleanup session creation
  Update to always return a pending auth token for JSON responses.
  Update API test for cross code
  Change test expectation on single tenant mode account creation
  Add unit tests for the new endpoints
  Pass a server token when creating a magic link via API
  Simplify code a bit
  Simplify session create logic for both html and json
  ...
This commit is contained in:
Andy Smith
2025-12-19 13:13:15 -06:00
268 changed files with 5452 additions and 749 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ registries:
type: git
url: https://github.com
username: x-access-token
password: ${{secrets.GH_TOKEN}}
password: ${{secrets.FIZZY_GH_TOKEN}}
updates:
- package-ecosystem: bundler
+1 -1
View File
@@ -18,4 +18,4 @@ jobs:
with:
saas: true
secrets:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }}
+2 -2
View File
@@ -7,7 +7,7 @@ on:
type: boolean
required: true
secrets:
GH_TOKEN:
FIZZY_GH_TOKEN:
required: false
permissions:
@@ -50,7 +50,7 @@ jobs:
MYSQL_USER: root
FIZZY_DB_HOST: 127.0.0.1
FIZZY_DB_PORT: 3306
BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }}
BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }}
steps:
- name: Install system packages
+8
View File
@@ -9,3 +9,11 @@ paths = [
'''docs/''',
'''test/''',
]
[[rules]]
id = "basecamp-integration-url"
description = "Basecamp Integration URL"
regex = '''https://[^\s]*?([0-9a-fA-F]{16,})'''
[rules.allowlist]
regexTarget = "match"
regexes = ['''github\.com''']
+2
View File
@@ -11,3 +11,5 @@ AllCops:
Exclude:
- 'db/migrate/**/*'
- 'db/schema*.rb'
- 'saas/db/migrate/**/*'
- 'saas/db/saas_schema.rb'
+17 -11
View File
@@ -1,8 +1,8 @@
GIT
remote: https://github.com/basecamp/lexxy
revision: d292280b6d2c5d8a924327adf8bb9f0b25953539
revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8
specs:
lexxy (0.1.23.beta)
lexxy (0.1.24.beta)
rails (>= 8.0.2)
GIT
@@ -122,8 +122,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1187.0)
aws-sdk-core (3.239.1)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -134,7 +134,7 @@ GEM
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.205.0)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -144,7 +144,7 @@ GEM
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
benchmark (0.5.0)
bigdecimal (3.3.1)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.19.0)
msgpack (~> 1.2)
@@ -190,6 +190,7 @@ GEM
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
fugit (1.12.1)
@@ -267,7 +268,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)
@@ -298,6 +299,8 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
@@ -422,6 +425,7 @@ GEM
sqlite3 (2.8.0-arm-linux-gnu)
sqlite3 (2.8.0-arm-linux-musl)
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
@@ -435,10 +439,11 @@ GEM
railties (>= 6.0.0)
stringio (3.1.9)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-linux)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-darwin)
thruster (0.1.17-x86_64-linux)
timeout (0.4.4)
trilogy (2.9.0)
tsort (0.2.0)
@@ -481,6 +486,7 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin-25
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
+2 -2
View File
@@ -3,10 +3,10 @@ eval_gemfile "Gemfile"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
# SaaS-only functionality
gem "activeresource", require: "active_resource"
gem "stripe", "~> 18.0"
gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", bc: "fizzy-saas"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984"
+35 -34
View File
@@ -19,38 +19,16 @@ GIT
rails (>= 7.0)
rainbow
GIT
remote: https://github.com/basecamp/fizzy-saas
revision: 9c356dfeedbf5309f1a36ee3371f49604c9be188
specs:
fizzy-saas (0.1.0)
audits1984
console1984
prometheus-client-mmap (~> 1.4.0)
queenbee
rails (>= 8.1.0.beta1)
rails_structured_logging
sentry-rails
sentry-ruby
yabeda
yabeda-actioncable
yabeda-activejob
yabeda-gc
yabeda-http_requests
yabeda-prometheus-mmap
yabeda-puma-plugin
yabeda-rails (>= 0.10)
GIT
remote: https://github.com/basecamp/lexxy
revision: d292280b6d2c5d8a924327adf8bb9f0b25953539
revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8
specs:
lexxy (0.1.23.beta)
lexxy (0.1.24.beta)
rails (>= 8.0.2)
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2
revision: 14312a940471e20617b38cdec7c092a01567d18b
specs:
queenbee (3.2.0)
activeresource
@@ -181,6 +159,27 @@ GIT
tsort (>= 0.2)
zeitwerk (~> 2.6)
PATH
remote: saas
specs:
fizzy-saas (0.1.0)
audits1984
console1984
prometheus-client-mmap (~> 1.4.0)
queenbee
rails (>= 8.1.0.beta1)
rails_structured_logging
sentry-rails
sentry-ruby
yabeda
yabeda-actioncable
yabeda-activejob
yabeda-gc
yabeda-http_requests
yabeda-prometheus-mmap
yabeda-puma-plugin
yabeda-rails (>= 0.10)
GEM
remote: https://rubygems.org/
specs:
@@ -201,8 +200,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1187.0)
aws-sdk-core (3.239.1)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -213,7 +212,7 @@ GEM
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.205.0)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -223,7 +222,7 @@ GEM
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
benchmark (0.5.0)
bigdecimal (3.3.1)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.19.0)
msgpack (~> 1.2)
@@ -347,7 +346,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)
@@ -558,11 +557,12 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.9)
stripe (18.0.1)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-linux)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-linux)
timeout (0.4.4)
trilogy (2.9.0)
tsort (0.2.0)
@@ -684,6 +684,7 @@ DEPENDENCIES
solid_queue (~> 1.2)
sqlite3 (>= 2.0)
stimulus-rails
stripe (~> 18.0)
thruster
trilogy (~> 2.9)
turbo-rails
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3" /></svg>

After

Width:  |  Height:  |  Size: 308 B

-1
View File
@@ -1 +0,0 @@
<svg enable-background="new 0 0 24 24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m20.3 16.9c0 .1-.1.2-.2.2h-16.1c-.1 0-.2-.1-.2-.2v-9.8c-.1-.1 0-.2.2-.2h5.8c0-.1 0-.2 0-.3 0-.6.1-1.1.2-1.7h-6.3c-1.1 0-2 .9-2 2v11.7c0 1.1.9 2 2 2h7.1c.1 0 .2.1.2.2v1c0 .1-.1.2-.2.2h-4.9c-.5 0-1 .4-1 1 0 .5.4 1 1 1h12.2c.5 0 1-.4 1-1 0-.5-.4-1-1-1h-4.9c-.1 0-.2-.1-.2-.2v-1c0-.1.1-.2.2-.2h7.1c1.1 0 2-.9 2-2v-5.8c-.6.5-1.2.8-2 1.1z"/><path d="m17.5.1c-3 0-5.6 2.1-6.3 4.9-.1.4-.2 1-.2 1.6v.3c.2 3.4 3 6.2 6.5 6.2 1 0 2-.2 2.8-.6.7-.4 1.4-.8 2-1.4 1-1.3 1.7-2.8 1.7-4.5 0-3.6-2.9-6.5-6.5-6.5zm2.4 7.3-2 2.5c-.2.2-.5.3-.7.1 0 0-.1 0-.1-.1l-2-2.5c-.2-.2-.1-.5.1-.7.1-.1.2-.1.3-.1h.8c.1 0 .2-.1.2-.2v-2.8c0-.6.4-1 1-1s1 .4 1 1v2.8c0 .1.1.2.2.2h.8c.3 0 .5.2.5.5 0 .1 0 .2-.1.3z"/></svg>

Before

Width:  |  Height:  |  Size: 785 B

+1 -1
View File
@@ -1,4 +1,4 @@
@layer reset, base, components, modules, utilities;
@layer reset, base, components, modules, utilities, native, platform;
:root {
/* Spacing */
+7
View File
@@ -0,0 +1,7 @@
@layer platform {
[data-platform~=android] {
.hide-on-android {
display: none;
}
}
}
+9
View File
@@ -71,6 +71,15 @@
33% { background-color: var(--color-border-darker); scale: 1; }
}
@keyframes wiggle {
0% { transform: rotate(0deg); }
20% { transform: rotate(3deg); }
40% { transform: rotate(-3deg); }
60% { transform: rotate(3deg); }
80% { transform: rotate(-3deg); }
100% { transform: rotate(0deg); }
}
@keyframes wobble {
0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); }
15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; }
+16
View File
@@ -481,7 +481,23 @@
font-size: clamp(0.6rem, 0.85cqi, 100px);
}
.card__comments {
--column-gap: 0.8ch;
display: flex;
margin-block-end: calc(var(--block-space) * -1.7);
margin-inline-end: calc(var(--card-padding-inline) * -0.5);
.icon {
--icon-size: 1.6em;
color: var(--card-color);
}
}
.card__steps {
--column-gap: 0.8ch;
display: flex;
}
+14 -1
View File
@@ -277,7 +277,7 @@
border-radius: 99rem;
}
.btn:not(.popup__btn, .btn--plain, .btn--reversed) {
.btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) {
--btn-background: var(--card-color);
--btn-color: var(--color-ink-inverted);
}
@@ -299,6 +299,19 @@
}
}
.card-perma__notch-new-card-buttons {
display: flex;
gap: var(--inline-space-half);
@media (max-width: 479px) {
flex-direction: column;
.btn {
inline-size: 100%;
}
}
}
.card-perma__closure-message {
color: var(--card-color);
}
+81 -43
View File
@@ -23,6 +23,7 @@
}
.comment {
display: flex;
margin-inline: auto;
max-inline-size: var(--comment-max);
position: relative;
@@ -34,6 +35,56 @@
.house-md-content {
padding: var(--comment-padding-block) 0 0;
}
.comment--system & {
--comment-padding-block: var(--block-space-half);
text-align: center;
&::before {
/* Make up space for lack of avatar */
content: "";
display: flex;
inline-size: calc(var(--comment-padding-inline) * 0.9);
}
.comment__avatar {
display: none;
}
.comment__author {
a { margin: 0 auto; }
h3 { margin-inline: auto; }
strong { display: none; }
}
.comment__body {
padding: 0;
text-align: center;
}
.comment__content {
--stripe-color: var(--color-ink-lightest);
background-image: repeating-linear-gradient(
45deg in srgb,
var(--color-canvas) 0 1px,
var(--stripe-color) 1px 10px);
padding-inline: var(--comment-padding-inline);
.comments--system-expanded .comment--system & {
--stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
}
.comment__history {
background-color: var(--stripe-color);
}
}
.reactions {
display: none !important;
}
}
}
.comment__author {
@@ -48,7 +99,6 @@
}
.comment__body {
padding-inline-end: var(--reaction-size);
text-align: start;
.action-text-content {
@@ -60,6 +110,10 @@
margin-block-end: 0;
}
}
&:not:has(lexxy-editor) {
padding-inline-end: var(--reaction-size);
}
}
.comment__content {
@@ -70,6 +124,7 @@
background-color: var(--comment-bg-color);
border-radius: 0.2em;
max-inline-size: calc(100% - calc(var(--comment-padding-inline) * 0.75));
padding:
var(--comment-padding-block)
calc(var(--comment-padding-inline) / 2)
@@ -82,53 +137,36 @@
background-color: var(--color-ink-lightest);
}
.comment__history {
background-color: var(--color-ink-lightest);
display: none;
inset: calc(var(--comment-padding-inline) / 2.5) calc(var(--comment-padding-inline) / 1.5) auto auto; margin-inline-end: calc(var(--comment-padding-inline) / -2.5);
position: absolute;
}
.comment--system {
--comment-padding-block: var(--block-space-half);
display: none;
transition: var(--dialog-duration) allow-discrete;
transition-property: display;
max-inline-size: var(--comment-max);
text-align: center;
&::before {
/* Make up space for lack of avatar */
content: "";
display: flex;
inline-size: calc(var(--comment-padding-inline) * 0.75);
.comments--system-expanded & {
display: contents;
}
}
.comment__avatar {
/* Show the last system comment */
:nth-last-child(1 of .comment--system) {
display: contents;
.comment__history {
display: grid;
}
}
/* Hide the "Show history" button if there's only one system comment */
:nth-child(1 of .comment--system) {
.comment__history {
display: none;
}
.comment__author {
a {
margin: 0 auto;
}
h3 {
margin-inline: auto;
}
strong {
display: none;
}
}
.comment__body {
padding: 0;
text-align: center;
}
.comment__content {
background-image: repeating-linear-gradient(
45deg in srgb,
var(--color-canvas) 0 1px,
var(--color-ink-lightest) 1px 10px
);
padding-inline: var(--comment-padding-inline);
}
.reactions {
display: none !important;
}
}
}
+1 -2
View File
@@ -59,9 +59,8 @@
.icon--fizzy { --svg: url("fizzy.svg"); }
.icon--globe { --svg: url("globe.svg "); }
.icon--golden-ticket { --svg: url("golden-ticket.svg "); }
.icon--history { --svg: url("history.svg "); }
.icon--home { --svg: url("home.svg "); }
.icon--install { --svg: url("install.svg "); }
.icon--install { --svg: url("install.svg "); }
.icon--install-edge { --svg: url("install-edge.svg "); }
.icon--lifebuoy { --svg: url("lifebuoy.svg "); }
.icon--lock { --svg: url("lock.svg "); }
+7
View File
@@ -0,0 +1,7 @@
@layer platform {
[data-platform~=ios] {
.hide-on-ios {
display: none;
}
}
}
+11 -9
View File
@@ -38,7 +38,7 @@
}
.lexxy-editor__content {
margin: var(--block-space-half) 0;
margin-block-start: var(--block-space-half);
min-block-size: calc(7lh + var(--block-space));
outline: 0;
@@ -46,6 +46,10 @@
::selection {
background: oklch(var(--lch-blue-light) / 0.5);
}
> :last-child {
margin-block-end: 0;
}
}
.lexxy-editor--drag-over {
@@ -227,12 +231,15 @@
[data-button-group] {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--gap);
+ & {
margin-block-start: var(--gap);
}
@media (max-width: 639px) {
flex-wrap: wrap;
}
}
}
@@ -297,17 +304,12 @@
font-size: var(--text-small);
list-style: none;
margin: 0;
max-height: 200px;
min-inline-size: var(--lexxy-prompt-min-width);
overflow: auto;
padding: var(--lexxy-prompt-padding);
visibility: hidden;
z-index: var(--z-popup);
max-height: 200px;
overflow: scroll;
&:is(.lexxy-prompt-menu--visible) {
padding: var(--lexxy-prompt-padding);
}
}
.lexxy-prompt-menu--visible {
+1 -1
View File
@@ -67,7 +67,7 @@
}
.lightbox__caption {
color: var(--color-ink-inverted);
color: var(--color-white);
&:empty {
display: none;
+13
View File
@@ -0,0 +1,13 @@
@layer native {
[data-platform~=native] {
/* The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */
--custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px));
--custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px));
--custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px));
--custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px));
.hide-on-native {
display: none;
}
}
}
+9
View File
@@ -36,6 +36,15 @@
}
}
.popup__footer {
border-block-start: 1px solid var(--color-ink-lightest);
color: var(--card-color);
margin-block-start: var(--popup-item-padding-inline);
padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0;
text-align: center;
text-transform: initial;
}
.popup__title {
font-weight: 800;
white-space: nowrap;
+4
View File
@@ -24,6 +24,10 @@
margin: 0;
position: absolute;
@media (max-width: 640px) {
inset-inline-end: calc(var(--comment-padding-inline) / 3);
}
.reactions__list {
display: none;
}
+40 -28
View File
@@ -1,8 +1,8 @@
@layer components {
.rich-text-content {
:where(.rich-text-content) {
--block-margin: 0.5lh;
:where(h1, h2, h3, h4, h5, h6) {
h1, h2, h3, h4, h5, h6 {
display: block;
font-weight: 800;
letter-spacing: -0.02ch;
@@ -12,14 +12,14 @@
text-wrap: balance;
}
:where(h1) { font-size: 2em; }
:where(h2) { font-size: 1.5em; }
:where(h3) { font-size: 1.17em; }
:where(h4) { font-size: 1em; }
:where(h5) { font-size: 0.83em; }
:where(h6) { font-size: 0.67em; }
h1 { font-size: 2em; }
h2 { font-size: 1.5em; }
h3 { font-size: 1.17em; }
h4 { font-size: 1em; }
h5 { font-size: 0.83em; }
h6 { font-size: 0.67em; }
:where(p, ul, ol, dl, blockquote, figure, .attachment) {
p, ul, ol, dl, blockquote, figure, .attachment {
margin-block: 0 var(--block-margin);
&:not(lexxy-editor &) {
@@ -28,49 +28,65 @@
}
}
:where(ol, ul) {
list-style: disc;
p:has(+ p) {
margin: 0;
}
ol, ul {
padding-inline-start: 3ch;
}
:where(li:has(li)) {
ol {
list-style: decimal;
}
ul {
list-style: disc;
}
li:has(li) {
list-style: none;
:where(ol, ul) {
ol, ul {
margin: 0;
padding-inline-start: 2ch;
}
}
:where(b, strong, .lexxy-content__bold) {
b, strong, .lexxy-content__bold {
font-weight: 700;
}
:where(i, em, .lexxy-content__italic) {
i, em, .lexxy-content__italic {
font-style: italic;
}
:where(s, .lexxy-content__strikethrough) {
s, .lexxy-content__strikethrough {
text-decoration: line-through;
}
:where(mark, .lexxy-content__highlight) {
mark, .lexxy-content__highlight {
background-color: transparent;
color: inherit;
}
:where(p, blockquote) {
p, blockquote {
letter-spacing: -0.005ch;
}
:where(blockquote) {
/* Avoid extra space due to empty paragraphs */
p:empty {
display: none;
}
blockquote {
border-inline-start: 0.25em solid var(--color-ink-lighter);
font-style: italic;
margin: var(--block-margin) 0;
padding-inline-start: 2ch;
}
:where(img, video, embed, object) {
img, video, embed, object {
inline-size: auto;
margin-inline: auto;
max-block-size: 32rem;
@@ -81,14 +97,10 @@
a:has(img),
a:has(video) {
inline-size: fit-content;
margin-inline: auto;
}
/* Avoid extra space due to empty paragraphs */
p:empty {
display: none;
}
:where(hr) {
hr {
color: currentColor;
block-size: 0;
border: none;
@@ -103,7 +115,7 @@
}
/* Code */
:where(code, pre) {
code, pre {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.3em;
@@ -209,7 +221,7 @@
position: relative;
max-inline-size: 100%;
:where(progress) {
progress {
inline-size: 100%;
margin: auto;
}
+4 -7
View File
@@ -93,17 +93,14 @@
}
.steps__icon {
--icon-size: 0.875em;
background-color: var(--card-color);
block-size: 1.3em;
border-radius: 50%;
color: var(--color-ink-inverted);
display: grid;
inline-size: 1.3em;
place-content: center;
.icon {
background-color: var(--color-ink-inverted);
block-size: 0.8em;
inline-size: 0.8em;
}
}
}
}
+1
View File
@@ -424,6 +424,7 @@
.card__meta-text:not(.card__meta-text--updated),
.card__stages,
.card__steps,
.card__comments,
.card__closed {
display: none;
}
+23
View File
@@ -178,6 +178,23 @@
.fill-highlight { background-color: var(--color-highlight); }
.fill-transparent { background-color: transparent; }
.fill-highlighter {
display: inline-block;
position: relative;
z-index: 1;
&::before {
background-color: var(--color-highlight);
border-radius: 0.2em;
content: "";
inset-block: 0;
inset-inline: -0.1em;
position: absolute;
transform: skewX(-10deg) rotate(1deg);
z-index: -1;
}
}
.translucent { opacity: var(--opacity, 0.66); }
/* Borders */
@@ -246,6 +263,12 @@
}
}
.show-on-native {
body:not([data-platform~=native]) & {
display: none;
}
}
.hide-scrollbar {
-ms-overflow-style: none; /* Edge */
scrollbar-width: none; /* FF */
+1 -1
View File
@@ -35,7 +35,7 @@ class Boards::ColumnsController < ApplicationController
@column.destroy
respond_to do |format|
format.turbo_stream
format.html { redirect_back_or_to @board }
format.json { head :no_content }
end
end
+1 -1
View File
@@ -83,7 +83,7 @@ class BoardsController < ApplicationController
def show_columns
cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded
set_page_and_extract_portion_from cards
fresh_when etag: [ @board, @page.records, @user_filtering ]
fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ]
end
def board_params
@@ -8,11 +8,16 @@ class Cards::AssignmentsController < ApplicationController
end
def create
@card.toggle_assignment @board.users.active.find(params[:assignee_id])
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
if @card.toggle_assignment @board.users.active.find(params[:assignee_id])
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
else
respond_to do |format|
format.turbo_stream
format.json { head :unprocessable_entity }
end
end
end
end
+1 -1
View File
@@ -61,6 +61,6 @@ class CardsController < ApplicationController
end
def card_params
params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ])
params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ])
end
end
+4 -33
View File
@@ -4,13 +4,12 @@ module Authentication
included do
before_action :require_account # Checking and setting account must happen first
before_action :require_authentication
after_action :ensure_development_magic_link_not_leaked
helper_method :authenticated?
helper_method :email_address_pending_authentication
etag { Current.identity.id if authenticated? }
include LoginHelper
include Authentication::ViaMagicLink, LoginHelper
end
class_methods do
@@ -38,7 +37,7 @@ module Authentication
def require_account
unless Current.account.present?
redirect_to main_app.session_menu_url(script_name: nil)
redirect_to main_app.session_menu_path(script_name: nil)
end
end
@@ -102,35 +101,7 @@ module Authentication
cookies.delete(:session_token)
end
def ensure_development_magic_link_not_leaked
unless Rails.env.development?
raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
end
end
def email_address_pending_authentication_matches?(email_address)
if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "")
session.delete(:email_address_pending_authentication)
true
else
false
end
end
def email_address_pending_authentication
session[:email_address_pending_authentication]
end
def redirect_to_session_magic_link(magic_link, return_to: nil)
serve_development_magic_link(magic_link)
session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link
session[:return_to_after_authenticating] = return_to if return_to
redirect_to main_app.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
end
def session_token
cookies[:session_token]
end
end
@@ -0,0 +1,67 @@
module Authentication::ViaMagicLink
extend ActiveSupport::Concern
included do
after_action :ensure_development_magic_link_not_leaked
end
private
def ensure_development_magic_link_not_leaked
unless Rails.env.development?
raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
end
end
def redirect_to_fake_session_magic_link(email_address, **options)
fake_magic_link = MagicLink.new(
identity: Identity.new(email_address: email_address),
code: SecureRandom.base32(6),
expires_at: MagicLink::EXPIRATION_TIME.from_now
)
redirect_to_session_magic_link fake_magic_link, **options
end
def redirect_to_session_magic_link(magic_link, return_to: nil)
serve_development_magic_link(magic_link)
set_pending_authentication_token(magic_link)
session[:return_to_after_authenticating] = return_to if return_to
respond_to do |format|
format.html { redirect_to main_app.session_magic_link_url(script_name: nil) }
format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created }
end
end
def serve_development_magic_link(magic_link)
if Rails.env.development? && magic_link.present?
flash[:magic_link_code] = magic_link.code
response.set_header("X-Magic-Link-Code", magic_link.code)
end
end
def set_pending_authentication_token(magic_link)
cookies[:pending_authentication_token] = {
value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at),
httponly: true,
same_site: :lax,
expires: magic_link.expires_at
}
end
def email_address_pending_authentication
pending_authentication_token_verifier.verified(pending_authentication_token)
end
def pending_authentication_token_verifier
Rails.application.message_verifier(:pending_authentication)
end
def pending_authentication_token
cookies[:pending_authentication_token]
end
def clear_pending_authentication_token
cookies.delete(:pending_authentication_token)
end
end
+1 -6
View File
@@ -3,7 +3,6 @@ module Authorization
included do
before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? }
before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated?
end
class_methods do
@@ -27,11 +26,7 @@ module Authorization
end
def ensure_can_access_account
redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active?
end
def ensure_only_staff_can_access_non_production_remote_environments
head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff?
redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active?
end
def redirect_existing_user
+1 -1
View File
@@ -16,7 +16,7 @@ module FilterScoped
end
def filter_params
params.reverse_merge(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS)
params.with_defaults(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS)
end
def set_user_filtering
+1
View File
@@ -1,5 +1,6 @@
class JoinCodesController < ApplicationController
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { head :too_many_requests }
before_action :set_join_code
before_action :ensure_join_code_is_valid
+2 -1
View File
@@ -4,7 +4,8 @@ class My::MenusController < ApplicationController
@boards = Current.user.boards.ordered_by_recently_accessed
@tags = Current.account.tags.all.alphabetically
@users = Current.account.users.active.alphabetically
@accounts = Current.identity.accounts
fresh_when etag: [ @filters, @boards, @tags, @users ]
fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ]
end
end
+1 -1
View File
@@ -11,7 +11,7 @@ class Public::BaseController < ApplicationController
end
def set_card
@card = @board.cards.find_by!(number: params[:id]) if params[:board_id] && params[:id]
@card = @board.cards.published.find_by!(number: params[:id]) if params[:board_id] && params[:id]
end
def set_public_cache_expiration
@@ -1,7 +1,7 @@
class Sessions::MagicLinksController < ApplicationController
disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" }
rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded
before_action :ensure_that_email_address_pending_authentication_exists
layout "public"
@@ -11,25 +11,20 @@ class Sessions::MagicLinksController < ApplicationController
def create
if magic_link = MagicLink.consume(code)
authenticate_with magic_link
authenticate magic_link
else
redirect_to session_magic_link_path, flash: { shake: true }
invalid_code
end
end
private
def ensure_that_email_address_pending_authentication_exists
unless email_address_pending_authentication.present?
redirect_to new_session_path, alert: "Enter your email address to sign in."
end
end
def authenticate_with(magic_link)
if email_address_pending_authentication_matches?(magic_link.identity.email_address)
start_new_session_for magic_link.identity
redirect_to after_sign_in_url(magic_link)
else
redirect_to new_session_path, alert: "Authentication failed. Please try again."
alert_message = "Enter your email address to sign in."
respond_to do |format|
format.html { redirect_to new_session_path, alert: alert_message }
format.json { render json: { message: alert_message }, status: :unauthorized }
end
end
end
@@ -37,6 +32,41 @@ class Sessions::MagicLinksController < ApplicationController
params.expect(:code)
end
def authenticate(magic_link)
if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address)
sign_in magic_link
else
email_address_mismatch
end
end
def sign_in(magic_link)
clear_pending_authentication_token
start_new_session_for magic_link.identity
respond_to do |format|
format.html { redirect_to after_sign_in_url(magic_link) }
format.json { render json: { session_token: session_token } }
end
end
def email_address_mismatch
clear_pending_authentication_token
alert_message = "Something went wrong. Please try again."
respond_to do |format|
format.html { redirect_to new_session_path, alert: alert_message }
format.json { render json: { message: alert_message }, status: :unauthorized }
end
end
def invalid_code
respond_to do |format|
format.html { redirect_to session_magic_link_path, flash: { shake: true } }
format.json { render json: { message: "Try another code." }, status: :unauthorized }
end
end
def after_sign_in_url(magic_link)
if magic_link.for_sign_up?
new_signup_completion_path
@@ -44,4 +74,12 @@ class Sessions::MagicLinksController < ApplicationController
after_authentication_url
end
end
def rate_limit_exceeded
rate_limit_exceeded_message = "Try again in 15 minutes."
respond_to do |format|
format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message }
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
end
end
end
+42 -10
View File
@@ -1,7 +1,7 @@
class SessionsController < ApplicationController
disallow_account_scope
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
layout "public"
@@ -9,16 +9,12 @@ class SessionsController < ApplicationController
end
def create
if identity = Identity.find_by_email_address(email_address)
redirect_to_session_magic_link identity.send_magic_link
if identity = Identity.find_by(email_address: email_address)
sign_in identity
elsif Account.accepting_signups?
sign_up
else
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
magic_link = signup.create_identity if Account.accepting_signups?
redirect_to_session_magic_link magic_link
else
head :unprocessable_entity
end
redirect_to_fake_session_magic_link email_address
end
end
@@ -28,7 +24,43 @@ class SessionsController < ApplicationController
end
private
def magic_link_from_sign_in_or_sign_up
if identity = Identity.find_by_email_address(email_address)
identity.send_magic_link
else
signup = Signup.new(email_address: email_address)
signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups?
end
end
def email_address
params.expect(:email_address)
end
def rate_limit_exceeded
rate_limit_exceeded_message = "Try again later."
respond_to do |format|
format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
end
end
def sign_in(identity)
redirect_to_session_magic_link identity.send_magic_link
end
def sign_up
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
magic_link = signup.create_identity
redirect_to_session_magic_link magic_link
else
respond_to do |format|
format.html { redirect_to new_session_path, alert: "Something went wrong" }
format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity }
end
end
end
end
-6
View File
@@ -10,12 +10,6 @@ module ApplicationHelper
tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options
end
def inline_svg(name)
file_path = "#{Rails.root}/app/assets/images/#{name}.svg"
return File.read(file_path).html_safe if File.exist?(file_path)
"(not found)"
end
def back_link_to(label, url, action, **options)
link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do
icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe
+1 -1
View File
@@ -43,7 +43,7 @@ module ColumnsHelper
end
def column_frame_tag(id, src: nil, data: {}, **options, &block)
data = data.reverse_merge \
data = data.with_defaults \
drag_and_drop_refresh: true,
controller: "frame",
action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload"
+3 -1
View File
@@ -3,6 +3,8 @@ module MessagesHelper
turbo_frame_tag dom_id(card, :messages),
class: "comments gap center",
style: "--card-color: #{card.color}",
role: "group", aria: { label: "Messages" }, &
role: "group",
aria: { label: "Messages" },
data: { controller: "toggle-class", toggle_class_toggle_class: "comments--system-expanded" }, &
end
end
@@ -0,0 +1,26 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { limit: Number, count: Number }
static targets = ["unassigned", "limitMessage"]
connect() {
this.updateState()
}
countValueChanged() {
this.updateState()
}
updateState() {
const atLimit = this.countValue >= this.limitValue
this.unassignedTargets.forEach(el => {
el.hidden = atLimit
})
if (this.hasLimitMessageTarget) {
this.limitMessageTarget.hidden = !atLimit
}
}
}
@@ -3,9 +3,22 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "caption", "image", "dialog", "zoomedImage" ]
open(event) {
imageTargetConnected(element) {
element.addEventListener("click", this.#handleImageClick)
}
imageTargetDisconnected(element) {
element.removeEventListener("click", this.#handleImageClick)
}
#handleImageClick = (event) => {
event.preventDefault()
this.#open(event.currentTarget)
}
#open(link) {
this.dialogTarget.showModal()
this.#set(event.target.closest("a"))
this.#set(link)
}
// Wait for the transition to finish before resetting the image
-4
View File
@@ -1,4 +0,0 @@
export const HttpStatus = {
CONFLICT: 409,
UNPROCESSABLE: 422
}
+1 -1
View File
@@ -19,7 +19,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", verified_at: Time.current))
account.users.create!(**owner.with_defaults(role: :owner, verified_at: Time.current))
end
end
end
+1
View File
@@ -69,6 +69,7 @@ class Account::Seeder
<li>Make another called "Working on"</li>
</ol>
<p>Go back to the Board view, click the little + to the right of the DONE column, name the column, pick a color, then do it again.</p>
<p><br></p>
<p>After that, drag this card to DONE or select DONE in the sidebar.</p>
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/make-columns.gif" alt="Demo of adding columns" caption="Make two more columns" content-type="image/*" filename="make-columns.gif" presentation="gallery"></action-text-attachment>
HTML
+2
View File
@@ -0,0 +1,2 @@
module Admin
end
+16
View File
@@ -35,10 +35,26 @@ class ApplicationPlatform < PlatformAgent
!mobile?
end
def native?
match? /Hotwire Native/
end
def windows?
operating_system == "Windows"
end
def type
if native? && android?
"native android"
elsif native? && ios?
"native ios"
elsif mobile?
"mobile web"
else
"desktop web"
end
end
def operating_system
case user_agent.platform
when /Android/ then "Android"
+11
View File
@@ -1,7 +1,18 @@
class Assignment < ApplicationRecord
LIMIT = 100
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
belongs_to :assignee, class_name: "User"
belongs_to :assigner, class_name: "User"
validate :within_limit, on: :create
private
def within_limit
if card.assignments.count >= LIMIT
errors.add(:base, "Card already has the maximum of #{LIMIT} assignees")
end
end
end
+5 -3
View File
@@ -24,10 +24,12 @@ module Card::Assignable
private
def assign(user)
assignments.create! assignee: user, assigner: Current.user
watch_by user
assignment = assignments.create assignee: user, assigner: Current.user
track_event :assigned, assignee_ids: [ user.id ]
if assignment.persisted?
watch_by user
track_event :assigned, assignee_ids: [ user.id ]
end
rescue ActiveRecord::RecordNotUnique
# Already assigned
end
+3 -3
View File
@@ -7,9 +7,9 @@ module Card::Closeable
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) }
scope :closed_by, ->(users) { closed.where("closures.user_id": Array(users)) }
scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) }
scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) }
scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) }
end
def closed?
+1 -1
View File
@@ -7,7 +7,7 @@ module Card::Stallable
has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy
scope :with_activity_spikes, -> { joins(:activity_spike) }
scope :stalled, -> { open.active.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }
scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }
before_update :remember_to_detect_activity_spikes
after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes?
-2
View File
@@ -6,8 +6,6 @@ module Card::Statuses
before_save :mark_if_just_published
after_create -> { track_event :published }, if: :published?
scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) }
end
attr_accessor :was_just_published
+4
View File
@@ -31,6 +31,10 @@ module Column::Positioned
right_column.nil?
end
def adjacent_columns
board.columns.where(id: [ left_column&.id, right_column&.id ].compact)
end
private
def set_position
max_position = board.columns.maximum(:position) || 0
+2 -2
View File
@@ -10,8 +10,8 @@ class Comment < ApplicationRecord
scope :chronologically, -> { order created_at: :asc, id: :desc }
scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }
scope :by_system, -> { joins(:creator).where(creator: { role: "system" }) }
scope :by_user, -> { joins(:creator).where.not(creator: { role: "system" }) }
scope :by_system, -> { joins(:creator).where(creator: { role: :system }) }
scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) }
after_create_commit :watch_card_by_creator
+1 -1
View File
@@ -29,7 +29,7 @@ class Filter < ApplicationRecord
result = result.where(creator_id: creators.ids) if creators.present?
result = result.where(board: boards.ids) if boards.present?
result = result.tagged_with(tags.ids) if tags.present?
result = result.where("cards.created_at": creation_window) if creation_window
result = result.where(cards: { created_at: creation_window }) if creation_window
result = result.closed_at_window(closure_window) if closure_window
result = result.closed_by(closers) if closers.present?
result = terms.reduce(result) do |result, term|
+1
View File
@@ -8,6 +8,7 @@ class Signup
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation
validates :full_name, :identity, presence: true, on: :completion
validates :full_name, length: { maximum: 240 }
def initialize(...)
super
+8 -8
View File
@@ -31,21 +31,21 @@ class TimeWindowParser
def parse(string)
case normalize(string)
when "today"
now.beginning_of_day..now.end_of_day
now.all_day
when "yesterday"
(now - 1.day).beginning_of_day..(now - 1.day).end_of_day
(now - 1.day).all_day
when "thisweek"
now.beginning_of_week..now.end_of_week
now.all_week
when "thismonth"
now.beginning_of_month..now.end_of_month
now.all_month
when "thisyear"
now.beginning_of_year..now.end_of_year
now.all_year
when "lastweek"
(now - 1.week).beginning_of_week..(now - 1.week).end_of_week
(now - 1.week).all_week
when "lastmonth"
(now - 1.month).beginning_of_month..(now - 1.month).end_of_month
(now - 1.month).all_month
when "lastyear"
(now - 1.year).beginning_of_year..(now - 1.year).end_of_year
(now - 1.year).all_year
end
end
+2 -2
View File
@@ -6,6 +6,8 @@ class User < ApplicationRecord
belongs_to :account
belongs_to :identity, optional: true
validates :name, presence: true
has_many :comments, inverse_of: :creator, dependent: :destroy
has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy
@@ -14,8 +16,6 @@ class User < ApplicationRecord
has_many :pinned_cards, through: :pins, source: :card
has_many :exports, class_name: "Account::Export", dependent: :destroy
scope :with_avatars, -> { preload(:account, :avatar_attachment) }
def deactivate
transaction do
accesses.destroy_all
+24 -2
View File
@@ -2,13 +2,20 @@ module User::Avatar
extend ActiveSupport::Concern
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze
included do
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately
end
validate :avatar_content_type_allowed
scope :with_avatars, -> { preload(:account, :avatar_attachment) }
validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached?
end
def avatar_attached?
avatar.attached?
end
def avatar_thumbnail
@@ -17,8 +24,23 @@ module User::Avatar
private
def avatar_content_type_allowed
if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image")
end
end
def avatar_dimensions_allowed
return unless avatar.blob.analyzed? || avatar.blob.analyze
width = avatar.blob.metadata[:width]
height = avatar.blob.metadata[:height]
if width && width > MAX_AVATAR_DIMENSIONS[:width]
errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px")
end
if height && height > MAX_AVATAR_DIMENSIONS[:height]
errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px")
end
end
end
+1 -1
View File
@@ -39,7 +39,7 @@ module User::EmailAddressChangeable
private
def generate_email_address_change_token(from: identity.email_address, to:, **options)
options = options.reverse_merge(
options = options.with_defaults(
for: EMAIL_CHANGE_TOKEN_PURPOSE,
old_email_address: from,
new_email_address: to,
+1 -1
View File
@@ -19,7 +19,7 @@ module User::Named
def familiar_name
names = name.split
return name if names.length == 1
return name if names.length <= 1
"#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}"
end
end
+1 -1
View File
@@ -1,4 +1,4 @@
<li class="flex align-center gap-half" data-filter-target="item" data-navigable-list-target="item">
<li class="flex align-center gap-half" data-filter-target="item" data-navigable-list-target="item" role="option">
<%= link_to user, class: "txt-ink flex gap-half align-center min-width" do %>
<%= avatar_preview_tag user, hidden_for_screen_reader: true %>
<div class="txt-align-start overflow-ellipsis">
+1 -1
View File
@@ -12,7 +12,7 @@
<div class="settings__user-filter">
<input placeholder="Filter…" class="input input--transparent full-width txt-small" type="search" autocorrect="off" autocomplete="off" data-1p-ignore="true" data-filter-target="input" data-action="input->filter#filter">
<ul class="settings__user-list margin-block-half" data-filter-target="list">
<ul class="settings__user-list margin-block-half" data-filter-target="list" role="listbox">
<%= render partial: "account/settings/user", collection: users %>
</ul>
</div>
+2
View File
@@ -20,3 +20,5 @@
<%= render "account/settings/export" %>
</div>
</section>
<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
@@ -15,7 +15,7 @@
<source src="<%= rails_blob_url(blob) %>" type="<%= blob.content_type %>">
</audio>
<% elsif blob.variable? %>
<%= link_to url_for(blob.variant(variant)), data: { action: "lightbox#open:prevent", lightbox_target: "image" } do %>
<%= link_to url_for(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %>
<%= image_tag url_for(blob.variant(variant)), width: width, height: height %>
<% end %>
<% elsif blob.previewable? %>
@@ -1 +1,2 @@
<%= turbo_stream.before("closed-cards", partial: "boards/show/column", method: :morph, locals: { column: @column }) %>
<%= render "columns/refresh_adjacent_columns", column: @column %>
@@ -1 +0,0 @@
<%= turbo_stream.remove(dom_id(@column)) %>
+1
View File
@@ -6,6 +6,7 @@ json.cache! card do
json.tags card.tags.pluck(:title).sort
json.closed card.closed?
json.golden card.golden?
json.last_active_at card.last_active_at.utc
json.created_at card.created_at.utc
+12 -8
View File
@@ -1,5 +1,5 @@
<% cache card do %>
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;">
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;">
<% cache card do %>
<div class="card-perma__actions card-perma__actions--left">
<%= render "cards/container/gild", card: card if card.published? && !card.closed? %>
<%= render "cards/container/image", card: card %>
@@ -33,11 +33,15 @@
<%= render "cards/display/preview/bubble", card: card %>
<% end %>
</div>
<% end %>
<% if card.published? %>
<%= render "cards/container/footer/published", card: card %>
<% elsif card.drafted? %>
<%= render "cards/container/footer/draft", card: card %>
<% if card.published? %>
<%= render "cards/container/footer/published", card: card %>
<% elsif card.drafted? %>
<% if Fizzy.saas? %>
<%= render "cards/container/footer/saas/create", card: card %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
<% end %>
</section>
<% end %>
<% end %>
</section>
+14 -3
View File
@@ -1,10 +1,12 @@
<%= turbo_frame_tag @card, :assignment do %>
<%= tag.div class: "max-width full-width", data: {
action: "turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset",
controller: "filter navigable-list",
controller: "filter navigable-list assignment-limit",
dialog_target: "dialog",
navigable_list_focus_on_selection_value: false,
navigable_list_actionable_items_value: true } do %>
navigable_list_actionable_items_value: true,
assignment_limit_limit_value: Assignment::LIMIT,
assignment_limit_count_value: @card.assignments.count } do %>
<div class="flex align-start justify-space-between">
<strong class="popup__title">Assign this to…</strong>
@@ -17,9 +19,18 @@
<ul class="popup__list" data-filter-target="list">
<%= render "user", card: @card, user: Current.user, user_label: "Me" do %>
<span class="visually-hidden"><%= Current.user.name %></span>
<kbd class="txt-xx-small hide-on-touch">m</kbd>
<% end %>
<%= render collection: @assigned_to, partial: "user", locals: { card: @card } %>
<%= render collection: @users, partial: "user", locals: { card: @card } %>
<% @users.each do |user| %>
<span data-assignment-limit-target="unassigned">
<%= render "user", card: @card, user: user %>
</span>
<% end %>
</ul>
<div class="popup__footer" hidden data-assignment-limit-target="limitMessage">
Maximum <%= Assignment::LIMIT %> assignees
</div>
<% end %>
<% end %>
+9 -3
View File
@@ -1,7 +1,8 @@
<% cache comment do %>
<%= turbo_frame_tag comment, :container do %>
<%# Bump for CSS changes: 2025-06-30 -%>
<div id="<%= dom_id(comment) %>" data-creator-id="<%= comment.creator_id %>" class="comment flex align-start full-width <%= "comment--system" if comment.creator.system? %>">
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= turbo_frame_tag comment, :container, class: ["comment--system": comment.creator.system?] do %>
<%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%>
<div id="<%= dom_id(comment) %>" data-creator-id="<%= comment.creator_id %>" class="comment align-start full-width">
<figure class="comment__avatar flex-item-no-shrink" aria-hidden="true">
<%= avatar_tag comment.creator, hidden_for_screen_reader: true %>
</figure>
@@ -19,6 +20,11 @@
<% end %>
</h3>
<button class="comment__history btn btn--circle-mobile borderless txt-x-small" data-action="toggle-class#toggle">
<%= icon_tag "history", class: "icon--mobile-only" %>
<span>Full history</span>
</button>
<%= link_to edit_card_comment_path(comment.card, comment),
class: "comment__edit btn btn--circle borderless translucent", data: { only_visible_to_you: true } do %>
<%= icon_tag "menu-dots-horizontal" %>
@@ -0,0 +1,19 @@
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<div class="card-perma__notch-new-card-buttons">
<%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn",
title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })",
form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %>
<span>Create card</span>
<% end %>
<%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another",
title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %>
<span>Create and add another</span>
<% end %>
</div>
<%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %>
</div>
@@ -1,14 +0,0 @@
<div class="card-perma__notch card-perma__notch--bottom">
<%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn",
title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })",
form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %>
<span>Create card</span>
<% end %>
<%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another",
title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %>
<span>Create and add another</span>
<% end %>
</div>
+2 -1
View File
@@ -30,8 +30,9 @@
</div>
</div>
<footer class="card__footer">
<footer class="card__footer flex gap-half">
<%= render "cards/display/preview/meta", card: card, preview: true %>
<%= render "cards/display/preview/comments", card: card %>
<%= render "cards/display/common/background", card: card %>
</footer>
@@ -12,6 +12,10 @@
</span>
</button>
<% if Current.user %>
<%= button_to "Assign to me", card_assignments_path(card, params: { assignee_id: Current.user.id }), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %>
<% end %>
<dialog class="popup panel flex-column align-start gap-half fill-white shadow" data-dialog-target="dialog" data-action="turbo:before-morph-attribute->dialog#preventCloseOnMorphing turbo:submit-end->dialog#close">
<%= yield %>
</dialog>
@@ -1,5 +1,5 @@
<%= render "cards/display/common/background", card: card do %>
<%= link_to card.image.presence, class: "btn fill-transparent flex-item-justify-end", data: { controller: "tooltip", action: "lightbox#open:prevent", lightbox_target: "image" } do %>
<%= link_to card.image.presence, class: "btn fill-transparent flex-item-justify-end", data: { controller: "tooltip", lightbox_target: "image" } do %>
<%= icon_tag "picture-zoom" %>
<span class="for-screen-reader">Zoom background image</span>
<% end %>
@@ -0,0 +1,7 @@
<% comments = card.comments.by_user %>
<% if comments.any? %>
<div class="card__comments align-center gap-half flex-item-justify-end flex-item-no-shrink">
<%= icon_tag "comment" %>
<strong><%= comments.count %></strong>
</div>
<% end %>
@@ -0,0 +1,3 @@
<% column.adjacent_columns.each do |adjacent_column| %>
<%= turbo_stream.replace(dom_id(adjacent_column), partial: "boards/show/column", method: :morph, locals: { column: adjacent_column }) %>
<% end %>
@@ -1,4 +1,5 @@
<% if @left_column %>
<%= turbo_stream.remove(dom_id(@column)) %>
<%= turbo_stream.before(@left_column, partial: "boards/show/column", locals: { column: @column }) %>
<%= render "columns/refresh_adjacent_columns", column: @column %>
<% end %>
@@ -1,4 +1,5 @@
<% if @right_column %>
<%= turbo_stream.remove(dom_id(@column)) %>
<%= turbo_stream.after(@right_column, partial: "boards/show/column", locals: { column: @column }) %>
<%= render "columns/refresh_adjacent_columns", column: @column %>
<% end %>
+1
View File
@@ -1,4 +1,5 @@
<% cache event do %>
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<% if lookup_context.exists?("events/event/eventable/_#{event.action}") %>
<%= render "events/event/eventable/#{event.action}", event: event %>
<% else %>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<%= render "layouts/shared/head" %>
<body class="<%= @body_class %>" data-controller="local-time timezone-cookie turbo-navigation theme" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation">
<body class="<%= @body_class %>" data-controller="local-time timezone-cookie turbo-navigation theme" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation" data-platform="<%= platform.type %>">
<div id="global-container">
<header class="header <%= @header_class %>" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
+2 -2
View File
@@ -7,8 +7,8 @@
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
<nav>
<%= link_to "https://fizzy.do", class: "header__logo center flex-inline align-center" do %>
<span><%= image_tag "logo.png" %></span>
<svg height="30" viewBox="0 0 96 30" width="96" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z" fill="currentColor" fill-rule="evenodd"/></svg>
<span><%= image_tag "logo.png", alt: "" %></span>
<svg height="30" viewBox="0 0 96 30" width="96" xmlns="http://www.w3.org/2000/svg" title="Fizzy"><path clip-rule="evenodd" d="m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z" fill="currentColor" fill-rule="evenodd"/></svg>
<% end %>
</nav>
+1
View File
@@ -17,6 +17,7 @@
<%= render "layouts/theme_preference" %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %>
<%= javascript_importmap_tags %>
<%= tenanted_action_cable_meta_tag %>
+2 -2
View File
@@ -4,8 +4,8 @@
<%= tag.button class:"nav__trigger input input--select center flex-inline align-center txt-normal", data: {
action: "click->dialog#open keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click",
controller: "hotkey" } do %>
<span><%= image_tag "logo.png" %></span>
<svg height="30" viewBox="0 0 96 30" width="96" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z" fill="currentColor" fill-rule="evenodd"/></svg>
<span><%= image_tag "logo.png", alt: "" %></span>
<svg height="30" viewBox="0 0 96 30" width="96" xmlns="http://www.w3.org/2000/svg" title="Fizzy"><path clip-rule="evenodd" d="m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z" fill="currentColor" fill-rule="evenodd"/></svg>
<kbd class="kbd txt-xx-small hide-on-touch">J</kbd>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= render "my/menus/people", users: @users %>
<%= render "my/menus/settings" %>
<%= render "my/menus/shortcuts" %>
<%= render "my/menus/accounts", accounts: Current.identity.accounts %>
<%= render "my/menus/accounts", accounts: @accounts %>
<% end %>
<footer class="nav__footer">
@@ -1,4 +1,5 @@
<% cache notification do %>
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= notification_tag notification do %>
<%= render "notifications/notification/header", notification: notification do %>
<%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %>
+1 -1
View File
@@ -14,7 +14,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click", style: "view-transition-name: card-board-title;" %>
<%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>
+1 -1
View File
@@ -9,7 +9,7 @@
</header>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %>
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large",
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase",
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#submitOnEnter paste->magic-link#submitOnPaste" } %>
+1 -1
View File
@@ -4,7 +4,7 @@
<h1 class="txt-x-large font-weight-black margin-block-end"><%= @page_title %></h1>
<%= form_with model: @signup, url: signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %>
<%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true %>
<%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true, maxlength: 240 %>
<p>You're one step away. Just enter your name to get your own Fizzy account.</p>
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
curl -s -d content="[Fizzy] ${1}" https://3.basecamp.com/2914079/integrations/5c3a7bd149c975b95312253c/buckets/4896644/chats/676031034/lines
+37 -4
View File
@@ -1,8 +1,13 @@
#!/usr/bin/env sh
bin/rails runner - <<EOF
puts "Login with david@example.com to: http://fizzy.localhost:3006/"
EOF
PORT=3006
USE_TAILSCALE=0
for arg in "$@"; do
case $arg in
--tailscale) USE_TAILSCALE=1 ;;
esac
done
if [ ! -f tmp/solid-queue.txt ]; then
export SOLID_QUEUE_IN_PUMA=false
@@ -12,4 +17,32 @@ if [ -f tmp/oss-config.txt ]; then
export OSS_CONFIG=1
fi
exec ./bin/rails server -p 3006
if [ "$USE_TAILSCALE" = "1" ]; then
if ! command -v tailscale >/dev/null 2>&1; then
echo "Error: tailscale not found" >&2
exit 1
fi
TS_STATUS=$(tailscale status --self --json 2>&1)
if [ $? -ne 0 ]; then
echo "Error: tailscale not logged in" >&2
exit 1
fi
TS_HOSTNAME=$(echo "$TS_STATUS" | jq -r '.Self.DNSName | rtrimstr(".")')
TS_PORT="4$PORT"
stop_tailscale() { tailscale serve --https=$TS_PORT off >/dev/null 2>&1; }
trap stop_tailscale EXIT INT TERM
tailscale serve --bg --https=$TS_PORT localhost:$PORT >/dev/null 2>&1
if ! tailscale serve status --json 2>/dev/null | jq -e ".TCP.\"$TS_PORT\"" >/dev/null 2>&1; then
echo "Error: tailscale serve failed. On Linux, run once: sudo tailscale set --operator=\$USER" >&2
exit 1
fi
echo "Login with david@example.com to: https://$TS_HOSTNAME:$TS_PORT/"
else
echo "Login with david@example.com to: http://fizzy.localhost:$PORT/"
fi
./bin/rails server -p $PORT
+1 -2
View File
@@ -136,8 +136,7 @@ bundle config set --local auto_install true
step "Installing RubyGems" bundle install
if [ -n "$SAAS" ]; then
saas_setup=$(bundle show fizzy-saas)/bin/setup
source "$saas_setup"
source "$app_root/saas/bin/setup"
else
if [ "$DATABASE_ADAPTER" = "mysql" ]; then
oss_mysql_setup
+1 -1
View File
@@ -1,6 +1,6 @@
<%
config_path = if Fizzy.saas?
gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir
gem_path = Rails.root.join("saas").to_s
File.join(gem_path, "config", "database.yml")
else
File.join("config", "database.#{Fizzy.db_adapter}.yml")
+1
View File
@@ -28,6 +28,7 @@ env:
- SMTP_USERNAME
- SMTP_PASSWORD
clear:
BASE_URL: https://fizzy.example.com # The public URL of your Fizzy instance
MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from
SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email
MULTI_TENANT: false # Set to true to allow multiple accounts to sign up
+11 -4
View File
@@ -34,6 +34,8 @@ Rails.application.configure do
# Store uploaded files on the local file system (see config/storage.yml for options).
if Rails.root.join("tmp/minio-dev.txt").exist?
config.active_storage.service = :devminio
config.x.content_security_policy.connect_src = "http://minio.localhost:39000"
config.x.content_security_policy.img_src = "http://minio.localhost:39000"
else
config.active_storage.service = :local
end
@@ -83,9 +85,14 @@ Rails.application.configure do
config.action_mailer.raise_delivery_errors = false
end
config.hosts = %w[ fizzy.localhost localhost 127.0.0.1 ] + [ /^fizzy-\d+(:\d+)$/ ]
config.hosts = [
"fizzy.localhost",
"localhost",
"127.0.0.1",
/fizzy-\d+/, # review apps: fizzy-123, fizzy-456:3000
/.*\.ts\.net/ # tailscale serve: hostname.tail1234.ts.net
]
# Set host to be used by links generated in mailer and notification view templates.
config.action_controller.default_url_options = { host: config.hosts.first, port: 3006 }
config.action_mailer.default_url_options = { host: config.hosts.first, port: 3006 }
# Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs)
config.action_mailer.default_url_options = { host: "#{config.hosts.first}:3006" }
end
+14 -5
View File
@@ -11,15 +11,27 @@ Rails.application.configure do
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: smtp_address,
port: ENV.fetch("SMTP_PORT", "587").to_i,
port: ENV.fetch("SMTP_PORT", ENV["SMTP_TLS"] == "true" ? "465" : "587").to_i,
domain: ENV.fetch("SMTP_DOMAIN", nil),
user_name: ENV.fetch("SMTP_USERNAME", nil),
password: ENV.fetch("SMTP_PASSWORD", nil),
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"),
enable_starttls_auto: ENV.fetch("SMTP_ENABLE_STARTTLS_AUTO", "true") == "true"
tls: ENV["SMTP_TLS"] == "true",
openssl_verify_mode: ENV["SMTP_SSL_VERIFY_MODE"]
}
end
# Base URL for links in emails and other external references.
# Set BASE_URL to your instance's public URL (e.g., https://fizzy.example.com)
if base_url = ENV["BASE_URL"].presence
uri = URI.parse(base_url)
url_options = { host: uri.host, protocol: uri.scheme }
url_options[:port] = uri.port if uri.port != uri.default_port
routes.default_url_options = url_options
config.action_mailer.default_url_options = url_options
end
# Code is not reloaded between requests.
config.enable_reloading = false
@@ -75,9 +87,6 @@ Rails.application.configure do
.tap { |logger| logger.formatter = ::Logger::Formatter.new }
.then { |logger| ActiveSupport::TaggedLogging.new(logger) }
# Suppress unstructured log lines
config.log_level = :fatal
# Prepend all log lines with the following tags.
config.log_tags = [ :request_id ]

Some files were not shown because too many files have changed in this diff Show More