Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
d787971
Add API to boards
dhh Dec 1, 2025
60cd429
Merge branch 'main' into basic-api
dhh Dec 1, 2025
237f02b
Add access token authentication via HTTP AUTHORIZATION bearer header
dhh Dec 1, 2025
155bd0b
Merge branch 'main' into basic-api
dhh Dec 1, 2025
982a1dd
Test the boards API
dhh Dec 1, 2025
27fca3e
API index for cards
dhh Dec 1, 2025
95708c7
Correct
dhh Dec 1, 2025
a7464f6
Tie access token directly to session
dhh Dec 1, 2025
4db45db
Add developer section to user profile
jzimdars Dec 2, 2025
95f89bf
List, create, and revoke access tokens
jzimdars Dec 2, 2025
367f45b
Authenticate api requests without needing a session
dhh Dec 2, 2025
350d349
Merge branch 'main' into basic-api
dhh Dec 2, 2025
88ea7d1
Drop the need for access tokens to have a session
dhh Dec 2, 2025
05f301f
Handle everything in the same method
dhh Dec 2, 2025
5067a36
Inline now anemic helper methods
dhh Dec 2, 2025
3ac7c82
Clarify
dhh Dec 2, 2025
a47c8f4
The magic of it is not needing to manually yield it!
dhh Dec 2, 2025
a47bd51
This had gotten stripped
dhh Dec 2, 2025
1b84b69
Smooth out the finder API
dhh Dec 2, 2025
b512c8c
Access tokens are strictly personal
dhh Dec 2, 2025
db226a7
Inline anemic partial
dhh Dec 2, 2025
d2b849c
Only allow new token to be viewed within 10 seconds
dhh Dec 2, 2025
9db0b84
Polish
dhh Dec 2, 2025
d889127
Awaiting JZ's design
dhh Dec 2, 2025
25ca9ea
Only allow writing when the access token has permission
dhh Dec 2, 2025
eefbf58
Allow API JSON requests to sidestep csrf protection
dhh Dec 2, 2025
9832b1f
Creating a new board will return the location header
dhh Dec 2, 2025
c4feffa
Return json URLs for API actions
dhh Dec 2, 2025
89f5e73
Create cards via API
dhh Dec 2, 2025
f608bfd
Design show view
jzimdars Dec 2, 2025
684ec3d
Complete the view transition loop
jzimdars Dec 2, 2025
0c60e27
Use built-in authenticate_or_request_with_http_token
dhh Dec 3, 2025
3bfce80
Add API support for users
jayohms Dec 3, 2025
673e06e
Add top-level API index support for tags
jayohms Dec 3, 2025
d3cdb01
Merge branch 'main' into basic-api
dhh Dec 3, 2025
cbc24e7
Merge branch 'main' into basic-api
dhh Dec 3, 2025
71ba999
Excess whitespace
dhh Dec 3, 2025
9567a07
Only authenticate with bearer token if the header is present
dhh Dec 3, 2025
13a471b
Compact
dhh Dec 3, 2025
bd5b46b
Publish any API card as soon as it is created
dhh Dec 3, 2025
748be87
Include card description and tags
dhh Dec 3, 2025
9d89967
Fix quoting
dhh Dec 3, 2025
be4f9ff
Add an /identity.json endpoint to obtain the identity accounts and users
jayohms Dec 4, 2025
528258b
Fix identity tests
monorkin Dec 4, 2025
7866537
Fix Current not setting a session in some contexts
monorkin Dec 4, 2025
591f290
Move tests into their controller tests
monorkin Dec 5, 2025
21b1075
Add card update & delete actions
monorkin Dec 5, 2025
0db9614
Add API for assigning cards
monorkin Dec 5, 2025
2fe4891
Add API for mobing cards between boards
monorkin Dec 5, 2025
ac656fe
Add API for closing and opening cards
monorkin Dec 5, 2025
8d53b3d
Add API for comments CRUD
monorkin Dec 5, 2025
4e55b04
Add API for gilding cards
monorkin Dec 5, 2025
2a3c529
Add API for removing card images
monorkin Dec 5, 2025
803d9cd
Add API for postponing cards
monorkin Dec 5, 2025
f0e6258
Add API for CRUD actions on steps
monorkin Dec 5, 2025
44d8051
Add API for tagging cards
monorkin Dec 5, 2025
ec1348f
Add API for card triage
monorkin Dec 5, 2025
0d96d35
Add API for watching cards
monorkin Dec 5, 2025
5019bb5
Add API for reactions
monorkin Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/assets/stylesheets/access-tokens.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 5 additions & 1 deletion app/controllers/boards_controller.rb
Original file line number Diff line number Diff line change
@@ -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 ]

def index
@boards = Current.user.boards
end

def show
if @filter.used?(ignore_boards: true)
show_filtered_cards
Expand Down
16 changes: 14 additions & 2 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def disallow_account_scope(**options)

private
def authenticated?
Current.session.present?
Current.identity.present?
end

def require_account
Expand All @@ -42,7 +42,7 @@ def require_account
end

def require_authentication
resume_session || request_authentication
resume_session || authenticate_by_bearer_token || request_authentication
end

def resume_session
Expand All @@ -55,6 +55,18 @@ def find_session_by_cookie
Session.find_signed(cookies.signed[:session_token])
end

