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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,7 @@
|
||||
.card__meta-text:not(.card__meta-text--updated),
|
||||
.card__stages,
|
||||
.card__steps,
|
||||
.card__comments,
|
||||
.card__closed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,6 +61,6 @@ class CardsController < ApplicationController
|
||||
end
|
||||
|
||||
def card_params
|
||||
params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ])
|
||||
params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</header>
|
||||
|
||||
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %>
|
||||
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large",
|
||||
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase",
|
||||
autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true,
|
||||
autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code],
|
||||
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %>
|
||||
|
||||
@@ -1,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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? %>
|
||||
You’ve 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? %>
|
||||
You’ve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards
|
||||
<% elsif Current.account.exceeding_storage_limit? %>
|
||||
You’ve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> free storage
|
||||
<% else %>
|
||||
You’ve 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? %> it’s 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 you’re 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? %>
|
||||
You’ve run out of storage
|
||||
<% elsif Current.account.nearing_plan_storage_limit? %>
|
||||
You’ve 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 you’re 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">You’ve 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">You’ve 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 you’d like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it’s 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 you’re 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 you’re 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>You’ve 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>You’ve 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>You’ve 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>
|
||||
@@ -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? %>
|
||||
You’ve 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 @@
|
||||
You’ve 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>.
|
||||
+1
@@ -0,0 +1 @@
|
||||
You’ve 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.
|
||||
+1
@@ -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.
|
||||
Executable
+4
@@ -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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 /you’ve 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 /you’ve 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 /you’ve 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 /you’ve 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 /You’ve 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
Reference in New Issue
Block a user