Merge branch 'main' into mobile-app/prepare-webviews

* main: (107 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:
Jay Ohms
2025-12-19 13:52:04 -05:00
120 changed files with 1551 additions and 636 deletions
+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''']
+11 -5
View File
@@ -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)
@@ -438,6 +442,7 @@ GEM
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)
@@ -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
+5 -5
View File
@@ -200,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)
@@ -212,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)
@@ -222,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)
@@ -346,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)
+16
View File
@@ -461,7 +461,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;
}
+13
View File
@@ -325,6 +325,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);
grid-area: closure-message;
+62 -69
View File
@@ -35,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 {
@@ -74,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)
@@ -94,86 +145,28 @@
}
.comment--system {
--comment-padding-block: var(--block-space-half);
display: none;
max-inline-size: var(--comment-max);
text-align: center;
transition: var(--dialog-duration) allow-discrete;
transition-property: display;
.comments--system-expanded & {
display: flex;
display: contents;
}
}
&:nth-last-child(1 of &):not(:has(~ * &) *):not(:has(~ &) *):not(:has(~ * &)):not(:has(&)) {
/* This unholy selector targets the last system comment in the document */
/* Hat-tip, https://css-tip.com/last-element-dom/ */
display: flex;
/* Show the last system comment */
:nth-last-child(1 of .comment--system) {
display: contents;
.comment__history {
display: grid;
}
.comment__history {
display: grid;
}
}
&:nth-child(1 of &):not(:has(&) ~ * *):not(:has(&) ~ *):not(& ~ * *):not(& *) {
/* Targets the first system comment in the document to effectively */
/* hide the "Show history" button if there's only one entry */
.comment__history {
display: none;
}
}
&::before {
/* Make up space for lack of avatar */
content: "";
display: flex;
inline-size: calc(var(--comment-padding-inline) * 0.75);
}
.comment__avatar {
/* 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 {
--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;
}
}
}
+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;
}
@@ -28,6 +28,10 @@
}
}
p:has(+ p) {
margin: 0;
}
ol, ul {
padding-inline-start: 3ch;
}
+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
@@ -440,6 +440,7 @@
.card__meta-text:not(.card__meta-text--updated),
.card__stages,
.card__steps,
.card__comments,
.card__closed {
display: none;
}
+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
+3 -32
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
@@ -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_path(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 -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
+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,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"
@@ -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
}
}
}
+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
+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
-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
+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
@@ -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>
+13 -8
View File
@@ -1,11 +1,15 @@
<% postponed_count = board.cards.postponed.size %>
<% awaiting_triage_count = board.cards.awaiting_triage.size %>
<% closed_count = board.cards.closed.size %>
<section class="mobile-card-columns">
<div class="cards margin-block-end-half">
<%= render "columns/show/add_card_button", board: board %>
</div>
<%= link_to board_columns_not_now_path(board), class: "cards cards--on-deck is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= board.cards.postponed.count %>">
<span class="cards__expander-count"><%= board.cards.postponed.count %></span>
<div class="cards__expander btn btn--plain" style="--card-count: <%= postponed_count %>">
<span class="cards__expander-count"><%= postponed_count %></span>
<h2 class="cards__expander-title">
Not Now
</h2>
@@ -13,8 +17,8 @@
<% end %>
<%= link_to board_columns_stream_path(board), class: "cards cards--considering is-collapsed", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= board.cards.awaiting_triage.count %>">
<span class="cards__expander-count"><%= board.cards.awaiting_triage.count %></span>
<div class="cards__expander btn btn--plain" style="--card-count: <%= awaiting_triage_count %>">
<span class="cards__expander-count"><%= awaiting_triage_count %></span>
<h2 class="cards__expander-title">
Maybe?
</h2>
@@ -22,9 +26,10 @@
<% end %>
<% board.columns.sorted.each do |column| %>
<% active_count = column.cards.active.size %>
<%= link_to board_column_path(column.board, column), class: "cards cards--doing is-collapsed", style: "--card-color: #{column.color}", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= column.cards.active.count %>">
<span class="cards__expander-count"><%= column.cards.active.count %></span>
<div class="cards__expander btn btn--plain" style="--card-count: <%= active_count %>">
<span class="cards__expander-count"><%= active_count %></span>
<h2 class="cards__expander-title">
<%= column.name %>
</h2>
@@ -33,8 +38,8 @@
<% end %>
<%= link_to board_columns_closed_path(board), class: "cards cards--closed is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= board.cards.closed.count %>">
<span class="cards__expander-count"><%= board.cards.closed.count %></span>
<div class="cards__expander btn btn--plain" style="--card-count: <%= closed_count %>">
<span class="cards__expander-count"><%= closed_count %></span>
<h2 class="cards__expander-title">
Done
</h2>
+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 %>
+2 -2
View File
@@ -1,8 +1,8 @@
<% cache comment do %>
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= turbo_frame_tag comment, :container do %>
<%= 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 <%= "comment--system" if comment.creator.system? %>">
<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>
@@ -1,5 +1,5 @@
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<div class="flex gap-half">
<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" } },
+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>
@@ -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 %>
+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>
+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 -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" } %>
-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
+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 -3
View File
@@ -21,6 +21,17 @@ Rails.application.configure do
}
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
@@ -76,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 ]
+1 -1
View File
@@ -4,7 +4,7 @@ pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@rails/request.js", to: "rails-request.js" # @0.0.11
pin "@rails/request.js", to: "@rails--request.js" # @0.0.13
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/helpers", under: "helpers"
+2
View File
@@ -1,3 +1,5 @@
raise LoadError, "Please install libvips" unless defined?(Vips::LIBRARY_VERSION)
# Disable Openslide to prevent sqlite segfault in forked parallel workers
# Requires libvips 8.13+
Vips.block "VipsForeignLoadOpenslide", true if Vips.respond_to?(:block)
+136 -1
View File
@@ -5,6 +5,13 @@ a bot to perform various actions for you.
## Authentication
There are two ways to authenticate with the Fizzy API:
1. **Personal access tokens** - Long-lived tokens for scripts and integrations
2. **Magic link authentication** - Session-based authentication for native apps
### Personal Access Tokens
To use the API you'll need an access token. To get one, go to your profile, then,
in the API section, click on "Personal access tokens" and then click on
"Generate new access token".
@@ -36,6 +43,81 @@ To authenticate a request using your access token, include it in the `Authorizat
curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/my/identity
```
### Magic Link Authentication
For native apps, you can authenticate users via magic links. This is a two-step process:
#### 1. Request a magic link
Send the user's email address to request a magic link be sent to them:
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email_address": "user@example.com"}' \
https://app.fizzy.do/session
```
__Response:__
```
HTTP/1.1 201 Created
Set-Cookie: pending_authentication_token=...; HttpOnly; SameSite=Lax
```
```json
{
"pending_authentication_token": "eyJfcmFpbHMi..."
}
```
The response includes a `pending_authentication_token` both in the JSON body and as a cookie.
Native apps should store this token and include it as a cookie when submitting the magic link code.
__Error responses:__
| Status Code | Description |
|--------|-------------|
| `422 Unprocessable entity` | Invalid email address, if sign ups are enabled and the value isn't a valid email address |
| `429 Too Many Requests` | Rate limit exceeded |
#### 2. Submit the magic link code
Once the user receives the magic link email, they'll have a 6-character code. Submit it to complete authentication:
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "Cookie: pending_authentication_token=eyJfcmFpbHMi..." \
-d '{"code": "ABC123"}' \
https://app.fizzy.do/session/magic_link
```
__Response:__
```json
{
"session_token": "eyJfcmFpbHMi..."
}
```
The `session_token` can be used to authenticate subsequent requests by including it as a cookie:
```bash
curl -H "Cookie: session_token=eyJfcmFpbHMi..." \
-H "Accept: application/json" \
https://app.fizzy.do/my/identity
```
__Error responses:__
| Status Code | Description |
|--------|-------------|
| `401 Unauthorized` | Invalid `pending_authentication_token` or `code` |
| `429 Too Many Requests` | Rate limit exceeded |
## Caching
Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data.
@@ -482,7 +564,60 @@ Returns a specific card by its number.
__Response:__
Same as the card object in the list response.
```json
{
"id": "03f5vaeq985jlvwv3arl4srq2",
"number": 1,
"title": "First!",
"status": "published",
"description": "Hello, World!",
"description_html": "<div class=\"action-text-content\"><p>Hello, World!</p></div>",
"image_url": null,
"tags": ["programming"],
"golden": false,
"last_active_at": "2025-12-05T19:38:48.553Z",
"created_at": "2025-12-05T19:38:48.540Z",
"url": "http://fizzy.localhost:3006/897362094/cards/4",
"board": {
"id": "03f5v9zkft4hj9qq0lsn9ohcm",
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
"name": "David Heinemeier Hansson",
"role": "owner",
"active": true,
"email_address": "david@example.com",
"created_at": "2025-12-05T19:36:35.401Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
}
},
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
"name": "David Heinemeier Hansson",
"role": "owner",
"active": true,
"email_address": "david@example.com",
"created_at": "2025-12-05T19:36:35.401Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
},
"comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments",
"steps": [
{
"id": "03f8huu0sog76g3s975963b5e",
"content": "This is the first step",
"completed": false
},
{
"id": "03f8huu0sog76g3s975969734",
"content": "This is the second step",
"completed": false
}
]
}
```
### `POST /:account_slug/boards/:board_id/cards`
+1 -1
View File
@@ -74,7 +74,7 @@ Under the hood, this will create or remove `tmp/email-dev.txt`.
## SaaS gem
37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup.
37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy/tree/main/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.
+11 -1
View File
@@ -90,6 +90,15 @@ Less commonly, you might also need to set some of the following:
You can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration).
#### Base URL
Fizzy needs to know the public URL of your instance so it can generate correct links in certain situations (like when sending emails).
Set `BASE_URL` to the full URL where your Fizzy instance is accessible:
```sh
docker run --environment BASE_URL=https://fizzy.example.com ...
```
#### VAPID keys
Fizzy can also send Web Push notifications.
@@ -114,7 +123,7 @@ Set those in the `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` environment variable
#### S3 storage (optional)
If you're rather that uploaded files were stored in an S3 bucket, rather than your mounted volume, you can set that up.
If you'd prefer that uploaded files were stored in an S3 bucket rather than in your mounted volume, you can set that up.
First set `ACTIVE_STORAGE_SERVICE` to `s3`.
Then set the following as appropriate for your S3 bucket:
@@ -153,6 +162,7 @@ services:
environment:
- SECRET_KEY_BASE=abcdefabcdef
- TLS_DOMAIN=fizzy.example.com
- BASE_URL=https://fizzy.example.com
- MAILER_FROM_ADDRESS=fizzy@example.com
- SMTP_ADDRESS=mail.example.com
- SMTP_USERNAME=user
+1
View File
@@ -33,6 +33,7 @@ We've added comments to that file to highlight what each setting needs to be, bu
- `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/BASE_URL`: The public URL of your Fizzy instance (e.g., `https://fizzy.example.com`). Used when generating links.
- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.
- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this.
- `env/clear/MULTI_TENANT`: Set to `true` if you want to allow multiple accounts to sign up on your server (by default, Fizzy will allow you to create a single account).
+15 -13
View File
@@ -4,32 +4,34 @@ module ActiveRecord
class Uuid < Binary
BASE36_LENGTH = 25 # 36^25 > 2^128
def self.generate
uuid = SecureRandom.uuid_v7
hex = uuid.delete("-")
normalize_base36(hex.to_i(16))
end
class << self
def generate
uuid = SecureRandom.uuid_v7
hex = uuid.delete("-")
hex_to_base36(hex)
end
def self.normalize_base36(integer)
integer.to_s(36).rjust(BASE36_LENGTH, "0")
def hex_to_base36(hex)
hex.to_i(16).to_s(36).rjust(BASE36_LENGTH, "0")
end
def base36_to_hex(base36)
base36.to_s.to_i(36).to_s(16).rjust(32, "0")
end
end
def serialize(value)
return unless value
binary = hex(value).scan(/../).map(&:hex).pack("C*")
binary = Uuid.base36_to_hex(value).scan(/../).map(&:hex).pack("C*")
super(binary)
end
def hex(value)
value.to_s.to_i(36).to_s(16).rjust(32, "0")
end
def deserialize(value)
return unless value
hex = value.to_s.unpack1("H*")
Uuid.normalize_base36(hex.to_i(16))
Uuid.hex_to_base36(hex)
end
def cast(value)
+2 -2
View File
@@ -11,7 +11,7 @@ if [[ $CURRENT_BRANCH == "main" && $KAMAL_DESTINATION == "production" ]]; then
RELEASE_URL=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .url)
RELEASE_BODY=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .body)
bin/broadcast_to_bc "$MESSAGE "$'\n'"$RELEASE_URL "$'\n'"$RELEASE_BODY"
saas/bin/broadcast_to_bc "$MESSAGE "$'\n'"$RELEASE_URL "$'\n'"$RELEASE_BODY"
else
bin/broadcast_to_bc "$MESSAGE"
saas/bin/broadcast_to_bc "$MESSAGE"
fi
+2 -1
View File
@@ -1,4 +1,4 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET)
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)
STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS)
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
+2 -1
View File
@@ -1,4 +1,4 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET)
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)
STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS)
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
+2 -1
View File
@@ -1,4 +1,4 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET)
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)
STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS)
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
+13 -7
View File
@@ -23,30 +23,36 @@
color: var(--settings-subscription-text-color);
&::before {
content: "————";
border-block-start: 1px solid var(--settings-subscription-text-color);
content: "";
display: block;
margin: auto;
text-align: center;
inline-size: 8ch;
margin: 0 auto 1ch;
}
}
.settings-subscription__link {
color: var(--settings-subscription-text-color);
}
.settings-subscription__notch {
animation: wiggle 500ms ease;
background: var(--settings-subscription-background);
box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color);
border-radius: 3em;
box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color);
color: var(--settings-subscription-text-color);
margin-inline: auto;
padding: 0 0.3em 0 1.2em;
transform: rotate(-1deg);
@media (max-width: 640px) {
padding-block: 0.6em;
padding-inline-start: 0.3em;
border-radius: 1ch;
padding-block: 1ch;
padding-inline: 2ch;
}
.btn {
margin-block: 0.3em;
/* margin-block: 0.3em; */
}
}
@@ -0,0 +1,12 @@
class Account::Subscriptions::DowngradesController < Account::Subscriptions::UpdatePlanController
before_action :ensure_downgradeable
private
def target_plan
Plan.paid
end
def ensure_downgradeable
head :bad_request unless subscription.plan == Plan.paid_with_extra_storage
end
end
@@ -0,0 +1,32 @@
class Account::Subscriptions::UpdatePlanController < ApplicationController
before_action :ensure_admin
def create
portal_session = Stripe::BillingPortal::Session.create(
customer: subscription.stripe_customer_id,
return_url: account_settings_url(anchor: "subscription"),
flow_data: {
type: "subscription_update_confirm",
subscription_update_confirm: {
subscription: subscription.stripe_subscription_id,
items: [ { id: stripe_subscription_item_id, price: target_plan.stripe_price_id } ]
}
}
)
redirect_to portal_session.url, allow_other_host: true
end
private
def target_plan
raise NotImplementedError
end
def subscription
@subscription ||= Current.account.subscription
end
def stripe_subscription_item_id
Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id
end
end
@@ -0,0 +1,12 @@
class Account::Subscriptions::UpgradesController < Account::Subscriptions::UpdatePlanController
before_action :ensure_upgradeable
private
def target_plan
Plan.paid_with_extra_storage
end
def ensure_upgradeable
head :bad_request unless subscription.plan == Plan.paid
end
end
@@ -9,10 +9,10 @@ class Account::SubscriptionsController < ApplicationController
session = Stripe::Checkout::Session.create \
customer: find_or_create_stripe_customer,
mode: "subscription",
line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ],
line_items: [ { price: plan_param.stripe_price_id, quantity: 1 } ],
success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: account_subscription_url,
metadata: { account_id: Current.account.id, plan_key: Plan.paid.key },
metadata: { account_id: Current.account.id, plan_key: plan_param.key },
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: "required",
@@ -22,6 +22,10 @@ class Account::SubscriptionsController < ApplicationController
end
private
def plan_param
@plan_param ||= Plan[params[:plan_key]] || Plan.paid
end
def set_stripe_session
@stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id]
end
@@ -36,7 +40,7 @@ class Account::SubscriptionsController < ApplicationController
def create_stripe_customer
Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer|
Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: Plan.paid.key, status: "incomplete")
Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: plan_param.key, status: "incomplete")
end
end
end
@@ -12,7 +12,7 @@ class Admin::AccountsController < AdminController
end
def update
@account.override_limits(card_count: overridden_card_count_param)
@account.override_limits(**overridden_limits_params.to_h.symbolize_keys)
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated"
end
@@ -21,7 +21,7 @@ class Admin::AccountsController < AdminController
@account = Account.find_by!(external_account_id: params[:id])
end
def overridden_card_count_param
params[:account][:overridden_card_count].to_i
def overridden_limits_params
params.expect(account: [ :card_count, :bytes_used ])
end
end
@@ -0,0 +1,8 @@
module Card::Limited
extend ActiveSupport::Concern
private
def ensure_under_limits
head :forbidden if Current.account.exceeding_limits?
end
end
@@ -2,13 +2,10 @@ module Card::LimitedCreation
extend ActiveSupport::Concern
included do
include Card::Limited
# Only limit API requests. We let you create drafts in the app to actually show the banner, no matter the card count.
# We limit card publications separately. See +Card::LimitedPublishing+.
before_action :ensure_can_create_cards, only: %i[ create ], if: -> { request.format.json? }
before_action :ensure_under_limits, only: %i[ create ], if: -> { request.format.json? }
end
private
def ensure_can_create_cards
head :forbidden if Current.account.exceeding_card_limit?
end
end
@@ -2,11 +2,8 @@ module Card::LimitedPublishing
extend ActiveSupport::Concern
included do
before_action :ensure_can_publish_cards, only: %i[ create ]
end
include Card::Limited
private
def ensure_can_publish_cards
head :forbidden if Current.account.exceeding_card_limit?
end
before_action :ensure_under_limits, only: %i[ create ]
end
end
@@ -49,7 +49,8 @@ class Stripe::WebhooksController < ApplicationController
status: stripe_subscription.status,
current_period_end: current_period_end_for(stripe_subscription),
cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil,
next_amount_due_in_cents: next_amount_due_for(stripe_subscription)
next_amount_due_in_cents: next_amount_due_for(stripe_subscription),
plan_key: plan_key_for(stripe_subscription)
}
yield subscription_properties if block_given?
@@ -76,4 +77,9 @@ class Stripe::WebhooksController < ApplicationController
rescue Stripe::InvalidRequestError
nil
end
def plan_key_for(stripe_subscription)
price_id = stripe_subscription.items.data.first&.price&.id
Plan.find_by_price_id(price_id)&.key
end
end
+3 -3
View File
@@ -1,6 +1,6 @@
module SubscriptionsHelper
def plan_storage_limit(plan)
number_to_human_size(plan.storage_limit).delete(" ")
def storage_to_human_size(bytes)
number_to_human_size(bytes).delete(" ")
end
def subscription_period_end_action(subscription)
@@ -9,7 +9,7 @@ module SubscriptionsHelper
elsif subscription.canceled?
"Your Fizzy subscription ended on"
else
"Your next payment of <b>#{ number_to_currency(subscription.next_amount_due) }</b> will be billed on".html_safe
"Your next payment is <b>#{ number_to_currency(subscription.next_amount_due, strip_insignificant_zeros: true) }</b> on".html_safe
end
end
end
+1 -1
View File
@@ -2,6 +2,6 @@ class Account::BillingWaiver < SaasRecord
belongs_to :account
def subscription
@subscription ||= Account::Subscription.new(plan_key: Plan.paid.key)
@subscription ||= Account::Subscription.new(plan: Plan.paid_with_extra_storage)
end
end
+23 -2
View File
@@ -6,15 +6,20 @@ module Account::Limited
end
NEAR_CARD_LIMIT_THRESHOLD = 100
NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes
def override_limits(card_count:)
(overridden_limits || build_overridden_limits).update!(card_count:)
def override_limits(card_count: nil, bytes_used: nil)
(overridden_limits || build_overridden_limits).update!(card_count:, bytes_used:)
end
def billed_cards_count
overridden_limits&.card_count || cards_count
end
def billed_bytes_used
overridden_limits&.bytes_used || bytes_used
end
def nearing_plan_cards_limit?
plan.limit_cards? && remaining_cards_count < NEAR_CARD_LIMIT_THRESHOLD
end
@@ -23,6 +28,18 @@ module Account::Limited
plan.limit_cards? && billed_cards_count > plan.card_limit
end
def nearing_plan_storage_limit?
remaining_storage < NEAR_STORAGE_LIMIT_THRESHOLD
end
def exceeding_storage_limit?
billed_bytes_used > plan.storage_limit
end
def exceeding_limits?
exceeding_card_limit? || exceeding_storage_limit?
end
def reset_overridden_limits
overridden_limits&.destroy
reload_overridden_limits
@@ -32,4 +49,8 @@ module Account::Limited
def remaining_cards_count
plan.card_limit - billed_cards_count
end
def remaining_storage
plan.storage_limit - billed_bytes_used
end
end
+4
View File
@@ -11,6 +11,10 @@ class Account::Subscription < SaasRecord
@plan ||= Plan.find(plan_key)
end
def plan=(plan)
self.plan_key = plan.key
end
def to_be_canceled?
active? && cancel_at.present?
end
+11 -2
View File
@@ -1,7 +1,8 @@
class Plan
PLANS = {
free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes },
monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] }
monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] },
monthly_extra_storage_v1: { name: "Unlimited + Extra Storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] }
}
attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id
@@ -19,11 +20,19 @@ class Plan
@paid ||= find(:monthly_v1)
end
def paid_with_extra_storage
@paid_with_extra_storage ||= find(:monthly_extra_storage_v1)
end
def find(key)
@all_by_key ||= all.index_by(&:key).with_indifferent_access
@all_by_key[key]
end
def find_by_price_id(price_id)
all.find { |plan| plan.stripe_price_id == price_id }
end
alias [] find
end
@@ -45,6 +54,6 @@ class Plan
end
def limit_cards?
card_limit != Float::INFINITY
!card_limit.infinite?
end
end
@@ -0,0 +1,18 @@
<h3 class="margin-block-start txt-large font-weight-black txt-tight-lines">
<% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %>
Youve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards and all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> of free storage
<% elsif Current.account.exceeding_card_limit? %>
Youve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards
<% elsif Current.account.exceeding_storage_limit? %>
Youve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> free storage
<% else %>
Youve used <u><%= Current.account.billed_cards_count %></u> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>
<% end %>
</h3>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) %>/month</strong> for <strong>unlimited cards</strong>, <strong>unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<%= button_to "Upgrade to #{Plan.paid.name} for #{ number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<p>Cancel anytime, no contracts, take your data with you whenever.</p>
<p class="settings-subscription__footer txt-small margin-none-block-end">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes <%= number_with_delimiter(Plan.free.card_limit) %> cards, unlimited users, and <%= storage_to_human_size(Plan.free.storage_limit) %> of storage.</p>
@@ -0,0 +1,19 @@
<h3 class="margin-block-start txt-x-large font-weight-black txt-tight-lines">
<% if Current.account.exceeding_storage_limit? %>
Youve run out of storage
<% elsif Current.account.nearing_plan_storage_limit? %>
Youve used <strong><%= storage_to_human_size(Current.account.billed_bytes_used) %></strong> of storage
<% else %>
Thank you for buying Fizzy
<% end %>
</h3>
<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %>
<p class="margin-block-start-half">
The <strong><%= Current.account.plan.name %></strong> plan includes <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong>. Upgrade to get <strong><%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %></strong> extra storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more.
</p>
<% end %>
<% if Current.account.subscription %>
<%= render "account/settings/subscription", subscription: Current.account.subscription %>
<% end %>
@@ -1,7 +1,20 @@
<p hidden>Status: <strong><%= subscription.status.titleize %></strong></p>
<% if subscription.current_period_end %>
<p class="txt-medium margin-none-block-start"><%= subscription_period_end_action(subscription) %> <strong><%= subscription.current_period_end.to_date.to_fs(:long) %></strong></p>
<p class="margin-block-start-half"><%= subscription_period_end_action(subscription) %> <strong><%= subscription.current_period_end.to_date.to_fs(:long) %></strong></p>
<% end %>
<%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %>
<p>
<%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %>
</p>
<div class="settings-subscription__footer txt-small margin-block">
Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes unlimited cards, unlimited users, and <%= storage_to_human_size(Current.account.plan.storage_limit) %> of storage.
<% if subscription.plan == Plan.paid %>
<%= button_to "Upgrade", account_subscription_upgrade_path, class: "btn btn--plain settings-subscription__link txt-link", form: { class: "flex-inline flex-wrap", data: { turbo: false } } %>
to <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more.
<% elsif subscription.plan == Plan.paid_with_extra_storage && !Current.account.exceeding_storage_limit? %>
<%= button_to "Downgrade", account_subscription_downgrade_path, class: "btn btn--plain settings-subscription__link txt-link", form: { class: "flex-inline flex-wrap", data: { turbo: false } } %>
to <%= storage_to_human_size(Plan.paid.storage_limit) %> storage.
<% end %>
</div>
@@ -3,26 +3,9 @@
<h2 id="subscription" class="divider settings-subscription__divider txt-small txt-uppercase margin-none">Subscription</h2>
<% if Current.account.plan.free? %>
<% if Current.account.exceeding_card_limit? %>
<h3 class="margin-block-start margin-block-end-half txt-large font-weight-black">Youve used up your <u><%= Plan.free.card_limit %></u> free cards</h3>
<% else %>
<h3 class="margin-block-start margin-block-end-half txt-large font-weight-black">Youve used <u><%= Current.account.billed_cards_count %></u> free cards out of <%= Plan.free.card_limit %></h3>
<% end %>
<p class="margin-none-block-start">If youd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, its only <strong>$<%= Plan.paid.price %>/month</strong> for <strong>unlimited cards</strong> + <strong>unlimited users</strong>. You'll also get <strong><%= plan_storage_limit(Plan.paid) %></strong> of storage. </p>
<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<p>Cancel anytime, no contracts, take your data with you whenever.</p>
<p class="settings-subscription__footer txt-small margin-none-block-end">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes <%= Plan.free.card_limit %> cards, unlimited users, and <%= plan_storage_limit(Plan.free) %> of storage.</p>
<%= render "account/settings/free_plan" %>
<% else %>
<h3 class="margin-block-start margin-block-end-half txt-x-large font-weight-black">Thank you for buying Fizzy</h3>
<% if Current.account.subscription %>
<%= render "account/settings/subscription", subscription: Current.account.subscription %>
<p class="settings-subscription__footer txt-small">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes unlimited cards, unlimited users, and <%= plan_storage_limit(Plan.paid) %> of storage.</p>
<% end %>
<%= render "account/settings/paid_plan" %>
<% end %>
</section>
<% end %>
@@ -1,12 +1,9 @@
<div class="settings-subscription__notch card-perma__notch card-perma__notch--bottom">
<% if Current.user.admin? %>
<strong>Youve used your <u><%= Plan.free.card_limit %></u> free cards.</strong>
<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
<span>Upgrade to Unlimited</span>
<% end %>
<%= render "account/subscriptions/notices/admin_exceeding_card_limit" if Current.account.exceeding_card_limit? %>
<%= render "account/subscriptions/notices/admin_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %>
<% else %>
<div class="pad-inline pad-block-half">
<strong>This account has used <u><%= Plan.free.card_limit %></u> free cards. Upgrade to get more.</strong>
</div>
<%= render "account/subscriptions/notices/user_exceeding_card_limit" if Current.account.exceeding_card_limit? %>
<%= render "account/subscriptions/notices/user_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %>
<% end %>
</div>
@@ -0,0 +1,4 @@
<strong>Youve used your <u><%= Plan.free.card_limit %></u> free cards.</strong>
<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
<span>Upgrade to Unlimited</span>
<% end %>
@@ -0,0 +1,4 @@
<strong>Youve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>.</strong>
<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
<span>Upgrade to get more</span>
<% end %>
@@ -0,0 +1,3 @@
<div class="pad-inline pad-block-half">
<strong>This account has used <u><%= Plan.free.card_limit %></u> free cards. Upgrade to get more.</strong>
</div>
@@ -0,0 +1,3 @@
<div class="pad-inline pad-block-half">
<strong>This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more.</strong>
</div>
+12 -5
View File
@@ -11,14 +11,21 @@
<dt class="font-weight-bold">Actual card count:</dt>
<dd class="margin-inline-start-none"><%= @account.cards_count %></dd>
</div>
<div class="flex gap-half">
<dt class="font-weight-bold">Actual bytes used:</dt>
<dd class="margin-inline-start-none"><%= storage_to_human_size(@account.bytes_used) %></dd>
</div>
</dl>
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %>
<div class="flex flex-column gap-half">
<%= form.label :overridden_card_count, "Override card count", class: "font-weight-bold" %>
<div class="flex align-center gap input input--actor">
<%= form.number_field :overridden_card_count, value: @account.overridden_limits&.card_count, class: "input full-width" %>
</div>
<div class="flex align-center gap-half">
<%= form.label :card_count, "Override card count", class: "font-weight-bold" %>
<%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %>
</div>
<div class="flex align-center gap-half">
<%= form.label :bytes_used, "Override bytes used", class: "font-weight-bold" %>
<%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
</div>
<button type="submit" class="btn btn--reversed">Save</button>
@@ -1,4 +1,4 @@
<% if Current.account.exceeding_card_limit? %>
<% if Current.account.exceeding_limits? %>
<%= render "account/subscriptions/upgrade" %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
@@ -1,9 +1,9 @@
<% if Current.account.nearing_plan_cards_limit? %>
<div class="settings_subscription__warning full-width pad-inline center margin-block-half">
<% if Current.user.admin? %>
Youve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong><%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %></strong>.
<% else %>
This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong>Upgrade soon</strong>
<% end %>
</div>
<% end %>
<div class="settings_subscription__warning full-width pad-inline center margin-block-half">
<% if Current.user.admin? %>
<%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %>
<%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %>
<% else %>
<%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %>
<%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %>
<% end %>
</div>
@@ -0,0 +1 @@
Youve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong><%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %></strong>.
@@ -0,0 +1 @@
Youve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. <strong><%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %></strong>.
@@ -0,0 +1 @@
This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon.
@@ -0,0 +1 @@
This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade soon.
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
url=$(op read "op://Deploy/Deploy Chatbot/url" --account Basecamp)
curl -s -d content="[Fizzy] ${1}" "${url}"
+1
View File
@@ -45,6 +45,7 @@ env:
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
+1
View File
@@ -51,6 +51,7 @@ env:
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
+1
View File
@@ -51,6 +51,7 @@ env:
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
+3
View File
@@ -1,6 +1,9 @@
Rails.application.configure do
config.active_storage.service = :purestorage
# Enable structured logging, suppress unstructured log lines
config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT)
config.log_level = :fatal
config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" }
config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" }
@@ -0,0 +1,5 @@
class AddBytesUsedToAccountOverriddenLimits < ActiveRecord::Migration[8.2]
def change
add_column :account_overridden_limits, :bytes_used, :bigint
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do
ActiveRecord::Schema[8.2].define(version: 2025_12_16_000000) do
create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
@@ -20,6 +20,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do
create_table "account_overridden_limits", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.bigint "bytes_used"
t.integer "card_count"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+4 -1
View File
@@ -30,13 +30,15 @@ secrets_escaped = `kamal secrets fetch \
--account basecamp \
--from "Deploy/Fizzy" \
"Development/STRIPE_SECRET_KEY" \
"Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null`
"Development/STRIPE_MONTHLY_V1_PRICE_ID" \
"Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID" 2>/dev/null`
secrets_json = secrets_escaped.gsub(/\\(.)/, '\1')
secrets = JSON.parse(secrets_json)
stripe_secret_key = secrets["Deploy/Fizzy/Development/STRIPE_SECRET_KEY"]
stripe_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_V1_PRICE_ID"]
stripe_extra_storage_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"]
# Clear previous log file
File.write(LOG_FILE, "")
@@ -72,6 +74,7 @@ end
# Output export statements
puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}")
puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}")
puts %Q(export STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID="#{stripe_extra_storage_price_id}")
puts %Q(export STRIPE_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret
# Informational message to stderr (won't be eval'd)
+7 -2
View File
@@ -10,7 +10,7 @@ module Fizzy
Queenbee.host_app = Fizzy
initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app|
app.config.x.content_security_policy.form_action = "https://checkout.stripe.com"
app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com"
end
initializer "fizzy_saas.assets" do |app|
@@ -22,7 +22,12 @@ module Fizzy
app.routes.prepend do
namespace :account do
resource :billing_portal, only: :show
resource :subscription
resource :subscription do
scope module: :subscriptions do
resource :upgrade, only: :create
resource :downgrade, only: :create
end
end
end
namespace :stripe do
@@ -0,0 +1,73 @@
require "test_helper"
class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :mike
end
# Nearing limits - shown in card creation footer
test "admin sees nearing card limit notice" do
accounts(:initech).update_column(:cards_count, 950)
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /upgrade to unlimited/i, response.body
end
test "admin sees nearing storage limit notice" do
Account.any_instance.stubs(:bytes_used).returns(600.megabytes)
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /upgrade to get more/i, response.body
end
# Exceeding limits - shown instead of create buttons
test "admin sees exceeding card limit notice" do
accounts(:initech).update_column(:cards_count, 1001)
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /youve used your.*free cards/i, response.body
end
test "admin sees exceeding storage limit notice" do
Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /youve run out of.*free storage/i, response.body
end
# Paid accounts under limits - no notices
test "paid account under limits sees no notices" do
logout_and_sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get card_path(cards(:layout), script_name: accounts(:"37s").slug)
assert_response :success
assert_no_match /upgrade/i, response.body
assert_no_match /youve used your/i, response.body
end
# Comped accounts under limits - no notices
test "comped account under limits sees no notices" do
accounts(:initech).comp
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_no_match /upgrade/i, response.body
assert_no_match /youve used your/i, response.body
end
end
@@ -0,0 +1,35 @@
require "test_helper"
require "ostruct"
class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid_with_extra_storage)
end
test "downgrade redirects to stripe billing portal" do
stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]))
portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123")
Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription)
Stripe::BillingPortal::Session.stubs(:create).returns(portal_session)
post account_subscription_downgrade_path
assert_redirected_to "https://billing.stripe.com/session/abc123"
end
test "downgrade requires admin" do
logout_and_sign_in_as :david
post account_subscription_downgrade_path
assert_response :forbidden
end
test "downgrade requires downgradeable plan" do
accounts(:"37s").subscription.update!(plan: Plan.paid)
post account_subscription_downgrade_path
assert_response :bad_request
end
end
@@ -0,0 +1,62 @@
require "test_helper"
class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest
test "free users see current usage" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 3)
get account_settings_path(script_name: accounts(:initech).slug)
assert_response :success
assert_match /Youve used.*3.*free cards out of 1,000/i, response.body
end
test "paid users see thank you message" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "h3", text: "Thank you for buying Fizzy"
end
test "regular plan users see upgrade option" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "button", text: /upgrade/i
assert_select "button", text: /downgrade/i, count: 0
end
test "extra storage plan users see downgrade option" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "button", text: /downgrade/i
end
test "comped accounts see no subscription panel" do
sign_in_as :mike
accounts(:initech).comp
get account_settings_path(script_name: accounts(:initech).slug)
assert_response :success
assert_no_match /thank you for buying/i, response.body
assert_no_match /free cards out of/i, response.body
assert_no_match /upgrade/i, response.body
assert_no_match /downgrade/i, response.body
end
end
@@ -0,0 +1,35 @@
require "test_helper"
require "ostruct"
class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid)
end
test "upgrade redirects to stripe billing portal" do
stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]))
portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123")
Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription)
Stripe::BillingPortal::Session.stubs(:create).returns(portal_session)
post account_subscription_upgrade_path
assert_redirected_to "https://billing.stripe.com/session/abc123"
end
test "upgrade requires admin" do
logout_and_sign_in_as :david
post account_subscription_upgrade_path
assert_response :forbidden
end
test "upgrade requires upgradeable plan" do
accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage)
post account_subscription_upgrade_path
assert_response :bad_request
end
end

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