def authenticate_by_bearer_token
authorization_scheme, bearer_token = request.authorization.to_s.split(" ", 2)

if authorization_scheme == "Bearer" && bearer_token.present?
if access_token = Identity::AccessToken.find_by(token: bearer_token)
Current.identity = access_token.identity
else
head :unauthorized
end
end
end

def request_authentication
if Current.account.present?
session[:return_to_after_authenticating] = request.url
Expand Down
35 changes: 35 additions & 0 deletions app/controllers/users/access_tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class Users::AccessTokensController < ApplicationController
before_action :set_user
before_action :set_access_token, except: %i[ index new create ]

def index
set_page_and_extract_portion_from @user.identity.access_tokens.order(created_at: :desc)
end

def new
@access_token = @user.identity.access_tokens.new
end

def create
@access_token = @user.identity.access_tokens.create!(access_token_params)
redirect_to user_access_tokens_path(@user)
end

def destroy
@access_token.destroy!
redirect_to user_access_tokens_path(@user)
end

private
def set_user
@user = Current.account.users.active.find(params[:user_id])
end

def set_access_token
@access_token = @user.identity.access_tokens.find(params[:id])
end

def access_token_params
params.expect(access_token: [ :description, :permission ])
end
end
4 changes: 4 additions & 0 deletions app/helpers/users_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ def role_display_name(user)
else user.role.titleize
end
end

def access_token_permission_options
Identity::AccessToken.permissions.keys.map { [ it.humanize, it ] }
end
end
10 changes: 9 additions & 1 deletion app/models/current.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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
Expand All @@ -8,6 +8,14 @@ def session=(value)
super(value)

if value.present? && account.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
Expand Down
1 change: 1 addition & 0 deletions app/models/identity.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/models/identity/access_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Identity::AccessToken < ApplicationRecord
belongs_to :identity

has_secure_token
enum :permission, %w[ read write ].index_by(&:itself), default: :read
end
1 change: 1 addition & 0 deletions app/views/boards/_board.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,6 +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
Expand Down
1 change: 1 addition & 0 deletions app/views/boards/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
json.array! @boards, partial: "boards/board", as: :board
1 change: 1 addition & 0 deletions app/views/boards/show.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
json.partial! "boards/board", board: @board
3 changes: 3 additions & 0 deletions app/views/cards/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
json.array! @page.records, partial: "cards/card", as: :card

json.next_page_url cards_path(@board, page: @page.next_param) unless @page.last?
9 changes: 9 additions & 0 deletions app/views/users/_developer.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div class="flex flex-column align-center gap margin-block-start-double">
<header class="full-width">
<h2 class="divider txt-large margin-none-block">Developer</h2>
</header>

<div class="flex align-center gap txt-normal">
<%= link_to "Personal access tokens", user_access_tokens_path(user), class: "btn" %>
</div>
</div>
16 changes: 8 additions & 8 deletions app/views/users/_transfer.html.erb
Original file line number Diff line number Diff line change
@@ -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">
Expand All @@ -34,4 +34,4 @@
<span class="for-screen-reader">Copy auto-login link</span>
<% end %>
</div>
</div>
</div>
13 changes: 13 additions & 0 deletions app/views/users/access_tokens/_access_token.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<tr>
<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 user_access_token_path(@user, 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>
37 changes: 37 additions & 0 deletions app/views/users/access_tokens/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<% @page_title = "Personal access tokens" %>

<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "My profile", user_path(@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 @page.used? %>
<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>
<%= with_automatic_pagination :access_tokens, @page do %>
<%= render partial: "users/access_tokens/access_token", collection: @page.records %>
<% end %>
</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_user_access_token_path(@user), class: "btn btn--link" do %>
<%= icon_tag "add" %>
<span>Generate a new access token</span>
<% end %>
</section>
29 changes: 29 additions & 0 deletions app/views/users/access_tokens/new.html.erb
Original file line number Diff line number Diff line change
@@ -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", user_access_tokens_path(@user), "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: user_access_tokens_path(@user), 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(access_token_permission_options), {}, 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", user_access_tokens_path(@user), data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
7 changes: 4 additions & 3 deletions app/views/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,21 @@

<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>
</div>
</section>

<% if Current.user == @user %>
<section class="panel shadow" style="--panel-size: 45ch;">
<%= render "users/transfer", user: @user %>
<%= render "users/developer", user: @user %>

<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>
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
resource :events

resources :push_subscriptions
resources :access_tokens

resources :email_addresses, param: :token do
resource :confirmation, module: :email_addresses
Expand Down
11 changes: 1 addition & 10 deletions db/cable_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,5 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.2].define(version: 1) do
create_table "solid_cable_messages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false
t.bigint "channel_hash", null: false
t.datetime "created_at", null: false
t.binary "payload", size: :long, null: false
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
end
ActiveRecord::Schema[8.2].define(version: 0) do
end
14 changes: 14 additions & 0 deletions db/migrate/20251201132341_create_identity_access_tokens.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2]
def change
create_table :identity_access_tokens, id: :uuid do |t|
t.uuid :identity_id, null: false
t.string :token
t.string :permission
t.text :description

t.timestamps

t.index ["identity_id"], name: "index_access_token_on_identity_id"
end
end
end
12 changes: 11 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading