Merge branch 'main' into update-columns-on-actions
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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
<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 %>
|
||||
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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.(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>
|
||||
@@ -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,4 +0,0 @@
|
||||
json.message "File uploaded successfully"
|
||||
json.fileName @upload.filename.to_s
|
||||
json.mimetype @upload.content_type
|
||||
json.fileUrl @upload.slug_url
|
||||
@@ -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,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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user