Merge branch 'main' into update-columns-on-actions

This commit is contained in:
Tomas Costantino
2025-12-11 10:17:15 +01:00
164 changed files with 4347 additions and 389 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
+3 -2
View File
@@ -1,8 +1,9 @@
source "https://rubygems.org"
gem "rails", github: "rails/rails", branch: "main"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
gem "rails", github: "rails/rails", branch: "ast-immediate-variants-process-locally"
# Assets & front end
gem "importmap-rails"
gem "propshaft"
@@ -27,7 +28,7 @@ gem "rqrcode"
gem "redcarpet"
gem "rouge"
gem "jbuilder"
gem "lexxy"
gem "lexxy", bc: "lexxy"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
+10 -5
View File
@@ -1,3 +1,10 @@
GIT
remote: https://github.com/basecamp/lexxy
revision: d292280b6d2c5d8a924327adf8bb9f0b25953539
specs:
lexxy (0.1.23.beta)
rails (>= 8.0.2)
GIT
remote: https://github.com/basecamp/useragent
revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e
@@ -6,8 +13,8 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
branch: main
revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
branch: ast-immediate-variants-process-locally
specs:
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
@@ -237,8 +244,6 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lexxy (0.1.23.beta)
rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
@@ -518,7 +523,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
lexxy
lexxy!
mission_control-jobs
mittens
mocha
+11 -6
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
@@ -41,6 +41,13 @@ GIT
yabeda-puma-plugin
yabeda-rails (>= 0.10)
GIT
remote: https://github.com/basecamp/lexxy
revision: d292280b6d2c5d8a924327adf8bb9f0b25953539
specs:
lexxy (0.1.23.beta)
rails (>= 8.0.2)
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2
@@ -75,8 +82,8 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
branch: main
revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
branch: ast-immediate-variants-process-locally
specs:
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
@@ -313,8 +320,6 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lexxy (0.1.23.beta)
rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
@@ -653,7 +658,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
lexxy
lexxy!
mission_control-jobs
mittens
mocha
+22 -5
View File
@@ -14,7 +14,7 @@ This repo contains a starter deployment file that you can modify for your own sp
The steps to configure your very own Fizzy are:
1. Fork the repo
2. Edit few things in config/deploy.yml, .kamal/secrets, and config/environments/production.rb
2. Edit few things in config/deploy.yml and .kamal/secrets
3. Run `kamal setup` to do your first deploy.
We'll go through each of these in turn.
@@ -37,6 +37,7 @@ We've added comments to that file to highlight what each setting needs to be, bu
- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here.
- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off.
- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.
- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this.
Fizzy also requires a few environment variables to be set up, some of which contain secrets.
The simplest way to do this is to put them in a file called `.kamal/secrets`.
@@ -58,6 +59,7 @@ SMTP_PASSWORD=email-provider-password
The values you enter here will be specific to you, and you can get or create them as follows:
- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this.
- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use.
- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with:
```sh
@@ -73,10 +75,6 @@ The values you enter here will be specific to you, and you can get or create the
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
- `SMTP_USERNAME`/`SMTP_PASSWORD` are credentials you should get from your email provider.
Lastly, you'll need to set up the rest of your email configuration in `config/environments/production.rb`. There is an example configuration in comments at the top of that file. The actual settings you use here will depend on your email provider, but in most cases will look similar to that section, so you can uncomment it and edit to suit. Note that it will use the `SMTP_USERNAME` and `SMTP_PASSWORD` values you entered in your secrets.
Once you've made all those changes, commit them to your fork so they're saved.
### Deploy Fizzy!
@@ -95,6 +93,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
@@ -5,13 +5,7 @@ class Users::PushSubscriptionsController < ApplicationController
end
def create
if subscription = @push_subscriptions.find_by(push_subscription_params)
subscription.touch
else
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
end
head :ok
@push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params)
end
def destroy
+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 -3
View File
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Entropic, Seedeable
include Account::Storage, Entropic, Seedeable
has_one :join_code
has_many :users, dependent: :destroy
@@ -10,8 +10,6 @@ class Account < ApplicationRecord
has_many :columns, dependent: :destroy
has_many :exports, class_name: "Account::Export", dependent: :destroy
has_many_attached :uploads
before_create :assign_external_account_id
after_create :create_join_code
+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
+18 -3
View File
@@ -1,7 +1,10 @@
class Webhook::Delivery < ApplicationRecord
class ResponseTooLarge < StandardError; end
STALE_TRESHOLD = 7.days
USER_AGENT = "fizzy/1.0.0 Webhook"
ENDPOINT_TIMEOUT = 7.seconds
MAX_RESPONSE_SIZE = 100.kilobytes
belongs_to :account, default: -> { webhook.account }
belongs_to :webhook
@@ -52,12 +55,16 @@ class Webhook::Delivery < ApplicationRecord
if resolved_ip.nil?
{ error: :private_uri }
else
response = http.request(
Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
)
request = Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
response = http.request(request) do |net_http_response|
stream_body_with_limit(net_http_response)
end
{ code: response.code.to_i }
end
rescue ResponseTooLarge
{ error: :response_too_large }
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
{ error: :dns_lookup_failed }
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
@@ -68,6 +75,14 @@ class Webhook::Delivery < ApplicationRecord
{ error: :failed_tls }
end
def stream_body_with_limit(response)
bytes_read = 0
response.read_body do |chunk|
bytes_read += chunk.bytesize
raise ResponseTooLarge if bytes_read > MAX_RESPONSE_SIZE
end
end
def resolved_ip
return @resolved_ip if defined?(@resolved_ip)
@resolved_ip = SsrfProtection.resolve_public_ip(uri.host)
+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 -1
View File
@@ -6,6 +6,6 @@
<% else %>
<%= button_to card_goldness_path(card), class: "btn", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click" } do %>
<%= icon_tag "golden-ticket" %>
<span class="for-screen-reader">Promote to golden (shift+g)</span>
<span class="for-screen-reader">Promote to Golden Ticket (shift+g)</span>
<% end %>
<% end %>
+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
-4
View File
@@ -1,4 +0,0 @@
json.message "File uploaded successfully"
json.fileName @upload.filename.to_s
json.mimetype @upload.content_type
json.fileUrl @upload.slug_url
+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

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