Merge branch 'main' into delete-account-improvements

This commit is contained in:
Alexander Zaytsev
2026-03-09 15:48:21 +01:00
17 changed files with 228 additions and 11 deletions
@@ -5,10 +5,20 @@ class Boards::PublicationsController < ApplicationController
def create
@board.publish
respond_to do |format|
format.turbo_stream
format.json { render partial: "boards/board", locals: { board: @board }, status: :created }
end
end
def destroy
@board.unpublish
@board.reload
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end
+11 -2
View File
@@ -15,9 +15,18 @@ class My::AccessTokensController < ApplicationController
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)
respond_to do |format|
format.html do
expiring_id = verifier.generate access_token.id, expires_in: 10.seconds
redirect_to my_access_token_path(expiring_id)
end
format.json do
render status: :created, json: \
{ token: access_token.token, description: access_token.description, permission: access_token.permission }
end
end
end
def destroy
@@ -1,5 +1,5 @@
class Public::Boards::Columns::ClosedsController < Public::BaseController
def show
set_page_and_extract_portion_from @board.cards.closed.recently_closed_first
set_page_and_extract_portion_from @board.cards.closed.published.recently_closed_first
end
end
@@ -54,6 +54,12 @@ export default class extends Controller {
}
}
frameColumnOnMobile(event) {
if (!this.#isDesktop) {
event.currentTarget.scrollIntoView({ behavior: "smooth", inline: "center" })
}
}
async #restoreColumnsDisablingTransitions() {
this.#disableTransitions()
this.#restoreColumns()
@@ -7,7 +7,8 @@ export default class extends Controller {
static values = {
modal: { type: Boolean, default: false },
sizing: { type: Boolean, default: true },
autoOpen: { type: Boolean, default: false }
autoOpen: { type: Boolean, default: false },
orient: { type: Boolean, default: true }
}
connect() {
@@ -26,7 +27,9 @@ export default class extends Controller {
this.dialogTarget.showModal()
} else {
this.dialogTarget.show()
orient({ target: this.dialogTarget, anchor: this.element })
if (this.orientValue) {
orient({ target: this.dialogTarget, anchor: this.element })
}
}
this.loadLazyFrames()
+2
View File
@@ -4,4 +4,6 @@ json.cache! board do
json.url board_url(board)
json.creator board.creator, partial: "users/user", as: :user
json.public_url published_board_url(board) if board.published?
end
+1 -1
View File
@@ -4,7 +4,7 @@
drag_and_strum_target: "container",
collapsible_columns_target: "column",
controller: "clicker",
action: "turbo:before-stream-render@document->collapsible-columns#restoreState focus->collapsible-columns#focusOnColumn"
action: "turbo:before-stream-render@document->collapsible-columns#restoreState focus->collapsible-columns#focusOnColumn dialog:show->collapsible-columns#frameColumnOnMobile"
} do %>
<header class="cards__header">
<%= render "boards/show/menu/column", column: column %>
+1 -1
View File
@@ -1,4 +1,4 @@
<div id="<%= dom_id(column, :menu) %>" class="cards__menu" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<div id="<%= dom_id(column, :menu) %>" class="cards__menu" data-controller="dialog" data-dialog-orient-value="false" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<button class="btn btn--circle txt-x-small borderless" data-action="click->dialog#open">
<%= icon_tag "menu-dots-horizontal", class: "translucent" %>
<span class="for-screen-reader">Column options</span>
@@ -4,7 +4,7 @@
<p class="subtitle">Please enter this 6-character verification code on the Fizzy sign-in page:</p>
<% else %>
<h1 class="title">Welcome to Fizzy!</h1>
<p class="subtitle">Please enter this 6-character verification code on the Fizzy sign-up page to create your new account:</p>
<p class="subtitle">Please enter this 6-character verification code to finish creating your account:</p>
<% end %>
<strong class="txt-large"><%= @magic_link.code %></strong>
@@ -1,7 +1,7 @@
<% if @magic_link.for_sign_in? %>
Please enter this 6-character verification code on the Fizzy sign-in page:
<% else %>
Please enter this 6-character verification code on the Fizzy sign-up page to create your new account:
Please enter this 6-character verification code to finish creating your account:
<% end %>
<%= @magic_link.code %>
@@ -5,7 +5,7 @@
<div class="cards__transition-container">
<header class="cards__header">
<%= render "boards/show/expander", title: "Done", count: board.cards.closed.count, column_id: "closed-cards" %>
<%= render "boards/show/expander", title: "Done", count: board.cards.closed.published.count, column_id: "closed-cards" %>
<%= link_to public_board_columns_closed_url(board.publication.key), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= icon_tag "grid", class: "translucent" %>
+90 -1
View File
@@ -134,6 +134,48 @@ __Response:__
Returns `204 No Content` on success.
#### Create an access token via the API
You can programmatically create a personal access token using either a session cookie or an existing Bearer token:
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "Cookie: session_token=eyJfcmFpbHMi..." \
-d '{"access_token": {"description": "Fizzy CLI", "permission": "write"}}' \
https://app.fizzy.do/1234567/my/access_tokens
```
Or with a Bearer token (must have `write` permission):
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "Authorization: Bearer put-your-access-token-here" \
-d '{"access_token": {"description": "Fizzy CLI", "permission": "write"}}' \
https://app.fizzy.do/1234567/my/access_tokens
```
The `permission` field accepts `read` or `write`.
__Response:__
```
HTTP/1.1 201 Created
```
```json
{
"token": "4f9Q6d2wXr8Kp1Ls0Vz3BnTa",
"description": "Fizzy CLI",
"permission": "write"
}
```
Store the `token` value securely — it won't be retrievable again. Use it as a Bearer token for subsequent API requests.
## Caching
Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data.
@@ -427,10 +469,13 @@ __Response:__
"email_address": "david@example.com",
"created_at": "2025-12-05T19:36:35.401Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
}
},
"public_url": "http://fizzy.localhost:3006/897362094/public/boards/aB3dEfGhIjKlMnOp"
}
```
The `public_url` field is only present when the board is published.
### `POST /:account_slug/boards`
Creates a new Board in the account.
@@ -502,6 +547,50 @@ __Response:__
Returns `204 No Content` on success.
## Board Publications
Publishing a board makes it publicly accessible via a shareable link, without requiring authentication. Only board administrators can publish or unpublish a board.
### `POST /:account_slug/boards/:board_id/publication`
Publishes a board, generating a shareable public link.
__Response:__
```
HTTP/1.1 201 Created
```
```json
{
"id": "03f5v9zkft4hj9qq0lsn9ohcm",
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
"name": "David Heinemeier Hansson",
"role": "owner",
"active": true,
"email_address": "david@example.com",
"created_at": "2025-12-05T19:36:35.401Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
},
"public_url": "http://fizzy.localhost:3006/897362094/public/boards/aB3dEfGhIjKlMnOp"
}
```
If the board is already published, the existing publication is returned.
### `DELETE /:account_slug/boards/:board_id/publication`
Unpublishes a board, removing public access.
__Response:__
Returns `204 No Content` on success.
## Cards
Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments.
@@ -27,6 +27,31 @@ class Boards::PublicationsControllerTest < ActionDispatch::IntegrationTest
assert_turbo_stream action: :replace, target: dom_id(@board, :publication)
end
test "publish a board via JSON" do
assert_not @board.published?
assert_changes -> { @board.reload.published? }, from: false, to: true do
post board_publication_path(@board), as: :json
end
assert_response :created
body = @response.parsed_body
@board.reload
assert_equal @board.name, body["name"]
assert_equal published_board_url(@board), body["public_url"]
end
test "unpublish a board via JSON" do
@board.publish
assert @board.published?
assert_changes -> { @board.reload.published? }, from: true, to: false do
delete board_publication_path(@board), as: :json
end
assert_response :no_content
end
test "publish requires board admin permission" do
logout_and_sign_in_as :jz
@@ -250,6 +250,24 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
assert_equal boards(:writebook).name, @response.parsed_body["name"]
end
test "show as JSON includes public_url when published" do
board = boards(:writebook)
board.publish
get board_path(board), as: :json
assert_response :success
assert_equal published_board_url(board), @response.parsed_body["public_url"]
end
test "show as JSON excludes public_url when not published" do
board = boards(:writebook)
assert_not board.published?
get board_path(board), as: :json
assert_response :success
assert_nil @response.parsed_body["public_url"]
end
test "create as JSON" do
assert_difference -> { Board.count }, +1 do
post boards_path, params: { board: { name: "My new board" } }, as: :json
@@ -19,6 +19,41 @@ class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest
end
end
test "create new token via JSON with session" do
assert_difference -> { identities(:kevin).access_tokens.count }, +1 do
post my_access_tokens_path, params: { access_token: { description: "Fizzy CLI", permission: "write" } }, as: :json
end
assert_response :created
body = @response.parsed_body
assert body["token"].present?
assert_equal "Fizzy CLI", body["description"]
assert_equal "write", body["permission"]
end
test "create new token via JSON with bearer token" do
sign_out
bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" }
assert_difference -> { identities(:david).access_tokens.count }, +1 do
post my_access_tokens_path, params: { access_token: { description: "Fizzy CLI", permission: "read" } }, env: bearer_token, as: :json
end
assert_response :created
body = @response.parsed_body
assert body["token"].present?
assert_equal "Fizzy CLI", body["description"]
assert_equal "read", body["permission"]
end
test "cannot create new token via JSON with read-only bearer token" do
sign_out
bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:jasons_api_token).token}" }
assert_no_difference -> { identities(:jason).access_tokens.count } do
post my_access_tokens_path, params: { access_token: { description: "Fizzy CLI", permission: "read" } }, env: bearer_token, as: :json
end
assert_response :unauthorized
end
test "accessing new token after reveal window redirects to index" do
assert_changes -> { identities(:kevin).access_tokens.count }, +1 do
post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } }
@@ -9,4 +9,14 @@ class Public::Boards::Columns::ClosedsControllerTest < ActionDispatch::Integrati
get public_board_columns_closed_path(boards(:writebook).publication.key)
assert_response :success
end
test "show excludes draft cards" do
draft_card = cards(:buy_domain)
draft_card.update!(status: :drafted)
Current.set(user: users(:david)) { draft_card.close }
get public_board_columns_closed_path(boards(:writebook).publication.key)
assert_response :success
assert_not_includes response.body, draft_card.title
end
end
@@ -21,6 +21,16 @@ class Public::BoardsControllerTest < ActionDispatch::IntegrationTest
assert_response :not_found
end
test "show excludes draft cards from closed count" do
draft_card = cards(:buy_domain)
draft_card.update!(status: :drafted)
Current.set(user: users(:david)) { draft_card.close }
get published_board_path(boards(:writebook))
assert_response :success
assert_select ".cards--closed .cards__expander-count", "1"
end
test "show works without authentication" do
sign_out
get published_board_path(boards(:writebook))