Merge branch 'main' into single_tenant

This commit is contained in:
Adam Haris
2025-12-11 06:08:01 +01:00
committed by GitHub
155 changed files with 4251 additions and 203 deletions
+8
View File
@@ -6,6 +6,14 @@
# Ignore bundler config.
/.bundle
# Ignore documentation
/docs/
/README.md
/CLAUDE.md
/AGENTS.md
/STYLE.md
/CONTRIBUTING.md
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
-1
View File
@@ -30,4 +30,3 @@ updates:
open-pull-requests-limit: 10
cooldown:
default-days: 7
semver-major-days: 14
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
@@ -33,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
+6 -6
View File
@@ -39,13 +39,13 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@v5.0.0
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.11.1
- name: Log in to GHCR
uses: docker/login-action@v3.5.0
uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -62,7 +62,7 @@ jobs:
- name: Extract Docker metadata (tags, labels) with arch suffix
id: meta
uses: docker/metadata-action@v5.8.0
uses: docker/metadata-action@v5.10.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -118,7 +118,7 @@ jobs:
uses: docker/setup-buildx-action@v3.11.1
- name: Log in to GHCR
uses: docker/login-action@v3.5.0
uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -135,7 +135,7 @@ jobs:
- name: Compute base tags (no suffix)
id: meta
uses: docker/metadata-action@v5.8.0
uses: docker/metadata-action@v5.10.0
with:
images: ${{ steps.vars.outputs.canonical }}
tags: |
@@ -179,7 +179,7 @@ jobs:
done <<< "$tags"
- name: Install Cosign
uses: sigstore/cosign-installer@v3.9.2
uses: sigstore/cosign-installer@v4.0.0
- name: Cosign sign all tags (keyless OIDC)
shell: bash
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
- name: Install system packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
+2
View File
@@ -6,4 +6,6 @@ paths = [
'''log''',
'''tmp''',
'''.*\.yml\.enc''',
'''docs/''',
'''test/''',
]
-6
View File
@@ -1,9 +1,3 @@
d8463077:gems/fizzy-saas/bin/setup:generic-api-key:54
c4073c1c:app/models/integration/basecamp.rb:generic-api-key:3
c4073c1c:app/models/integration/basecamp.rb:generic-api-key:4
02a42167:test/models/webhook_test.rb:slack-webhook-url:57
2fc9215b:test/models/webhook/delivery_test.rb:slack-webhook-url:156
2fc9215b:test/models/webhook_test.rb:slack-webhook-url:57
a515ea3b:test/fixtures/webhooks.yml:generic-api-key:5
a515ea3b:test/fixtures/webhooks.yml:generic-api-key:11
1f21c12c:test/vcr_cassettes/command/ai/translator_test-test_combine_commands_and_filters.yml:github-oauth:73012
+1 -1
View File
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: bf81d40a91880c059ce9d0447219385a2c8e4a15
revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
branch: ast-immediate-variants-process-locally
specs:
actioncable (8.2.0.alpha)
+2 -2
View File
@@ -21,7 +21,7 @@ GIT
GIT
remote: https://github.com/basecamp/fizzy-saas
revision: 43dbc896ce7b6a08194a92ddd1695d3f1ebf554b
revision: a14df11b57818697df4b2cc7b6a43e762ebaa196
specs:
fizzy-saas (0.1.0)
audits1984
@@ -82,7 +82,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: bf81d40a91880c059ce9d0447219385a2c8e4a15
revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
branch: ast-immediate-variants-process-locally
specs:
actioncable (8.2.0.alpha)
+19
View File
@@ -95,6 +95,25 @@ After the first deploy is done, any subsequent steps won't need to do that initi
bin/kamal deploy
```
## File storage (Active Storage)
Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`.
To use the included `s3` service, set:
- `ACTIVE_STORAGE_SERVICE=s3`
- `S3_ACCESS_KEY_ID`
- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`)
- `S3_REGION` (defaults to `us-east-1`)
- `S3_SECRET_ACCESS_KEY`
Optional for S3-compatible endpoints:
- `S3_ENDPOINT`
- `S3_FORCE_PATH_STYLE=true`
- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`)
- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`)
## Development
### Setting up
+19
View File
@@ -0,0 +1,19 @@
.access_tokens_table {
border-collapse: collapse;
inline-size: 100%;
td, th {
border-block-end: 1px solid var(--color-ink-light);
padding-inline: var(--inline-space);
text-align: start;
}
th {
font-size: var(--text-x-small);
text-transform: uppercase;
}
tr:nth-of-type(even) {
background-color: var(--color-ink-lightest);
}
}
+4
View File
@@ -295,6 +295,10 @@
max-height: 200px;
overflow: scroll;
&:is(.lexxy-prompt-menu--visible) {
padding: var(--lexxy-prompt-padding);
}
}
.lexxy-prompt-menu--visible {
+9
View File
@@ -219,6 +219,15 @@
}
}
.nav__blank-slate {
font-size: var(--text-small);
margin: 1rem auto;
.nav:has(.popup__item:not([hidden])) & {
display: none;
}
}
.nav__footer {
background-color: var(--color-canvas);
border-block-start: 1px solid var(--color-ink-lighter);
+2
View File
@@ -116,6 +116,8 @@
pointer-events: none;
}
.cursor-pointer { cursor: pointer; }
/* Padding */
.pad { padding: var(--block-space) var(--inline-space); }
.pad-double { padding: var(--block-space-double) var(--inline-space-double); }
@@ -9,8 +9,11 @@ class Account::JoinCodesController < ApplicationController
end
def update
@join_code.update!(join_code_params)
redirect_to account_join_code_path
if @join_code.update(join_code_params)
redirect_to account_join_code_path
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@@ -3,6 +3,11 @@ class Boards::ColumnsController < ApplicationController
before_action :set_column, only: %i[ show update destroy ]
def index
@columns = @board.columns.sorted
fresh_when etag: @columns
end
def show
set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded
fresh_when etag: @page.records
@@ -10,14 +15,29 @@ class Boards::ColumnsController < ApplicationController
def create
@column = @board.columns.create!(column_params)
respond_to do |format|
format.turbo_stream
format.json { head :created, location: board_column_path(@board, @column, format: :json) }
end
end
def update
@column.update!(column_params)
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@column.destroy
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
+24 -7
View File
@@ -1,9 +1,13 @@
class BoardsController < ApplicationController
include FilterScoped
before_action :set_board, except: %i[ new create ]
before_action :set_board, except: %i[ index new create ]
before_action :ensure_permission_to_admin_board, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from Current.user.boards
end
def show
if @filter.used?(ignore_boards: true)
show_filtered_cards
@@ -18,7 +22,11 @@ class BoardsController < ApplicationController
def create
@board = Board.create! board_params.with_defaults(all_access: true)
redirect_to board_path(@board)
respond_to do |format|
format.html { redirect_to board_path(@board) }
format.json { head :created, location: board_path(@board, format: :json) }
end
end
def edit
@@ -31,16 +39,25 @@ class BoardsController < ApplicationController
@board.update! board_params
@board.accesses.revise granted: grantees, revoked: revokees if grantees_changed?
if @board.accessible_to?(Current.user)
redirect_to edit_board_path(@board), notice: "Saved"
else
redirect_to root_path, notice: "Saved (you were removed from the board)"
respond_to do |format|
format.html do
if @board.accessible_to?(Current.user)
redirect_to edit_board_path(@board), notice: "Saved"
else
redirect_to root_path, notice: "Saved (you were removed from the board)"
end
end
format.json { head :no_content }
end
end
def destroy
@board.destroy
redirect_to root_path
respond_to do |format|
format.html { redirect_to root_path }
format.json { head :no_content }
end
end
private
@@ -9,5 +9,10 @@ class Cards::AssignmentsController < ApplicationController
def create
@card.toggle_assignment @board.users.active.find(params[:assignee_id])
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end
+5 -1
View File
@@ -11,7 +11,11 @@ class Cards::BoardsController < ApplicationController
def update
@card.move_to(@board)
redirect_to @card
respond_to do |format|
format.html { redirect_to @card }
format.json { head :no_content }
end
end
private
+10 -2
View File
@@ -3,11 +3,19 @@ class Cards::ClosuresController < ApplicationController
def create
@card.close
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
def destroy
@card.reopen
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
end
@@ -13,10 +13,20 @@ class Cards::Comments::ReactionsController < ApplicationController
def create
@reaction = @comment.reactions.create!(params.expect(reaction: :content))
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end
def destroy
@reaction.destroy
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
+20 -1
View File
@@ -4,8 +4,17 @@ class Cards::CommentsController < ApplicationController
before_action :set_comment, only: %i[ show edit update destroy ]
before_action :ensure_creatorship, only: %i[ edit update destroy ]
def index
set_page_and_extract_portion_from @card.comments.chronologically
end
def create
@comment = @card.comments.create!(comment_params)
respond_to do |format|
format.turbo_stream
format.json { head :created, location: card_comment_path(@card, @comment, format: :json) }
end
end
def show
@@ -16,10 +25,20 @@ class Cards::CommentsController < ApplicationController
def update
@comment.update! comment_params
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@comment.destroy
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
@@ -32,6 +51,6 @@ class Cards::CommentsController < ApplicationController
end
def comment_params
params.expect(comment: :body)
params.expect(comment: [ :body, :created_at ])
end
end
+10 -2
View File
@@ -3,11 +3,19 @@ class Cards::GoldnessesController < ApplicationController
def create
@card.gild
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
def destroy
@card.ungild
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
end
+5 -1
View File
@@ -3,6 +3,10 @@ class Cards::ImagesController < ApplicationController
def destroy
@card.image.purge_later
redirect_to @card
respond_to do |format|
format.html { redirect_to @card }
format.json { head :no_content }
end
end
end
+5 -1
View File
@@ -3,6 +3,10 @@ class Cards::NotNowsController < ApplicationController
def create
@card.postpone
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
end
+15
View File
@@ -5,6 +5,11 @@ class Cards::StepsController < ApplicationController
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) }
end
end
def show
@@ -15,10 +20,20 @@ class Cards::StepsController < ApplicationController
def update
@step.update!(step_params)
respond_to do |format|
format.turbo_stream
format.json { render :show }
end
end
def destroy
@step.destroy!
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
@@ -9,6 +9,11 @@ class Cards::TaggingsController < ApplicationController
def create
@card.toggle_tag_with sanitized_tag_title_param
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
+9 -2
View File
@@ -5,11 +5,18 @@ class Cards::TriagesController < ApplicationController
column = @card.board.columns.find(params[:column_id])
@card.triage_into(column)
redirect_to @card
respond_to do |format|
format.html { redirect_to @card }
format.json { head :no_content }
end
end
def destroy
@card.send_back_to_triage
redirect_to @card
respond_to do |format|
format.html { redirect_to @card }
format.json { head :no_content }
end
end
end
@@ -7,9 +7,19 @@ class Cards::WatchesController < ApplicationController
def create
@card.watch_by Current.user
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@card.unwatch_by Current.user
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end
+22 -4
View File
@@ -10,8 +10,17 @@ class CardsController < ApplicationController
end
def create
card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted")
redirect_to card
respond_to do |format|
format.html do
card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted")
redirect_to card
end
format.json do
card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
head :created, location: card_path(card, format: :json)
end
end
end
def show
@@ -22,11 +31,20 @@ class CardsController < ApplicationController
def update
@card.update! card_params
respond_to do |format|
format.turbo_stream
format.json { render :show }
end
end
def destroy
@card.destroy!
redirect_to @card.board, notice: "Card deleted"
respond_to do |format|
format.html { redirect_to @card.board, notice: "Card deleted" }
format.json { head :no_content }
end
end
private
@@ -43,6 +61,6 @@ class CardsController < ApplicationController
end
def card_params
params.expect(card: [ :status, :title, :description, :image, tag_ids: [] ])
params.expect(card: [ :status, :title, :description, :image, :created_at, tag_ids: [] ])
end
end
+13 -3
View File
@@ -7,7 +7,7 @@ module Authentication
after_action :ensure_development_magic_link_not_leaked
helper_method :authenticated?
etag { Current.session.id if authenticated? }
etag { Current.identity.id if authenticated? }
include LoginHelper
end
@@ -32,7 +32,7 @@ module Authentication
private
def authenticated?
Current.session.present?
Current.identity.present?
end
def require_account
@@ -42,7 +42,7 @@ module Authentication
end
def require_authentication
resume_session || request_authentication
resume_session || authenticate_by_bearer_token || request_authentication
end
def resume_session
@@ -55,6 +55,16 @@ module Authentication
Session.find_signed(cookies.signed[:session_token])
end
def authenticate_by_bearer_token
if request.authorization.to_s.include?("Bearer")
authenticate_or_request_with_http_token do |token|
if identity = Identity.find_by_permissable_access_token(token, method: request.method)
Current.identity = identity
end
end
end
end
def request_authentication
if Current.account.present?
session[:return_to_after_authenticating] = request.url
@@ -12,7 +12,7 @@ module RequestForgeryProtection
end
def verified_request?
super || safe_fetch_site?
super || safe_fetch_site? || request.format.json?
end
SAFE_FETCH_SITES = %w[ same-origin same-site ]
@@ -0,0 +1,40 @@
class My::AccessTokensController < ApplicationController
def index
@access_tokens = my_access_tokens.order(created_at: :desc)
end
def show
@access_token = my_access_tokens.find(verifier.verify(params[:id]))
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to my_access_tokens_path, alert: "Token is no longer visible"
end
def new
@access_token = my_access_tokens.new
end
def create
access_token = my_access_tokens.create!(access_token_params)
expiring_id = verifier.generate access_token.id, expires_in: 10.seconds
redirect_to my_access_token_path(expiring_id)
end
def destroy
my_access_tokens.find(params[:id]).destroy!
redirect_to my_access_tokens_path
end
private
def my_access_tokens
Current.identity.access_tokens
end
def access_token_params
params.expect(access_token: %i[ description permission ])
end
def verifier
Rails.application.message_verifier(:access_tokens)
end
end
@@ -0,0 +1,7 @@
class My::IdentitiesController < ApplicationController
disallow_account_scope
def show
@identity = Current.identity
end
end
@@ -2,10 +2,15 @@ class Notifications::BulkReadingsController < ApplicationController
def create
Current.user.notifications.unread.read_all
if from_tray?
head :ok
else
redirect_to notifications_path
respond_to do |format|
format.html do
if from_tray?
head :ok
else
redirect_to notifications_path
end
end
format.json { head :no_content }
end
end
@@ -2,10 +2,20 @@ class Notifications::ReadingsController < ApplicationController
def create
@notification = Current.user.notifications.find(params[:notification_id])
@notification.read
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@notification = Current.user.notifications.find(params[:notification_id])
@notification.unread
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end
+8 -1
View File
@@ -1,13 +1,20 @@
class NotificationsController < ApplicationController
MAX_UNREAD_NOTIFICATIONS = 500
MAX_UNREAD_NOTIFICATIONS_VIA_API = 100
def index
@unread = Current.user.notifications.unread.ordered.preloaded.limit(MAX_UNREAD_NOTIFICATIONS) unless current_page_param
@unread = Current.user.notifications.unread.ordered.preloaded.limit(max_unread_notifications) unless current_page_param
set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded
respond_to do |format|
format.turbo_stream if current_page_param # Allows read-all action to side step pagination
format.html
format.json
end
end
private
def max_unread_notifications
request.format.json? ? MAX_UNREAD_NOTIFICATIONS_VIA_API : MAX_UNREAD_NOTIFICATIONS
end
end
+5
View File
@@ -0,0 +1,5 @@
class TagsController < ApplicationController
def index
set_page_and_extract_portion_from Current.account.tags.alphabetically
end
end
+18 -4
View File
@@ -1,7 +1,11 @@
class UsersController < ApplicationController
before_action :set_user
before_action :set_user, except: %i[ index ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from Current.account.users.active.alphabetically
end
def show
end
@@ -10,15 +14,25 @@ class UsersController < ApplicationController
def update
if @user.update(user_params)
redirect_to @user
respond_to do |format|
format.html { redirect_to @user }
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: @user.errors, status: :unprocessable_entity }
end
end
end
def destroy
@user.deactivate
redirect_to users_path
respond_to do |format|
format.html { redirect_to users_path }
format.json { head :no_content }
end
end
private
+14 -5
View File
@@ -1,6 +1,11 @@
module HtmlHelper
include ERB::Util
EXCLUDE_PUNCTUATION = %(.?,:!;"'<>)
EXCLUDE_PUNCTUATION_REGEX = /[#{Regexp.escape(EXCLUDE_PUNCTUATION)}]+\z/
def format_html(html)
fragment = Nokogiri::HTML.fragment(html)
fragment = Nokogiri::HTML5.fragment(html)
auto_link(fragment)
@@ -16,14 +21,16 @@ module HtmlHelper
fragment.traverse do |node|
next unless auto_linkable_node?(node)
content = node.text
# Take care to escape the html text node, so that the subsequent Nokogiri re-parse doesn't
# create tags where there aren't any.
content = h(node.text)
linked_content = content.dup
auto_link_urls(linked_content)
auto_link_emails(linked_content)
if linked_content != content
node.replace(Nokogiri::HTML.fragment(linked_content))
node.replace(Nokogiri::HTML5.fragment(linked_content))
end
end
end
@@ -40,8 +47,10 @@ module HtmlHelper
end
def extract_url_and_punctuation(url_match)
if url_match.end_with?(".", "?", ",", ":")
[ url_match[..-2], url_match[-1] ]
url_match = CGI.unescapeHTML(url_match)
if match = url_match.match(EXCLUDE_PUNCTUATION_REGEX)
len = match[0].length
[ url_match[..-(len+1)], url_match[-len..] ]
else
[ url_match, "" ]
end
+10
View File
@@ -0,0 +1,10 @@
class Storage::MaterializeJob < ApplicationJob
queue_as :backend
limits_concurrency to: 1, key: ->(owner) { owner }
discard_on ActiveJob::DeserializationError
def perform(owner)
owner.materialize_storage
end
end
+9
View File
@@ -0,0 +1,9 @@
class Storage::ReconcileJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform(owner)
owner.reconcile_storage
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Entropic, Seedeable, MultiTenant
include Account::Storage, Entropic, MultiTenant, Seedeable
has_one :join_code
has_many :users, dependent: :destroy
+3
View File
@@ -1,8 +1,11 @@
class Account::JoinCode < ApplicationRecord
CODE_LENGTH = 12
USAGE_LIMIT_MAX = 10_000_000_000
belongs_to :account
validates :usage_limit, numericality: { less_than_or_equal_to: USAGE_LIMIT_MAX, message: "cannot be larger than the population of the planet" }
scope :active, -> { where("usage_count < usage_limit") }
before_create :generate_code, if: -> { code.blank? }
+9
View File
@@ -0,0 +1,9 @@
module Account::Storage
extend ActiveSupport::Concern
include Storage::Totaled
private
def calculate_real_storage_bytes
boards.sum { |board| board.send(:calculate_real_storage_bytes) }
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Board < ApplicationRecord
include Accessible, AutoPostponing, Broadcastable, Cards, Entropic, Filterable, Publishable, Triageable
include Accessible, AutoPostponing, Board::Storage, Broadcastable, Cards, Entropic, Filterable, Publishable, ::Storage::Tracked, Triageable
belongs_to :creator, class_name: "User", default: -> { Current.user }
belongs_to :account, default: -> { creator.account }
+56
View File
@@ -0,0 +1,56 @@
module Board::Storage
extend ActiveSupport::Concern
include Storage::Totaled
# Board's own embeds (public_description) count toward itself
def board_for_storage_tracking
self
end
private
BATCH_SIZE = 1000
# Calculate actual storage by summing blob sizes.
#
# Uses batched pluck queries to avoid loading huge ID arrays, and avoids
# ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool
# issues when ActiveStorage uses separate connection pools (e.g., with replicas).
def calculate_real_storage_bytes
card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes
end
def card_image_bytes
sum_blob_bytes_in_batches \
ActiveStorage::Attachment.where(record_type: "Card", name: "image"),
cards.pluck(:id)
end
def card_embed_bytes
sum_embed_bytes_for "Card", cards.pluck(:id)
end
def comment_embed_bytes
sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id)
end
def board_embed_bytes
sum_embed_bytes_for "Board", [ id ]
end
def sum_embed_bytes_for(record_type, record_ids)
rich_text_ids = ActionText::RichText \
.where(record_type: record_type, record_id: record_ids)
.pluck(:id)
sum_blob_bytes_in_batches \
ActiveStorage::Attachment.where(record_type: "ActionText::RichText", name: "embeds"),
rich_text_ids
end
def sum_blob_bytes_in_batches(base_scope, record_ids)
record_ids.each_slice(BATCH_SIZE).sum do |batch_ids|
blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id)
ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size)
end
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
class Card < ApplicationRecord
include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
belongs_to :account, default: -> { board.account }
belongs_to :board
+39 -9
View File
@@ -1,4 +1,6 @@
class Card::Eventable::SystemCommenter
include ERB::Util
attr_reader :card, :event
def initialize(card, event)
@@ -15,25 +17,53 @@ class Card::Eventable::SystemCommenter
def comment_body
case event.action
when "card_assigned"
"#{event.creator.name} <strong>assigned</strong> this to #{event.assignees.pluck(:name).to_sentence}."
"#{creator_name} <strong>assigned</strong> this to #{assignee_names}."
when "card_unassigned"
"#{event.creator.name} <strong>unassigned</strong> from #{event.assignees.pluck(:name).to_sentence}."
"#{creator_name} <strong>unassigned</strong> from #{assignee_names}."
when "card_closed"
"<strong>Moved</strong> to “Done” by #{event.creator.name}"
"<strong>Moved</strong> to “Done” by #{creator_name}"
when "card_reopened"
"<strong>Reopened</strong> by #{event.creator.name}"
"<strong>Reopened</strong> by #{creator_name}"
when "card_postponed"
"#{event.creator.name} <strong>moved</strong> this to “Not Now”"
"#{creator_name} <strong>moved</strong> this to “Not Now”"
when "card_auto_postponed"
"<strong>Moved</strong> to “Not Now” due to inactivity"
when "card_title_changed"
"#{event.creator.name} <strong>changed the title</strong> from “#{event.particulars.dig('particulars', 'old_title')}” to “#{event.particulars.dig('particulars', 'new_title')}”."
"#{creator_name} <strong>changed the title</strong> from “#{old_title}” to “#{new_title}”."
when "card_board_changed"
"#{event.creator.name} <strong>moved</strong> this from “#{event.particulars.dig('particulars', 'old_board')}” to “#{event.particulars.dig('particulars', 'new_board')}”."
"#{creator_name} <strong>moved</strong> this from “#{old_board}” to “#{new_board}”."
when "card_triaged"
"#{event.creator.name} <strong>moved</strong> this to “#{event.particulars.dig('particulars', 'column')}"
"#{creator_name} <strong>moved</strong> this to “#{column}"
when "card_sent_back_to_triage"
"#{event.creator.name} <strong>moved</strong> this back to “Maybe?”"
"#{creator_name} <strong>moved</strong> this back to “Maybe?”"
end
end
def creator_name
h event.creator.name
end
def assignee_names
h event.assignees.pluck(:name).to_sentence
end
def old_title
h event.particulars.dig("particulars", "old_title")
end
def new_title
h event.particulars.dig("particulars", "new_title")
end
def old_board
h event.particulars.dig("particulars", "old_board")
end
def new_board
h event.particulars.dig("particulars", "new_board")
end
def column
h event.particulars.dig("particulars", "column")
end
end
+7 -19
View File
@@ -4,37 +4,25 @@ module Card::Statuses
included do
enum :status, %w[ drafted published ].index_by(&:itself)
attr_reader :initial_status
before_save :update_created_at_on_publication
before_save :remember_initial_status
before_save :mark_if_just_published
after_create -> { track_event :published }, if: :published?
scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) }
end
attr_accessor :was_just_published
alias_method :was_just_published?, :was_just_published
def publish
transaction do
self.created_at = Time.current
published!
track_event :published
end
end
def was_just_published?
initial_status&.drafted? && status_in_database.inquiry.published?
end
private
def update_created_at_on_publication
if will_save_change_to_status? && status_in_database.inquiry.drafted?
self.created_at = Time.current
end
end
# So that we can check it in callbacks when other operations in the transaction clean the changes.
def remember_initial_status
if will_save_change_to_status?
@initial_status ||= status_in_database.to_s.inquiry
end
def mark_if_just_published
self.was_just_published = true if published? && status_changed?
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Comment < ApplicationRecord
include Attachments, Eventable, Mentions, Promptable, Searchable
include Attachments, Eventable, Mentions, Promptable, Searchable, Storage::Tracked
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
+70
View File
@@ -0,0 +1,70 @@
module Storage::Totaled
extend ActiveSupport::Concern
included do
has_one :storage_total, as: :owner, class_name: "Storage::Total", dependent: :destroy
has_many :storage_entries, class_name: "Storage::Entry", foreign_key: foreign_key_for_storage
end
class_methods do
def foreign_key_for_storage
"#{model_name.singular}_id"
end
end
# Fast: materialized snapshot (may be slightly stale)
def bytes_used
storage_total&.bytes_stored || 0
end
# Exact: snapshot + pending entries
def bytes_used_exact
(storage_total || create_storage_total!).current_usage
end
def materialize_storage_later
Storage::MaterializeJob.perform_later(self)
end
# Materialize all pending entries into snapshot
def materialize_storage
total = storage_total || create_storage_total!
total.with_lock do
latest_entry_id = storage_entries.maximum(:id)
if latest_entry_id && total.last_entry_id != latest_entry_id
scope = storage_entries.where(id: ..latest_entry_id)
scope = scope.where.not(id: ..total.last_entry_id) if total.last_entry_id
delta_sum = scope.sum(:delta)
total.update! bytes_stored: total.bytes_stored + delta_sum, last_entry_id: latest_entry_id
end
end
end
# Reconcile ledger against actual attachment storage.
# Uses cursor to ensure consistency: captures max entry ID first, then calculates
# real bytes, then sums only entries up to that cursor. Concurrent uploads during
# calculation will have entries with IDs beyond the cursor, avoiding double-count.
def reconcile_storage
max_entry_id = storage_entries.maximum(:id)
real_bytes = calculate_real_storage_bytes
ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0
diff = real_bytes - ledger_bytes
if diff.nonzero?
Storage::Entry.record \
account: is_a?(Account) ? self : account,
board: is_a?(Board) ? self : nil,
recordable: nil,
delta: diff,
operation: "reconcile"
end
end
private
def calculate_real_storage_bytes
raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes"
end
end
+57
View File
@@ -0,0 +1,57 @@
module Storage::Tracked
extend ActiveSupport::Concern
included do
before_update :track_board_transfer, if: :board_transfer?
end
# Return self as the trackable record for storage entries
def storage_tracked_record
self
end
# Override in models where board is determined differently (e.g., Board itself)
def board_for_storage_tracking
board
end
# Total bytes for all attachments on this record
def storage_bytes
attachments_for_storage.sum { |a| a.blob.byte_size }
end
private
def board_transfer?
respond_to?(:board_id_changed?) && board_id_changed?
end
def track_board_transfer
old_board_id = board_id_was
current_bytes = storage_bytes
if current_bytes.positive?
# Debit old board
if old_board_id
Storage::Entry.record \
account: account,
board_id: old_board_id,
recordable: self,
delta: -current_bytes,
operation: "transfer_out"
end
# Credit new board
Storage::Entry.record \
account: account,
board: board,
recordable: self,
delta: current_bytes,
operation: "transfer_in"
end
end
# Override if needed. Default = all direct attachments
def attachments_for_storage
ActiveStorage::Attachment.where(record: self)
end
end
+10 -4
View File
@@ -1,13 +1,19 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :account
attribute :session, :user, :identity, :account
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
delegate :identity, to: :session, allow_nil: true
def session=(value)
super(value)
if value.present? && account.present?
if value.present?
self.identity = session.identity
end
end
def identity=(identity)
super(identity)
if identity.present?
self.user = identity.users.find_by(account: account)
end
end
+7
View File
@@ -1,6 +1,7 @@
class Identity < ApplicationRecord
include Joinable, Transferable
has_many :access_tokens, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
has_many :users, dependent: :nullify
@@ -13,6 +14,12 @@ class Identity < ApplicationRecord
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
def self.find_by_permissable_access_token(token, method:)
if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method)
access_token.identity
end
end
def send_magic_link(**attributes)
attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for)
+10
View File
@@ -0,0 +1,10 @@
class Identity::AccessToken < ApplicationRecord
belongs_to :identity
has_secure_token
enum :permission, %w[ read write ].index_by(&:itself), default: :read
def allows?(method)
method.in?(%w[ GET HEAD ]) || write?
end
end
+1
View File
@@ -1,5 +1,6 @@
class Push::Subscription < ApplicationRecord
PERMITTED_ENDPOINT_HOSTS = %w[
jmt17.google.com
fcm.googleapis.com
updates.push.services.mozilla.com
web.push.apple.com
+5
View File
@@ -0,0 +1,5 @@
module Storage
def self.table_name_prefix
"storage_"
end
end
+53
View File
@@ -0,0 +1,53 @@
module Storage::AttachmentTracking
extend ActiveSupport::Concern
included do
# Snapshot IDs in before_destroy since parent record may be deleted
# by the time after_destroy_commit runs
before_destroy :snapshot_storage_context
after_create_commit :record_storage_attach
after_destroy_commit :record_storage_detach
end
private
def record_storage_attach
return unless storage_tracked_record
Storage::Entry.record \
account: storage_tracked_record.account,
board: storage_tracked_record.board_for_storage_tracking,
recordable: storage_tracked_record,
blob: blob,
delta: blob.byte_size,
operation: "attach"
end
def record_storage_detach
return unless @storage_snapshot
Storage::Entry.record \
account_id: @storage_snapshot[:account_id],
board_id: @storage_snapshot[:board_id],
recordable_type: @storage_snapshot[:recordable_type],
recordable_id: @storage_snapshot[:recordable_id],
blob_id: @storage_snapshot[:blob_id],
delta: -blob.byte_size,
operation: "detach"
end
def snapshot_storage_context
return unless storage_tracked_record
@storage_snapshot = {
account_id: storage_tracked_record.account.id,
board_id: storage_tracked_record.board_for_storage_tracking&.id,
recordable_type: storage_tracked_record.class.name,
recordable_id: storage_tracked_record.id,
blob_id: blob.id
}
end
def storage_tracked_record
record.try(:storage_tracked_record)
end
end
+35
View File
@@ -0,0 +1,35 @@
class Storage::Entry < ApplicationRecord
belongs_to :account
belongs_to :board, optional: true
belongs_to :recordable, polymorphic: true, optional: true
scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id }
# Accepts either objects or _id params (for after_destroy_commit snapshots)
def self.record(delta:, operation:, account: nil, account_id: nil, board: nil, board_id: nil,
recordable: nil, recordable_type: nil, recordable_id: nil, blob: nil, blob_id: nil)
return if delta.zero?
account_id ||= account&.id
board_id ||= board&.id
blob_id ||= blob&.id
entry = create! \
account_id: account_id,
board_id: board_id,
recordable_type: recordable_type || recordable&.class&.name,
recordable_id: recordable_id || recordable&.id,
blob_id: blob_id,
delta: delta,
operation: operation,
user_id: Current.user&.id,
request_id: Current.request_id
# Enqueue materialization - use find_by to handle cascading deletes
# (Account/Board may be destroyed while attachments are still being cleaned up)
Account.find_by(id: account_id)&.materialize_storage_later
Board.find_by(id: board_id)&.materialize_storage_later if board_id
entry
end
end
+12
View File
@@ -0,0 +1,12 @@
class Storage::Total < ApplicationRecord
belongs_to :owner, polymorphic: true
def pending_entries
owner.storage_entries.pending(last_entry_id)
end
# Exact current usage (snapshot + pending)
def current_usage
bytes_stored + pending_entries.sum(:delta)
end
end
+11 -2
View File
@@ -14,12 +14,21 @@
<%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<%= form.number_field :usage_limit,
required: true, autofocus: true, min: @join_code.usage_count,
required: true, autofocus: true,
in: 0..Account::JoinCode::USAGE_LIMIT_MAX,
class: "input center txt-large fit-content font-weight-black txt-align-center", style: "max-inline-size: 8ch",
data: { action: "keydown.esc@document->form#cancel focus->form#select" } %>
<% if @join_code.errors.any? %>
<div class="txt-negative txt-small">
<% @join_code.errors.full_messages.each do |message| %>
<p class="margin-block-none"><%= message %></p>
<% end %>
</div>
<% end %>
<p class="margin-none txt-subtle">
This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times.
This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit_in_database %> times.
</p>
<%= form.button type: :submit, class: "btn btn--link center txt-medium", data: { form_target: "submit" } do %>
+2 -3
View File
@@ -1,8 +1,7 @@
json.cache! board do
json.(board, :id, :name, :all_access)
json.created_at board.created_at.utc
json.url board_url(board)
json.creator do
json.partial! "users/user", user: board.creator
end
json.creator board.creator, partial: "users/user", as: :user
end
@@ -0,0 +1 @@
json.array! @columns, partial: "columns/column", as: :column
@@ -0,0 +1 @@
json.partial! "columns/column", column: @column
+16 -16
View File
@@ -6,17 +6,17 @@
<% if board.published? %>
<div class="border-radius pad fill-selected">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: board_publication_path(board), method: :delete, data: { controller: "form" } do |form| %>
<%= form_with url: board_publication_path(board), method: :delete, class: "flex-inline center justify-between gap", data: { controller: "form" } do |form| %>
<label class="flex gap cursor-pointer">
<span class="txt-large"><%= icon_tag "lock" %></span>
<span class="switch flex align-center justify-between">
<%= form.check_box :published, class: "switch__input", checked: true, data: { action: "change->form#submit" }, disabled: !Current.user.can_administer_board?(@board) %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn off the public link</span>
<% end %>
</span>
<span class="for-screen-reader">Turn off the public link</span>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
<span class="txt-large" aria-hidden="true"><%= icon_tag "world" %></span>
<% end %>
<div class="flex align-center gap-half margin-block">
<%= text_field_tag :publication_url, published_board_url(board), readonly: true, class: "full-width input fill-white" %>
@@ -40,17 +40,17 @@
</div>
<% else %>
<div class="border-radius pad fill-shade">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: board_publication_path(board), method: :post, data: { controller: "form" } do |form| %>
<%= form_with url: board_publication_path(board), method: :post, class: "flex-inline center justify-between gap", data: { controller: "form" } do |form| %>
<span class="txt-large" aria-hidden="true"><%= icon_tag "lock" %></span>
<label class="flex gap cursor-pointer">
<span class="switch flex align-center justify-between">
<%= form.check_box :published, class: "switch__input", checked: false, data: { action: "change->form#submit" }, disabled: !Current.user.can_administer_board?(@board) %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn on the public link</span>
<% end %>
</span>
<span class="txt-large"><%= icon_tag "world" %></span>
<span class="for-screen-reader">Turn on the public link</span>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
<% end %>
</div>
<% end %>
<% end %>
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "boards/board", as: :board
+1
View File
@@ -0,0 +1 @@
json.partial! "boards/board", board: @board
+10 -16
View File
@@ -1,26 +1,20 @@
json.cache! [ card, card.column&.color ] do
json.(card, :id, :title, :status)
json.cache! card do
json.(card, :id, :number, :title, :status)
json.description card.description.to_plain_text
json.description_html card.description.to_s
json.image_url card.image.presence && url_for(card.image)
json.tags card.tags.pluck(:title).sort
json.golden card.golden?
json.last_active_at card.last_active_at.utc
json.created_at card.created_at.utc
json.url card_url(card)
json.board do
json.partial! "boards/board", locals: { board: card.board }
end
json.board card.board, partial: "boards/board", as: :board
json.column card.column, partial: "columns/column", as: :column if card.column
json.creator card.creator, partial: "users/user", as: :user
json.column do
if card.column
json.partial! "columns/column", column: card.column
else
nil
end
end
json.creator do
json.partial! "users/user", user: card.creator
end
json.comments_url card_comments_url(card)
end
@@ -9,9 +9,7 @@ json.cache! comment do
json.html comment.body.to_s
end
json.creator do
json.partial! "users/user", user: comment.creator
end
json.creator comment.creator, partial: "users/user", as: :user
json.reactions_url card_comment_reactions_url(comment.card_id, comment.id)
json.url card_comment_url(comment.card_id, comment.id)
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/comments/comment", as: :comment
@@ -0,0 +1,5 @@
json.cache! reaction do
json.(reaction, :id, :content)
json.reacter reaction.reacter, partial: "users/user", as: :user
json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction)
end
@@ -0,0 +1 @@
json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction
@@ -0,0 +1 @@
json.partial! "cards/comments/comment", comment: @comment
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
+2
View File
@@ -0,0 +1,2 @@
json.partial! "cards/card", card: @card
json.steps @card.steps, partial: "cards/steps/step", as: :step
@@ -0,0 +1,3 @@
json.cache! step do
json.(step, :id, :content, :completed)
end
+1
View File
@@ -0,0 +1 @@
json.partial! "cards/steps/step", step: @step
+4 -2
View File
@@ -1,2 +1,4 @@
json.(column, :id, :name, :color)
json.created_at column.created_at.utc
json.cache! column do
json.(column, :id, :name, :color)
json.created_at column.created_at.utc
end
@@ -0,0 +1,13 @@
<tr style="view-transition-name: <%= dom_id(access_token) %>">
<td><strong><%= access_token.description %></strong></td>
<td><%= access_token.permission.humanize %></td>
<td><%= local_datetime_tag access_token.created_at, style: :datetime %></td>
<td>
<%= button_to my_access_token_path(access_token), method: :delete,
class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent",
data: { turbo_confirm: "Are you sure you want to permanently revoke this access token?" } do %>
<%= icon_tag "trash" %>
<span class="for-screen-reader">Edit this token</span>
<% end %>
</td>
</tr>
+35
View File
@@ -0,0 +1,35 @@
<% @page_title = "Personal access tokens" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<section class="panel panel--wide shadow center webhooks">
<% if @access_tokens.any? %>
<p class="margin-none-block-start">Tokens you have generated that can be used to access the Fizzy API.</p>
<table class="access_tokens_table margin-block-end-double max-width txt-small">
<thead>
<tr>
<th>Description</th>
<th>Permission</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<%= render partial: "my/access_tokens/access_token", collection: @access_tokens %>
</tbody>
</table>
<% else %>
<p class="margin-none-block-start">Personal access tokens can be used like a password to access the Fizzy developer API. You can have as many tokens as you need and revoke access to each one at any time.</p>
<% end %>
<%= link_to new_my_access_token_path, class: "btn btn--link" do %>
<%= icon_tag "add" %>
<span>Generate a new access token</span>
<% end %>
</section>
+29
View File
@@ -0,0 +1,29 @@
<% @page_title = "Generate a personal access token" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<article class="panel panel--wide shadow center txt-align-start" style="view-transition-name: <%= dom_id(@access_token) %>">
<%= form_with model: @access_token, url: my_access_tokens_path, scope: :access_token, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<div class="flex flex-column gap-half">
<strong><%= form.label :description, "Access token description" %></strong>
<%= form.text_field :description, required: true, autofocus: true, class: "input", placeholder: "e.g. Github", data: { action: "keydown.esc@document->form#cancel" } %>
</div>
<div class="flex flex-column gap-half">
<strong><%= form.label :permission %></strong>
<%= form.select :permission, options_for_select({ "Read" => "read", "Read + Write" => "write"}), {}, class: "input input--select" %>
</div>
<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
<span>Generate access token</span>
<% end %>
<%= link_to "Cancel and go back", my_access_tokens_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
+26
View File
@@ -0,0 +1,26 @@
<% @page_title = "New personal access token" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<article class="panel panel--wide shadow center txt-align-start" style="view-transition-name: <%= dom_id(@access_token) %>">
<div class="flex flex-column gap">
<label class="flex flex-column gap-half txt-align-start">
<strong><%= @access_token.description %> (<%= @access_token.permission == "write" ? "Read + Write" : "Read" %>)</strong>
<input type="text" value="<%= @access_token.token %>" class="input" readonly>
</label>
<p class="margin-none txt-small">Be sure to save this access token now because you wont be able to see it again.</p>
<%= tag.button class: "btn btn--link center", data: {
controller: "copy-to-clipboard", action: "copy-to-clipboard#copy",
copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: @access_token.token } do %>
<%= icon_tag "copy-paste" %>
<span>Copy access token</span>
<% end %>
</div>
</article>
@@ -0,0 +1,4 @@
json.cache! account do
json.(account, :id, :name, :slug)
json.created_at account.created_at.utc
end
@@ -0,0 +1,4 @@
json.accounts @identity.users do |user|
json.partial! "my/identities/account", account: user.account
json.user user, partial: "users/user", as: :user
end
+4
View File
@@ -25,4 +25,8 @@
</div>
<%= yield %>
<div class="nav__blank-slate blank-slate blank-slate--empty">
Nothing matches that filter
</div>
</div>
@@ -0,0 +1,17 @@
json.cache! notification do
json.(notification, :id)
json.read notification.read?
json.read_at notification.read_at&.utc
json.created_at notification.created_at.utc
json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification
json.creator notification.creator, partial: "users/user", as: :user
json.card do
json.(notification.card, :id, :title, :status)
json.url card_url(notification.card)
end
json.url notification_url(notification)
end
@@ -0,0 +1 @@
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification
@@ -0,0 +1,2 @@
json.title event_notification_title(notification.source)
json.body event_notification_body(notification.source)
@@ -0,0 +1,4 @@
mention = notification.source
json.title "#{mention.mentioner.first_name} @mentioned you"
json.body mention.source.mentionable_content.truncate(200)
+5
View File
@@ -0,0 +1,5 @@
json.cache! tag do
json.(tag, :id, :title)
json.created_at tag.created_at.utc
json.url cards_url(tag_ids: [ tag ])
end
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "tags/tag", as: :tag
+8 -8
View File
@@ -1,13 +1,13 @@
<div class="flex flex-column align-center gap txt-medium--responsive txt-medium">
<div class="flex flex-column align-center gap">
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
<header class="full-width">
<h2 class="divider txt-large">Link a device</h2>
<p class="margin-none-block" id="session_transfer_label">Use this link to sign-in on another device</p>
</header>
<label class="flex flex-column gap full-width">
<div class="flex align-center gap justify-center">
<strong id="session_transfer_label" class="txt-medium">Link to automatically log in on another device</strong>
</div>
<span class="flex align-center gap margin-inline">
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
</span>
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
</label>
<div class="flex align-center gap">
@@ -34,4 +34,4 @@
<span class="for-screen-reader">Copy auto-login link</span>
<% end %>
</div>
</div>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
json.cache! user do
json.(user, :id, :name, :role, :active)
json.email_address user.identity.email_address
json.email_address user.identity&.email_address
json.created_at user.created_at.utc
json.url user_url(user)
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "users/user", as: :user
+13 -3
View File
@@ -32,9 +32,9 @@
<% if @user.verified? %>
<div class="flex-inline center justify-center flex-wrap gap">
<%= link_to "Which cards are assigned to #{me_or_you}?",
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
<%= link_to "Which cards were added by #{me_or_you}?",
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
</div>
<% end %>
</div>
@@ -44,9 +44,19 @@
<section class="panel shadow" style="--panel-size: 45ch;">
<%= render "users/transfer", user: @user %>
<div class="flex flex-column align-center gap margin-block-start-double">
<header class="full-width">
<h2 class="divider txt-large margin-none-block">API</h2>
</header>
<div class="flex align-center gap txt-normal">
<%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %>
</div>
</div>
<div class="center margin-block-start-double">
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %>
<span>Sign out</span>
<span>Sign out of Fizzy</span>
<% end %>
</div>
</section>
+1
View File
@@ -0,0 +1 @@
json.partial! "users/user", user: @user
+1 -1
View File
@@ -15,7 +15,7 @@
<div class="flex flex-column gap-half">
<%= form.label :actions do %>
<strong>Events</strong><br>
<p class="margin-none txt-x-small txt-subtle">Trigger a call to the Payload URL when these things occur:</p>
<p class="margin-none txt-x-small txt-subtle">Trigger a call to the Payload URL when:</p>
<% end %>
<%= render "webhooks/form/actions", form: form %>
</div>
+2 -7
View File
@@ -9,11 +9,6 @@ json.cache! @event do
end
end
json.board do
json.partial! "boards/board", locals: { board: @event.board }
end
json.creator do
json.partial! "users/user", user: @event.creator
end
json.board @event.board, partial: "boards/board", as: :board
json.creator @event.creator, partial: "users/user", as: :user
end
+7 -6
View File
@@ -4,13 +4,14 @@
webhook_action_options,
:first,
:last do |item| %>
<li class="list-style-none margin-none flex align-center gap">
<label class="switch toggler__visible-when-off flex-item-no-shrink txt-x-small">
<%= item.check_box(class: "switch__input") %>
<span class="switch__btn round"></span>
<span class="for-screen-reader"><%= item.text %></span>
<li class="list-style-none margin-none">
<label class="toggler__visible-when-off flex align-center gap cursor-pointer">
<span class="switch txt-x-small flex-item-no-shrink">
<%= item.check_box(class: "switch__input") %>
<span class="switch__btn round"></span>
</span>
<span class="min-width"><%= item.text %></span>
</label>
<span><%= item.text %></span>
</li>
<% end %>
</ul>

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