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:
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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!
|
||||
|
||||
@@ -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
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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." }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
import "initializers/current"
|
||||
import "initializers/bridge/bridge_element"
|
||||
import "initializers/offline"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
@@ -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,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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! "reactions/reaction", reaction: @reaction
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @page.records, partial: "cards/card", as: :card
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @page.records, partial: "webhooks/webhook", as: :webhook
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! "webhooks/webhook", webhook: @webhook
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user