Merge branch 'main' into mobile/bridge-done-stamp

* main: (43 commits)
  Remove payment/subscription system and card/storage limits
  Conditionally disable peer verification for ZIP streaming
  Prevent HTML injection through filenames
  Revert "Configure Lexxy to add extra spacing between block elements"
  Configure Lexxy to add extra spacing between block elements
  SaaS usage reporting (#2690)
  Update app/models/user/named.rb
  Prevent line breaks in the middle of familiar_name
  Tweak the layout
  Set the list of importable models based on the record sets in the manifest
  Validate polymorphic types against importable models allowlist
  Fix that the "maybe" stream wouldn't be replaced after triaging a card
  Use x_user_agent cookie for platform detection in Hotwire Native
  Add JSON response format to entropy endpoints (#2673)
  Fix path traversal vulnerability in ActiveStorage blob key import
  Enable CORS fetch mode for storage requests
  Revert CORS fetch mode for Active Storage
  Use CORS fetch mode for Active Storage to enable `maxEntrySize` checks
  Only start offline mode when signed in
  Exclude service worker and edit/pin/watch/new pages from main cache
  ...
This commit is contained in:
Adrien Maston
2026-03-17 15:27:29 +01:00
194 changed files with 2150 additions and 2313 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
- name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@v3.2.0
uses: actions/attest-build-provenance@v4.1.0
with:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
+1 -1
View File
@@ -8,7 +8,7 @@ gem "rails", github: "rails/rails", branch: "main"
gem "importmap-rails"
gem "propshaft"
gem "stimulus-rails"
gem "turbo-rails"
gem "turbo-rails", github: "hotwired/turbo-rails", branch: "offline-cache"
# Deployment and drivers
gem "bootsnap", require: false
+11 -5
View File
@@ -11,6 +11,15 @@ GIT
specs:
useragent (0.16.11)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -158,7 +167,7 @@ GEM
bindex (0.8.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (8.0.2)
brakeman (8.0.4)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -461,9 +470,6 @@ GEM
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -539,7 +545,7 @@ DEPENDENCIES
stimulus-rails
thruster
trilogy (~> 2.10)
turbo-rails
turbo-rails!
useragent!
vcr
web-console!
-1
View File
@@ -5,7 +5,6 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
gem "activeresource", require: "active_resource"
gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing
gem "stripe", "~> 18.0"
gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
+11 -7
View File
@@ -60,6 +60,15 @@ GIT
rails (>= 6.1)
yabeda (~> 0.6)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -248,7 +257,7 @@ GEM
bindex (0.8.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (8.0.2)
brakeman (8.0.4)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -622,7 +631,6 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
stripe (18.0.1)
thor (1.5.0)
thruster (0.1.18)
thruster (0.1.18-aarch64-linux)
@@ -633,9 +641,6 @@ GEM
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -749,10 +754,9 @@ DEPENDENCIES
sqlite3 (>= 2.0)
stackprof
stimulus-rails
stripe (~> 18.0)
thruster
trilogy (~> 2.10)
turbo-rails
turbo-rails!
useragent!
vcr
web-console!
+60 -17
View File
@@ -146,37 +146,74 @@
@media (max-width: 639px) {
--meta-spacer-block: 0.75ch;
inline-size: 100%;
grid-template-areas:
"avatars-author text-added"
"avatars-author text-author"
"text-updated text-updated"
"text-assignees text-assignees"
"avatars-assignees avatars-assignees";
grid-template-columns: auto 1fr;
min-inline-size: 0;
gap: calc(var(--meta-spacer-block) / 2);
display: flex;
flex-wrap: wrap;
.card__meta-text {
border: 0;
padding: 0;
}
.card__meta-text + .card__meta-text {
.card__meta-avatars--author {
--btn-size: 1.5em;
display: initial;
margin-inline-end: unset;
order: 3;
}
.card__meta-text--added {
inline-size: 100%;
order: 1;
}
.card__meta-text--author {
order: 2;
}
.card__meta-text--updated {
border-block-start: var(--card-border);
margin-block-start: var(--meta-spacer-block);
inline-size: 100%;
margin-block-start: calc(var(--meta-spacer-block) * 0.5);
order: 4;
padding-block-start: var(--meta-spacer-block);
}
.card__meta-text--assignees {
margin-block-start: calc(var(--meta-spacer-block) * 3);
order: 6;
white-space: unset !important;
}
.card__meta-avatars--assignees {
margin-inline: 0;
margin-block-start: var(--meta-spacer-block);
margin-inline: 0 var(--meta-spacer-inline);
margin-block-start: calc(var(--meta-spacer-block) * 3);
order: 5;
.avatar {
display: grid;
}
}
.card__meta-avatars--author {
display: initial;
}
&:has(.card__meta-avatars--assignees .avatar) {
.card__meta-text--assignees {
order: 5;
}
.card__meta-avatars--assignees .avatar {
display: grid;
.card__meta-avatars--assignees {
margin-block-start: var(--meta-spacer-block);
order: 6;
}
}
}
}
&:has(.card__closed) .card__meta {
@media (max-width: 639px) {
.card__meta-avatars--assignees {
display: none;
}
}
}
@@ -241,10 +278,16 @@
@media (max-width: 639px) {
display: grid;
font-size: var(--text-x-small);
gap: 1ch 0;
grid-template-columns: 1fr auto;
grid-template-areas:
"meta bg-zoom"
"meta reactions";
&:not(:has(.reaction)),
&:has(.card__background) {
column-gap: 2ch;
}
}
}
@@ -1,13 +1,22 @@
class Account::EntropiesController < ApplicationController
wrap_parameters :entropy, include: [ :auto_postpone_period_in_days ]
before_action :ensure_admin
def update
Current.account.entropy.update!(entropy_params)
redirect_to account_settings_path, notice: "Account updated"
@account = Current.account
@account.entropy.update!(entropy_params)
respond_to do |format|
format.html { redirect_to account_settings_path, notice: "Account updated" }
format.json { render "account/settings/show", status: :ok }
end
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
end
private
def entropy_params
params.expect(entropy: [ :auto_postpone_period ])
params.expect(entropy: [ :auto_postpone_period_in_days ])
end
end
+13 -3
View File
@@ -6,11 +6,20 @@ class Account::ExportsController < ApplicationController
CURRENT_EXPORT_LIMIT = 10
def show
respond_to do |format|
format.html
format.json { @export ? render(:show) : head(:not_found) }
end
end
def create
Current.account.exports.create!(user: Current.user).build_later
redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready."
@export = Current.account.exports.create!(user: Current.user)
@export.build_later
respond_to do |format|
format.html { redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." }
format.json { render :show, status: :created }
end
end
private
@@ -23,6 +32,7 @@ class Account::ExportsController < ApplicationController
end
def set_export
@export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user)
scope = request.format.json? ? Current.account.exports : Current.account.exports.completed
@export = scope.find_by(id: params[:id], user: Current.user)
end
end
@@ -1,4 +1,6 @@
class Account::JoinCodesController < ApplicationController
wrap_parameters :account_join_code, include: %i[ usage_limit ]
before_action :set_join_code
before_action :ensure_admin, only: %i[ update destroy ]
@@ -10,15 +12,25 @@ class Account::JoinCodesController < ApplicationController
def update
if @join_code.update(join_code_params)
redirect_to account_join_code_path
respond_to do |format|
format.html { redirect_to account_join_code_path }
format.json { head :no_content }
end
else
render :edit, status: :unprocessable_entity
respond_to do |format|
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @join_code.errors, status: :unprocessable_entity }
end
end
end
def destroy
@join_code.reset
redirect_to account_join_code_path
respond_to do |format|
format.html { redirect_to account_join_code_path }
format.json { head :no_content }
end
end
private
+11 -2
View File
@@ -1,14 +1,23 @@
class Account::SettingsController < ApplicationController
wrap_parameters :account, include: %i[ name ]
before_action :ensure_admin, only: :update
before_action :set_account
def show
@users = @account.users.active.alphabetically.includes(:identity)
respond_to do |format|
format.html { @users = @account.users.active.alphabetically.includes(:identity) }
format.json
end
end
def update
@account.update!(account_params)
redirect_to account_settings_path
respond_to do |format|
format.html { redirect_to account_settings_path }
format.json { head :no_content }
end
end
private
+3 -1
View File
@@ -1,4 +1,6 @@
class Boards::ColumnsController < ApplicationController
wrap_parameters :column, include: %i[ name color ]
include BoardScoped
before_action :set_column, only: %i[ show update destroy ]
@@ -18,7 +20,7 @@ class Boards::ColumnsController < ApplicationController
respond_to do |format|
format.turbo_stream
format.json { head :created, location: board_column_path(@board, @column, format: :json) }
format.json { render :show, status: :created, location: board_column_path(@board, @column, format: :json) }
end
end
+10 -1
View File
@@ -1,14 +1,23 @@
class Boards::EntropiesController < ApplicationController
wrap_parameters :board, include: [ :auto_postpone_period_in_days ]
include BoardScoped
before_action :ensure_permission_to_admin_board
def update
@board.update!(entropy_params)
respond_to do |format|
format.turbo_stream
format.json { render "boards/show", status: :ok }
end
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
end
private
def entropy_params
params.expect(board: [ :auto_postpone_period ])
params.expect(board: [ :auto_postpone_period_in_days ])
end
end
@@ -3,5 +3,11 @@ class Boards::InvolvementsController < ApplicationController
def update
@board.access_for(Current.user).update!(involvement: params[:involvement])
respond_to do |format|
format.html
format.turbo_stream
format.json { head :no_content }
end
end
end
+4 -2
View File
@@ -1,4 +1,6 @@
class BoardsController < ApplicationController
wrap_parameters :board, include: %i[ name all_access auto_postpone_period_in_days public_description ]
include FilterScoped
before_action :set_board, except: %i[ index new create ]
@@ -26,7 +28,7 @@ class BoardsController < ApplicationController
respond_to do |format|
format.html { redirect_to board_path(@board) }
format.json { head :created, location: board_path(@board, format: :json) }
format.json { render :show, status: :created, location: board_path(@board, format: :json) }
end
end
@@ -88,7 +90,7 @@ class BoardsController < ApplicationController
end
def board_params
params.expect(board: [ :name, :all_access, :auto_postpone_period, :public_description ])
params.expect(board: [ :name, :all_access, :auto_postpone_period_in_days, :public_description ])
end
def grantees
@@ -1,4 +1,6 @@
class Cards::Comments::ReactionsController < ApplicationController
wrap_parameters :reaction, include: %i[ content ]
include CardScoped
before_action :set_comment
@@ -22,7 +24,7 @@ class Cards::Comments::ReactionsController < ApplicationController
respond_to do |format|
format.turbo_stream { render "reactions/create" }
format.json { head :created }
format.json { render "reactions/show", status: :created }
end
end
+2 -1
View File
@@ -1,4 +1,5 @@
class Cards::CommentsController < ApplicationController
wrap_parameters :comment, include: %i[ body created_at ]
include CardScoped
before_action :set_comment, only: %i[ show edit update destroy ]
@@ -14,7 +15,7 @@ class Cards::CommentsController < ApplicationController
respond_to do |format|
format.turbo_stream
format.json { head :created, location: card_comment_path(@card, @comment, format: :json) }
format.json { render :show, status: :created, location: card_comment_path(@card, @comment, format: :json) }
end
end
+11 -5
View File
@@ -4,11 +4,17 @@ class Cards::PublishesController < ApplicationController
def create
@card.publish
if add_another_param?
card = @board.cards.create!(status: :drafted)
redirect_to card_draft_path(card), notice: "Card added"
else
redirect_to @card.board
respond_to do |format|
format.html do
if add_another_param?
card = @board.cards.create!(status: :drafted)
redirect_to card_draft_path(card), notice: "Card added"
else
redirect_to @card.board
end
end
format.json { head :created }
end
end
@@ -1,4 +1,6 @@
class Cards::ReactionsController < ApplicationController
wrap_parameters :reaction, include: %i[ content ]
include CardScoped
before_action :set_reactable
@@ -21,7 +23,7 @@ class Cards::ReactionsController < ApplicationController
respond_to do |format|
format.turbo_stream { render "reactions/create" }
format.json { head :created }
format.json { render "reactions/show", status: :created }
end
end
@@ -4,11 +4,21 @@ class Cards::ReadingsController < ApplicationController
def create
@notification = @card.read_by(Current.user)
record_board_access
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end
def destroy
@notification = @card.unread_by(Current.user)
record_board_access
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
+7 -1
View File
@@ -1,14 +1,20 @@
class Cards::StepsController < ApplicationController
wrap_parameters :step, include: %i[ content completed ]
include CardScoped
before_action :set_step, only: %i[ show edit update destroy ]
def index
fresh_when etag: @card.steps
end
def create
@step = @card.steps.create!(step_params)
respond_to do |format|
format.turbo_stream
format.json { head :created, location: card_step_path(@card, @step, format: :json) }
format.json { render :show, status: :created, location: card_step_path(@card, @step, format: :json) }
end
end
+4 -2
View File
@@ -1,4 +1,6 @@
class CardsController < ApplicationController
wrap_parameters :card, include: %i[ title description image created_at last_active_at ]
include FilterScoped
before_action :set_board, only: %i[ create ]
@@ -18,8 +20,8 @@ class CardsController < ApplicationController
end
format.json do
card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
head :created, location: card_path(card, format: :json)
@card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
render :show, status: :created, location: card_path(@card, format: :json)
end
end
end
@@ -4,5 +4,10 @@ class Columns::LeftPositionsController < ApplicationController
def create
@left_column = @column.left_column
@column.move_left
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end
end
@@ -4,5 +4,10 @@ class Columns::RightPositionsController < ApplicationController
def create
@right_column = @column.right_column
@column.move_right
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end
end
+1 -1
View File
@@ -7,6 +7,6 @@ module SetPlatform
private
def platform
@platform ||= ApplicationPlatform.new(request.user_agent)
@platform ||= ApplicationPlatform.new(cookies[:x_user_agent].presence || request.user_agent)
end
end
+11 -2
View File
@@ -1,4 +1,8 @@
class My::AccessTokensController < ApplicationController
wrap_parameters :access_token, include: %i[ description permission ]
skip_before_action :require_account
def index
@access_tokens = my_access_tokens.order(created_at: :desc)
end
@@ -24,14 +28,19 @@ class My::AccessTokensController < ApplicationController
format.json do
render status: :created, json: \
{ token: access_token.token, description: access_token.description, permission: access_token.permission }
{ id: access_token.id, token: access_token.token, description: access_token.description,
permission: access_token.permission, created_at: access_token.created_at.utc }
end
end
end
def destroy
my_access_tokens.find(params[:id]).destroy!
redirect_to my_access_tokens_path
respond_to do |format|
format.html { redirect_to my_access_tokens_path }
format.json { head :no_content }
end
end
private
@@ -1,4 +1,6 @@
class Notifications::SettingsController < ApplicationController
wrap_parameters :user_settings, include: %i[ bundle_email_frequency ]
before_action :set_settings
def show
@@ -7,7 +9,11 @@ class Notifications::SettingsController < ApplicationController
def update
@settings.update!(settings_params)
redirect_to notifications_settings_path, notice: "Settings updated"
respond_to do |format|
format.html { redirect_to notifications_settings_path, notice: "Settings updated" }
format.json { head :no_content }
end
end
private
+15 -3
View File
@@ -5,10 +5,22 @@ class SearchesController < ApplicationController
@query = params[:q].blank? ? nil : params[:q]
if card = Current.user.accessible_cards.find_by_id(@query)
@card = card
respond_to do |format|
format.html { @card = card }
format.json { set_page_and_extract_portion_from Current.user.accessible_cards.where(id: card.id) }
end
else
set_page_and_extract_portion_from Current.user.search(@query)
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
respond_to do |format|
format.html do
set_page_and_extract_portion_from Current.user.search(@query)
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
end
format.json do
set_page_and_extract_portion_from \
Current.user.accessible_cards.mentioning(@query, user: Current.user).distinct.latest.preloaded
end
end
end
end
end
@@ -1,4 +1,6 @@
class Signups::CompletionsController < ApplicationController
wrap_parameters :signup, include: %i[ full_name ]
layout "public"
disallow_account_scope
+2
View File
@@ -1,4 +1,6 @@
class SignupsController < ApplicationController
wrap_parameters :signup, include: %i[ email_address ]
disallow_account_scope
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." }
+5 -1
View File
@@ -16,7 +16,11 @@ class Users::AvatarsController < ApplicationController
def destroy
@user.avatar.destroy
redirect_to @user
respond_to do |format|
format.html { redirect_to @user }
format.json { head :no_content }
end
end
private
+7 -1
View File
@@ -1,4 +1,6 @@
class Users::JoinsController < ApplicationController
wrap_parameters :user, include: %i[ name avatar ]
layout "public"
def new
@@ -6,7 +8,11 @@ class Users::JoinsController < ApplicationController
def create
Current.user.update!(user_params)
redirect_to landing_path
respond_to do |format|
format.html { redirect_to landing_path }
format.json { head :no_content }
end
end
private
@@ -1,16 +1,27 @@
class Users::PushSubscriptionsController < ApplicationController
wrap_parameters :push_subscription, include: %i[ endpoint p256dh_key auth_key ]
before_action :set_push_subscriptions
def index
end
def create
@push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
subscription = @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
respond_to do |format|
format.html { head :no_content }
format.json { head :created }
end
end
def destroy
@push_subscriptions.destroy_by(id: params[:id])
redirect_to user_push_subscriptions_url
respond_to do |format|
format.html { redirect_to user_push_subscriptions_url }
format.json { head :no_content }
end
end
private
+7 -1
View File
@@ -1,10 +1,16 @@
class Users::RolesController < ApplicationController
wrap_parameters :user, include: %i[ role ]
before_action :set_user
before_action :ensure_permission_to_administer_user
def update
@user.update!(role_params)
redirect_to account_settings_path
respond_to do |format|
format.html { redirect_to account_settings_path }
format.json { head :no_content }
end
end
private
+2
View File
@@ -1,4 +1,6 @@
class UsersController < ApplicationController
wrap_parameters :user, include: %i[ name avatar ]
before_action :set_user, except: %i[ index ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
@@ -4,9 +4,12 @@ class Webhooks::ActivationsController < ApplicationController
before_action :ensure_admin
def create
webhook = @board.webhooks.find(params[:webhook_id])
webhook.activate
@webhook = @board.webhooks.find(params[:webhook_id])
@webhook.activate
redirect_to webhook
respond_to do |format|
format.html { redirect_to @webhook }
format.json { render "webhooks/show", status: :created }
end
end
end
+31 -5
View File
@@ -1,4 +1,6 @@
class WebhooksController < ApplicationController
wrap_parameters :webhook, include: %i[ name url subscribed_actions ]
include BoardScoped
before_action :ensure_admin
@@ -16,21 +18,45 @@ class WebhooksController < ApplicationController
end
def create
webhook = @board.webhooks.create!(webhook_params)
redirect_to webhook
@webhook = @board.webhooks.new(webhook_params)
if @webhook.save
respond_to do |format|
format.html { redirect_to @webhook }
format.json { render :show, status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) }
end
else
respond_to do |format|
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @webhook.errors, status: :unprocessable_entity }
end
end
end
def edit
end
def update
@webhook.update!(webhook_params.except(:url))
redirect_to @webhook
if @webhook.update(webhook_params.except(:url))
respond_to do |format|
format.html { redirect_to @webhook }
format.json { render :show }
end
else
respond_to do |format|
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @webhook.errors, status: :unprocessable_entity }
end
end
end
def destroy
@webhook.destroy!
redirect_to board_webhooks_path
respond_to do |format|
format.html { redirect_to board_webhooks_path }
format.json { head :no_content }
end
end
private
-4
View File
@@ -1,8 +1,4 @@
module EntropyHelper
def entropy_auto_close_options
[ 3, 7, 30, 90, 365, 11 ]
end
def entropy_bubble_options_for(card)
{
daysBeforeReminder: card.entropy.days_before_reminder,
@@ -0,0 +1,8 @@
import { Controller } from "@hotwired/stimulus"
import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
clearCache() {
Turbo.offline.clearCache()
}
}
@@ -15,7 +15,7 @@ export default class extends Controller {
submit() {
if (this.inputTarget.disabled) return
this.element.submit()
this.element.requestSubmit()
this.inputTarget.disabled = true
}
}
@@ -15,7 +15,7 @@ export default class extends Controller {
}
#showFileName() {
this.fileNameTarget.innerHTML = this.#file.name
this.fileNameTarget.innerText = this.#file.name
this.fileNameTarget.removeAttribute("hidden")
this.placeholderTarget.setAttribute("hidden", true)
}
+1
View File
@@ -1,2 +1,3 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
+9
View File
@@ -0,0 +1,9 @@
import { Turbo } from "@hotwired/turbo-rails"
if (Current.user) {
Turbo.offline.start("/service-worker.js", {
scope: "/",
native: true,
preload: /\/assets\//
})
}
@@ -13,6 +13,7 @@ class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfe
data = load(file)
data.slice(*attributes).merge(
"account_id" => account.id,
"key" => ::ActiveStorage::Blob.generate_unique_secure_token(length: ::ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH),
"service_name" => ::ActiveStorage::Blob.service.name
)
end
@@ -22,9 +22,12 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
def import_batch(files)
files.each do |file|
key = File.basename(file)
blob = ::ActiveStorage::Blob.find_by(key: key, account: account)
next unless blob
old_key = file.delete_prefix("storage/")
blob_id = old_key_to_blob_id[old_key]
raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" unless blob_id
blob = ::ActiveStorage::Blob.find_by(id: blob_id, account: account)
raise IntegrityError, "Blob #{blob_id} not found for storage key #{old_key}" unless blob
zip.read(file) do |stream|
blob.upload(stream)
@@ -32,12 +35,31 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
end
end
def check_record(file_path)
key = File.basename(file_path)
def old_key_to_blob_id
@old_key_to_blob_id ||= build_old_key_to_blob_id
end
unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account)
# File exists without corresponding blob record - could be orphaned or blob not yet imported
# We allow this since blob metadata is imported before files
def build_old_key_to_blob_id
zip.glob("data/active_storage_blobs/*.json").each_with_object({}) do |file, map|
data = load(file)
old_key = data["key"]
if map.key?(old_key)
raise IntegrityError, "Duplicate blob key in export: #{old_key}"
end
map[old_key] = data["id"]
end
end
def with_zip(zip)
@old_key_to_blob_id = nil
super
end
def check_record(file_path)
old_key = file_path.delete_prefix("storage/")
unless old_key_to_blob_id.key?(old_key)
raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export"
end
end
end
+35 -7
View File
@@ -26,20 +26,42 @@ class Account::DataTransfer::Manifest
[
Account::DataTransfer::AccountRecordSet.new(account),
Account::DataTransfer::UserRecordSet.new(account),
*build_record_sets(::User::Settings, ::Tag, ::Board, ::Column),
*build_record_sets(
::User::Settings,
::Tag,
::Board,
::Column
),
Account::DataTransfer::EntropyRecordSet.new(account),
*build_record_sets(
::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step,
::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow,
::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention,
::Filter, ::Webhook::DelinquencyTracker, ::Event,
::Notification, ::Notification::Bundle, ::Webhook::Delivery
::Board::Publication,
::Webhook,
::Access,
::Card,
::Comment,
::Step,
::Assignment,
::Tagging,
::Closure,
::Card::Goldness,
::Card::NotNow,
::Card::ActivitySpike,
::Watch,
::Pin,
::Reaction,
::Mention,
::Filter,
::Webhook::DelinquencyTracker,
::Event,
::Notification,
::Notification::Bundle,
::Webhook::Delivery
),
Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account),
*build_record_sets(::ActiveStorage::Attachment),
Account::DataTransfer::ActionText::RichTextRecordSet.new(account),
Account::DataTransfer::ActiveStorage::FileRecordSet.new(account)
]
].then { set_importable_model_names(it) }
end
def build_record_sets(*models)
@@ -47,4 +69,10 @@ class Account::DataTransfer::Manifest
Account::DataTransfer::RecordSet.new(account: account, model: model)
end
end
def set_importable_model_names(record_sets)
model_names = record_sets.filter_map { |record_set| record_set.model&.name }
record_sets.each { |record_set| record_set.importable_model_names = model_names }
record_sets
end
end
+12 -2
View File
@@ -4,12 +4,14 @@ class Account::DataTransfer::RecordSet
IMPORT_BATCH_SIZE = 100
attr_accessor :importable_model_names
attr_reader :account, :model, :attributes
def initialize(account:, model:, attributes: nil)
def initialize(account:, model:, attributes: nil, importable_model_names: nil)
@account = account
@model = model
@attributes = (attributes || model.column_names).map(&:to_s)
@importable_model_names = importable_model_names || [ model.name ]
end
def export(to:, start: nil)
@@ -113,7 +115,7 @@ class Account::DataTransfer::RecordSet
def check_association_doesnt_exist(data, association, associated_id)
if association.polymorphic?
type_column = association.foreign_type.to_s
associated_class = data[type_column].constantize
associated_class = verify_model_type(data[type_column])
else
associated_class = association.klass
end
@@ -123,6 +125,14 @@ class Account::DataTransfer::RecordSet
end
end
def verify_model_type(type_name)
if importable_model_names.include?(type_name)
type_name.constantize
else
raise IntegrityError, "Unrecognized model type: #{type_name}"
end
end
def skip_to(file_list, last_id)
index = file_list.index(last_id)
+1 -3
View File
@@ -6,9 +6,7 @@ module Board::AutoPostponing
end
private
DEFAULT_AUTO_POSTPONE_PERIOD = 30.days
def set_default_auto_postpone_period
self.auto_postpone_period ||= DEFAULT_AUTO_POSTPONE_PERIOD unless attribute_present?(:auto_postpone_period)
self.auto_postpone_period ||= Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS.days unless attribute_present?(:auto_postpone_period)
end
end
+6 -2
View File
@@ -2,7 +2,7 @@ module Board::Entropic
extend ActiveSupport::Concern
included do
delegate :auto_postpone_period, to: :entropy
delegate :auto_postpone_period, :auto_postpone_period_in_days, to: :entropy
has_one :entropy, as: :container, dependent: :destroy
end
@@ -12,6 +12,10 @@ module Board::Entropic
def auto_postpone_period=(new_value)
entropy ||= association(:entropy).reader || self.build_entropy
entropy.update auto_postpone_period: new_value
entropy.update! auto_postpone_period: new_value
end
def auto_postpone_period_in_days=(value)
self.auto_postpone_period = value.to_i.days.to_i
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class Board::Publication < ApplicationRecord
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :board, touch: true
has_secure_token :key
end
+29
View File
@@ -1,6 +1,35 @@
class Entropy < ApplicationRecord
DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS = 30
AUTO_POSTPONE_PERIODS_IN_DAYS = [ 3, 7, 30, 90, 365, 11 ].freeze
AUTO_POSTPONE_PERIODS_IN_SECONDS = AUTO_POSTPONE_PERIODS_IN_DAYS.map { |n| n.day.in_seconds }.freeze
belongs_to :account, default: -> { container.account }
belongs_to :container, polymorphic: true
validates :auto_postpone_period, inclusion: { in: AUTO_POSTPONE_PERIODS_IN_SECONDS }
after_commit -> { container.cards.touch_all if container }
def auto_postpone_period_in_days
days = auto_postpone_period / 1.day.to_i
if days.in?(AUTO_POSTPONE_PERIODS_IN_DAYS)
days
else
default_auto_postpone_period_in_days
end
end
def auto_postpone_period_in_days=(new_value)
self.auto_postpone_period = new_value.to_i.days.to_i
end
private
def default_auto_postpone_period_in_days
if container.is_a?(Board)
container.account.entropy.auto_postpone_period_in_days
else
DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS
end
end
end
+1 -1
View File
@@ -20,6 +20,6 @@ module User::Named
def familiar_name
names = name.split
return name if names.length <= 1
"#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}"
"#{names.first}\u00A0#{names[1..].map { |n| "#{n[0]}." }.join}"
end
end
+2 -1
View File
@@ -84,7 +84,8 @@ class ZipFile
def read_from_s3(blob)
url = blob.url(expires_in: 6.hour)
remote_io = RemoteIO.new(url)
ssl_verify_peer = blob.service.client.client.config.ssl_verify_peer
remote_io = RemoteIO.new(url, ssl_verify_peer: ssl_verify_peer)
reader = Reader.new(remote_io)
yield reader
end
+6 -2
View File
@@ -1,4 +1,9 @@
class ZipFile::RemoteIO < ZipKit::RemoteIO
def initialize(url, ssl_verify_peer: true)
super(url)
@ssl_verify_peer = ssl_verify_peer
end
protected
def request_range(range)
with_http do |http|
@@ -37,8 +42,7 @@ class ZipFile::RemoteIO < ZipKit::RemoteIO
def with_http
http = Net::HTTP.new(@uri.hostname, @uri.port)
http.use_ssl = @uri.scheme == "https"
# FIXME: Disable SSL verification for now to avoid issues with our self-signed certificates for PureStorage
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify_peer
http.start { yield http }
end
end
@@ -0,0 +1,6 @@
json.(@export, :id, :status)
json.created_at @export.created_at.utc
if @export.completed? && @export.file.attached?
json.download_url rails_blob_url(@export.file, disposition: "attachment")
end
@@ -0,0 +1,3 @@
json.(@join_code, :code, :usage_count, :usage_limit)
json.url join_url(code: @join_code.code, script_name: Current.account.slug)
json.active !!@join_code.active?
-2
View File
@@ -21,5 +21,3 @@
<%= render "account/settings/cancellation" %>
</div>
</div>
<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
@@ -0,0 +1,3 @@
json.(@account, :id, :name, :cards_count)
json.created_at @account.created_at.utc
json.auto_postpone_period_in_days @account.entropy.auto_postpone_period_in_days
+1
View File
@@ -1,6 +1,7 @@
json.cache! board do
json.(board, :id, :name, :all_access)
json.created_at board.created_at.utc
json.auto_postpone_period_in_days board.auto_postpone_period_in_days
json.url board_url(board)
json.creator board.creator, partial: "users/user", as: :user
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
+1 -1
View File
@@ -12,7 +12,7 @@
</div>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if page.used? %>
<%= with_automatic_pagination "maybe", @page do %>
<%= with_automatic_pagination "maybe", page do %>
<%= render "cards/display/previews", cards: page.records, draggable: true %>
<% end %>
<% end %>
@@ -3,7 +3,7 @@
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
@@ -3,7 +3,7 @@
<% if @card.column %>
<%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %>
<% elsif @card.awaiting_triage? %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
@@ -13,7 +13,5 @@
<span>Create and add another</span>
<% end %>
</div>
<%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %>
</div>
+1 -5
View File
@@ -26,9 +26,5 @@
</div>
<% end %>
<% if Fizzy.saas? %>
<%= render "cards/container/footer/saas/create", card: card %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
<% end %>
<%= render "cards/container/footer/create", card: card %>
</section>
@@ -3,7 +3,7 @@
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
@@ -0,0 +1 @@
json.array! @card.steps, partial: "cards/steps/step", as: :step
+3 -2
View File
@@ -6,8 +6,9 @@
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
<%= render "entropy/knob",
form: form,
name: :auto_postpone_period,
knob_options: entropy_auto_close_options,
name: :auto_postpone_period_in_days,
current_value: model.auto_postpone_period_in_days,
knob_options: Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS,
label: "Days until auto-close",
disabled: disabled %>
<% end %>
+2 -3
View File
@@ -1,6 +1,5 @@
<%
period = form.object.send(name)
current_index = knob_options.index(period.to_i / 1.day)
current_index = knob_options.index(current_value) || knob_options.index(Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS)
%>
<fieldset class="knob" data-controller="knob" data-knob-target="field" style="--knob-options: <%= knob_options.length %>; --knob-index: <%= current_index %>">
@@ -8,7 +7,7 @@
<% knob_options.each_with_index do |value, index| %>
<label class="knob__option" style="--i: <%= index %>">
<%= form.radio_button name,
value.days,
value,
data: {
action: "change->knob#optionChanged change->form#submit",
index: index,
-1
View File
@@ -17,7 +17,6 @@
<%= render "layouts/theme_preference" %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %>
<%= javascript_importmap_tags %>
<%= tenanted_action_cable_meta_tag %>
@@ -0,0 +1,2 @@
json.(access_token, :id, :description, :permission)
json.created_at access_token.created_at.utc
@@ -0,0 +1 @@
json.array! @access_tokens, partial: "my/access_tokens/access_token", as: :access_token
+1 -1
View File
@@ -6,7 +6,7 @@
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
<%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
<%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
<span>Sign out</span>
<% end %>
<% end %>
@@ -0,0 +1 @@
json.bundle_email_frequency Current.user.settings.bundle_email_frequency
-41
View File
@@ -1,41 +0,0 @@
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
if (event.request.destination === 'document') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.catch(() => caches.match(event.request))
)
}
})
self.addEventListener("push", (event) => {
const data = event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
})
async function showNotification({ title, options }) {
return self.registration.showNotification(title, options)
}
async function updateBadgeCount({ data: { badge } }) {
return self.navigator.setAppBadge?.(badge || 0)
}
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})
async function openURL(url) {
const clients = await self.clients.matchAll({ type: "window" })
const focused = clients.find((client) => client.focused)
if (focused) {
await focused.navigate(url)
} else {
await self.clients.openWindow(url)
}
}
+97
View File
@@ -0,0 +1,97 @@
importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
// Documents - top-level navigation requests only
// We need `cache: "no-cache"` here to work around
// an annoying Safari PWA bug that predates offline mode
TurboOffline.addRule({
match: (request) => request.destination === "document",
except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
networkTimeout: 3,
fetchOptions: { cache: "no-cache" },
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
handler: TurboOffline.handlers.cacheFirst({
cacheName: "assets",
maxAge: 60 * 60 * 24 * 7,
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/(boards|cards|users)\//,
except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
networkTimeout: 2,
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/rails\/active_storage\//,
handler: TurboOffline.handlers.networkFirst({
cacheName: "storage",
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
maxEntries: 500,
fetchOptions: { mode: "cors" }
})
})
// Everything else
TurboOffline.addRule({
except: /\/(service-worker\.js|edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24,
networkTimeout: 3,
maxEntrySize: 1024 * 1024
})
})
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim())
})
TurboOffline.start()
self.addEventListener("push", (event) => {
const data = event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
})
async function showNotification({ title, options }) {
return self.registration.showNotification(title, options)
}
async function updateBadgeCount({ data: { badge } }) {
return self.navigator.setAppBadge?.(badge || 0)
}
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})
async function openURL(url) {
const clients = await self.clients.matchAll({ type: "window" })
const focused = clients.find((client) => client.focused)
if (focused) {
await focused.navigate(url)
} else {
await self.clients.openWindow(url)
}
}
+1
View File
@@ -0,0 +1 @@
json.partial! "reactions/reaction", reaction: @reaction
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
</header>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do |form| %>
<%= 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],
+1 -1
View File
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
<span>Sign out of Fizzy on this device</span>
<% end %>
<% end %>
@@ -0,0 +1,8 @@
json.cache! [ webhook, webhook.board ] do
json.(webhook, :id, :name, :active, :signing_secret, :subscribed_actions)
json.payload_url webhook.url
json.created_at webhook.created_at.utc
json.url board_webhook_url(webhook.board, webhook)
json.board webhook.board, partial: "boards/board", as: :board
end
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "webhooks/webhook", as: :webhook
+1
View File
@@ -0,0 +1 @@
json.partial! "webhooks/webhook", webhook: @webhook
+1
View File
@@ -2,6 +2,7 @@
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/turbo/offline", to: "turbo-offline.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js"
+193 -3
View File
@@ -434,6 +434,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -460,6 +461,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -484,7 +486,7 @@ Creates a new Board in the account.
|-----------|------|----------|-------------|
| `name` | string | Yes | The name of the board |
| `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` |
| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed |
| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) |
| `public_description` | string | No | Rich text description shown on the public board page |
__Request:__
@@ -514,7 +516,7 @@ Updates a Board. Only board administrators can update a board.
|-----------|------|----------|-------------|
| `name` | string | No | The name of the board |
| `all_access` | boolean | No | Whether any user in the account can access this board |
| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed |
| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) |
| `public_description` | string | No | Rich text description shown on the public board page |
| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) |
@@ -524,7 +526,7 @@ __Request:__
{
"board": {
"name": "Updated board name",
"auto_postpone_period": 14,
"auto_postpone_period_in_days": 30,
"public_description": "This is a **public** description of the board.",
"all_access": false,
"user_ids": [
@@ -567,6 +569,7 @@ HTTP/1.1 201 Created
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -591,6 +594,190 @@ __Response:__
Returns `204 No Content` on success.
## Account
### `GET /account/settings`
Returns the current account.
__Response:__
```json
{
"id": "03f5v9zjvypwh0t0e2rfh0h7k",
"name": "37signals",
"cards_count": 5,
"created_at": "2025-12-05T19:36:35.401Z",
"auto_postpone_period_in_days": 30
}
```
The `auto_postpone_period_in_days` is the account-level default in days (e.g. `30`). Cards are automatically moved to "Not Now" after this period of inactivity. Each board can override this with its own value.
### `PUT /account/entropy`
Updates the account-level default auto close period. Requires admin role.
__Request:__
```json
{
"entropy": {
"auto_postpone_period_in_days": 30
}
}
```
__Response:__
Returns the account object:
```json
{
"id": "03f5v9zjvypwh0t0e2rfh0h7k",
"name": "37signals",
"cards_count": 5,
"created_at": "2025-12-05T19:36:35.401Z",
"auto_postpone_period_in_days": 30
}
```
### `PUT /:account_slug/boards/:board_id/entropy`
Updates the auto close period for a specific board. Requires board admin permission.
__Request:__
```json
{
"board": {
"auto_postpone_period_in_days": 90
}
}
```
__Response:__
Returns the board object.
## Webhooks
Webhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks.
### `GET /:account_slug/boards/:board_id/webhooks`
Returns a paginated list of webhooks for a board.
__Response:__
```json
[
{
"id": "03f5v9zkft4hj9qq0lsn9ohcm",
"name": "Production API",
"payload_url": "https://api.example.com/webhooks",
"active": true,
"signing_secret": "p94Bx2HjempCdYB4DTyZkY1b",
"subscribed_actions": ["card_published", "card_assigned", "card_closed"],
"created_at": "2025-12-05T19:36:35.534Z",
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm",
"board": {
"id": "03f5v9zkft4hj9qq0lsn9ohcy",
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy",
"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"
}
}
}
]
```
### `GET /:account_slug/boards/:board_id/webhooks/:id`
Returns a single webhook.
__Response:__
Returns the same webhook shape shown above.
### `POST /:account_slug/boards/:board_id/webhooks`
Creates a webhook.
__Request:__
```json
{
"webhook": {
"name": "Production API",
"url": "https://api.example.com/webhooks",
"subscribed_actions": ["card_published", "card_assigned", "card_closed"]
}
}
```
`subscribed_actions` accepts any of:
`card_assigned`, `card_closed`, `card_postponed`, `card_auto_postponed`, `card_board_changed`, `card_published`, `card_reopened`, `card_sent_back_to_triage`, `card_triaged`, `card_unassigned`, `comment_created`
__Response:__
```
HTTP/1.1 201 Created
Location: http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm.json
```
Returns the created webhook in the response body.
### `PATCH /:account_slug/boards/:board_id/webhooks/:id`
Updates a webhook.
__Request:__
```json
{
"webhook": {
"name": "Production API",
"subscribed_actions": ["card_closed"]
}
}
```
The `url` is immutable after creation and is ignored on update.
__Response:__
Returns the updated webhook.
### `DELETE /:account_slug/boards/:board_id/webhooks/:id`
Deletes a webhook.
__Response:__
Returns `204 No Content` on success.
### `POST /:account_slug/boards/:board_id/webhooks/:id/activation`
Reactivates a deactivated webhook.
__Response:__
```
HTTP/1.1 201 Created
```
Returns the reactivated webhook in the response body.
## Cards
Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments.
@@ -639,6 +826,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -692,6 +880,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -959,6 +1148,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -1,70 +0,0 @@
/* Subscriptions
/* ------------------------------------------------------------------------ */
:root {
--settings-subscription-background: linear-gradient(to bottom, var(--color-canvas), oklch(var(--lch-violet-lighter)));
--settings-subscription-color: oklch(var(--lch-violet-medium));
--settings-subscription-text-color: oklch(var(--lch-violet-dark));
.settings-subscription__button {
--btn-background: var(--settings-subscription-color);
--btn-border-color: var(--color-canvas);
--btn-color: var(--color-canvas);
--focus-ring-color: var(--color-ink);
}
.settings-subscription__divider {
--divider-color: currentColor;
color: var(--settings-subscription-color);
margin-block-start: calc(var(--block-space-half) * -1);
}
.settings-subscription__footer {
color: var(--settings-subscription-text-color);
&::before {
border-block-start: 1px solid var(--settings-subscription-text-color);
content: "";
display: block;
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);
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.3em 0.3em 0.3em 1.2em;
transform: rotate(-1deg);
@media (max-width: 640px) {
border-radius: 1ch;
padding-block: 1ch;
padding-inline: 2ch;
}
.btn {
/* margin-block: 0.3em; */
}
}
.settings-subscription__panel {
background: var(--settings-subscription-background);
}
.settings_subscription__warning {
color: var(--settings-subscription-text-color);
a {
color: var(--settings-subscription-text-color);
}
}
}
@@ -1,19 +0,0 @@
class Account::BillingPortalsController < ApplicationController
before_action :ensure_admin
before_action :ensure_subscribed_account
def show
redirect_to create_stripe_billing_portal_session.url, allow_other_host: true
end
private
def ensure_subscribed_account
unless Current.account.subscribed?
redirect_to account_subscription_path, alert: "No billing information found"
end
end
def create_stripe_billing_portal_session
Stripe::BillingPortal::Session.create(customer: Current.account.subscription.stripe_customer_id, return_url: account_settings_url)
end
end
@@ -1,12 +0,0 @@
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
@@ -1,32 +0,0 @@
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
@@ -1,12 +0,0 @@
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
@@ -1,46 +0,0 @@
class Account::SubscriptionsController < ApplicationController
before_action :ensure_admin
before_action :set_stripe_session, only: :show
def show
end
def create
session = Stripe::Checkout::Session.create \
customer: find_or_create_stripe_customer,
mode: "subscription",
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_param.key },
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: "required",
customer_update: { address: "auto", name: "auto" }
redirect_to session.url, allow_other_host: true
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
def find_or_create_stripe_customer
find_stripe_customer || create_stripe_customer
end
def find_stripe_customer
Stripe::Customer.retrieve(Current.account.subscription.stripe_customer_id) if Current.account.subscription&.stripe_customer_id
end
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_param.key, status: "incomplete")
end
end
end
@@ -1,5 +0,0 @@
class Admin::AccountSearchesController < AdminController
def create
redirect_to saas.edit_admin_account_path(params[:q])
end
end
@@ -1,27 +0,0 @@
class Admin::AccountsController < AdminController
include Admin::AccountScoped
layout "public"
before_action :set_account, only: %i[ edit update ]
def index
end
def edit
end
def update
@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
private
def set_account
@account = Account.find_by!(external_account_id: params[:id])
end
def overridden_limits_params
params.expect(account: [ :card_count, :bytes_used ])
end
end
@@ -1,13 +0,0 @@
class Admin::BillingWaiversController < AdminController
include Admin::AccountScoped
def create
@account.comp
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account comped"
end
def destroy
@account.uncomp
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account uncomped"
end
end
@@ -1,8 +0,0 @@
class Admin::OverriddenLimitsController < AdminController
include Admin::AccountScoped
def destroy
@account.reset_overridden_limits
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Limits reset"
end
end
@@ -6,10 +6,6 @@ class Admin::StatsController < AdminController
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
@paid_accounts_total = paid_subscriptions.distinct.count(:account_id)
@paid_accounts_last_7_days = paid_subscriptions.where(created_at: 7.days.ago..).distinct.count(:account_id)
@paid_accounts_last_24_hours = paid_subscriptions.where(created_at: 24.hours.ago..).distinct.count(:account_id)
@identities_total = Identity.count
@identities_last_7_days = Identity.where(created_at: 7.days.ago..).count
@identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count
@@ -21,12 +17,4 @@ class Admin::StatsController < AdminController
@recent_accounts = Account.order(created_at: :desc).limit(10)
end
private
def paid_subscriptions
Account::Subscription
.where(status: %w[active trialing past_due])
.where(plan_key: %w[monthly_v1 monthly_extra_storage_v1])
.where.not(account_id: Account::BillingWaiver.select(:account_id))
end
end
@@ -1,12 +0,0 @@
module Admin::AccountScoped
extend ActiveSupport::Concern
included do
before_action :set_account
end
private
def set_account
@account = Account.find_by!(external_account_id: params[:account_id] || params[:id])
end
end
@@ -1,8 +0,0 @@
module Card::Limited
extend ActiveSupport::Concern
private
def ensure_under_limits
head :forbidden if Current.account.exceeding_limits?
end
end
@@ -1,11 +0,0 @@
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_under_limits, only: %i[ create ], if: -> { request.format.json? }
end
end

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