Merge branch 'main' into single_tenant
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -30,4 +30,3 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 14
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,4 +6,6 @@ paths = [
|
||||
'''log''',
|
||||
'''tmp''',
|
||||
'''.*\.yml\.enc''',
|
||||
'''docs/''',
|
||||
'''test/''',
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -295,6 +295,10 @@
|
||||
|
||||
max-height: 200px;
|
||||
overflow: scroll;
|
||||
|
||||
&:is(.lexxy-prompt-menu--visible) {
|
||||
padding: var(--lexxy-prompt-padding);
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-prompt-menu--visible {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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?
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,10 +10,19 @@ class CardsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
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
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 +2,17 @@ class Notifications::BulkReadingsController < ApplicationController
|
||||
def create
|
||||
Current.user.notifications.unread.read_all
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
def from_tray?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class TagsController < ApplicationController
|
||||
def index
|
||||
set_page_and_extract_portion_from Current.account.tags.alphabetically
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
class Storage::ReconcileJob < ApplicationJob
|
||||
queue_as :backend
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(owner)
|
||||
owner.reconcile_storage
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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? }
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module Storage
|
||||
def self.table_name_prefix
|
||||
"storage_"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
@@ -6,17 +6,17 @@
|
||||
|
||||
<% if board.published? %>
|
||||
<div class="border-radius pad fill-selected">
|
||||
<div class="flex-inline center justify-between gap">
|
||||
<%= 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>
|
||||
<label class="switch flex align-center justify-between">
|
||||
<%= form_with url: board_publication_path(board), method: :delete, data: { controller: "form" } do |form| %>
|
||||
<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>
|
||||
<span class="for-screen-reader">Turn off the public link</span>
|
||||
<% end %>
|
||||
</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 %>
|
||||
</label>
|
||||
</span>
|
||||
<span class="txt-large"><%= icon_tag "world" %></span>
|
||||
</div>
|
||||
<span class="for-screen-reader">Turn on the public link</span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @page.records, partial: "boards/board", as: :board
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! "boards/board", board: @board
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @page.records, partial: "cards/card", as: :card
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! "cards/steps/step", step: @step
|
||||
@@ -1,2 +1,4 @@
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 won’t 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @page.records, partial: "tags/tag", as: :tag
|
||||
@@ -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>
|
||||
</label>
|
||||
|
||||
<div class="flex align-center gap">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @page.records, partial: "users/user", as: :user
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! "users/user", user: @user
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
<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 class="for-screen-reader"><%= item.text %></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
Reference in New Issue
Block a user