Merge pull request #1766 from basecamp/basic-api

Add basic API
This commit is contained in:
Stanko Krtalić
2025-12-10 15:43:58 +01:00
committed by GitHub
93 changed files with 2361 additions and 84 deletions
+8
View File
@@ -6,6 +6,14 @@
# Ignore bundler config.
/.bundle
# Ignore documentation
/docs/
/README.md
/CLAUDE.md
/AGENTS.md
/STYLE.md
/CONTRIBUTING.md
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
+19
View File
@@ -0,0 +1,19 @@
.access_tokens_table {
border-collapse: collapse;
inline-size: 100%;
td, th {
border-block-end: 1px solid var(--color-ink-light);
padding-inline: var(--inline-space);
text-align: start;
}
th {
font-size: var(--text-x-small);
text-transform: uppercase;
}
tr:nth-of-type(even) {
background-color: var(--color-ink-lightest);
}
}
@@ -3,6 +3,11 @@ class Boards::ColumnsController < ApplicationController
before_action :set_column, only: %i[ show update destroy ]
def index
@columns = @board.columns.sorted
fresh_when etag: @columns
end
def show
set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded
fresh_when etag: @page.records
@@ -10,14 +15,29 @@ class Boards::ColumnsController < ApplicationController
def create
@column = @board.columns.create!(column_params)
respond_to do |format|
format.turbo_stream
format.json { head :created, location: board_column_path(@board, @column, format: :json) }
end
end
def update
@column.update!(column_params)
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@column.destroy
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
+24 -7
View File
@@ -1,9 +1,13 @@
class BoardsController < ApplicationController
include FilterScoped
before_action :set_board, except: %i[ new create ]
before_action :set_board, except: %i[ index new create ]
before_action :ensure_permission_to_admin_board, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from Current.user.boards
end
def show
if @filter.used?(ignore_boards: true)
show_filtered_cards
@@ -18,7 +22,11 @@ class BoardsController < ApplicationController
def create
@board = Board.create! board_params.with_defaults(all_access: true)
redirect_to board_path(@board)
respond_to do |format|
format.html { redirect_to board_path(@board) }
format.json { head :created, location: board_path(@board, format: :json) }
end
end
def edit
@@ -31,16 +39,25 @@ class BoardsController < ApplicationController
@board.update! board_params
@board.accesses.revise granted: grantees, revoked: revokees if grantees_changed?
if @board.accessible_to?(Current.user)
redirect_to edit_board_path(@board), notice: "Saved"
else
redirect_to root_path, notice: "Saved (you were removed from the board)"
respond_to do |format|
format.html do
if @board.accessible_to?(Current.user)
redirect_to edit_board_path(@board), notice: "Saved"
else
redirect_to root_path, notice: "Saved (you were removed from the board)"
end
end
format.json { head :no_content }
end
end
def destroy
@board.destroy
redirect_to root_path
respond_to do |format|
format.html { redirect_to root_path }
format.json { head :no_content }
end
end
private
@@ -9,5 +9,10 @@ class Cards::AssignmentsController < ApplicationController
def create
@card.toggle_assignment @board.users.active.find(params[:assignee_id])
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end
+5 -1
View File
@@ -11,7 +11,11 @@ class Cards::BoardsController < ApplicationController
def update
@card.move_to(@board)
redirect_to @card
respond_to do |format|
format.html { redirect_to @card }
format.json { head :no_content }
end
end
private
+10 -2
View File
@@ -3,11 +3,19 @@ class Cards::ClosuresController < ApplicationController
def create
@card.close
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
def destroy
@card.reopen
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
end
@@ -13,10 +13,20 @@ class Cards::Comments::ReactionsController < ApplicationController
def create
@reaction = @comment.reactions.create!(params.expect(reaction: :content))
respond_to do |format|
format.turbo_stream
format.json { head :created }
end
end
def destroy
@reaction.destroy
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
@@ -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
+10 -2
View File
@@ -3,11 +3,19 @@ class Cards::GoldnessesController < ApplicationController
def create
@card.gild
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
def destroy
@card.ungild
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
end
+5 -1
View File
@@ -3,6 +3,10 @@ class Cards::ImagesController < ApplicationController
def destroy
@card.image.purge_later
redirect_to @card
respond_to do |format|
format.html { redirect_to @card }
format.json { head :no_content }
end
end
end
+5 -1
View File
@@ -3,6 +3,10 @@ class Cards::NotNowsController < ApplicationController
def create
@card.postpone
render_card_replacement
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
end
+15
View File
@@ -5,6 +5,11 @@ class Cards::StepsController < ApplicationController
def create
@step = @card.steps.create!(step_params)
respond_to do |format|
format.turbo_stream
format.json { head :created, location: card_step_path(@card, @step, format: :json) }
end
end
def show
@@ -15,10 +20,20 @@ class Cards::StepsController < ApplicationController
def update
@step.update!(step_params)
respond_to do |format|
format.turbo_stream
format.json { render :show }
end
end
def destroy
@step.destroy!
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
@@ -9,6 +9,11 @@ class Cards::TaggingsController < ApplicationController
def create
@card.toggle_tag_with sanitized_tag_title_param
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
private
+9 -2
View File
@@ -5,11 +5,18 @@ class Cards::TriagesController < ApplicationController
column = @card.board.columns.find(params[:column_id])
@card.triage_into(column)
redirect_to @card
respond_to do |format|
format.html { redirect_to @card }
format.json { head :no_content }
end
end
def destroy
@card.send_back_to_triage
redirect_to @card
respond_to do |format|
format.html { redirect_to @card }
format.json { head :no_content }
end
end
end
@@ -7,9 +7,19 @@ class Cards::WatchesController < ApplicationController
def create
@card.watch_by Current.user
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@card.unwatch_by Current.user
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end
+22 -3
View File
@@ -10,8 +10,18 @@ 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)
card.publish
head :created, location: card_path(card, format: :json)
end
end
end
def show
@@ -22,11 +32,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
+13 -3
View File
@@ -7,7 +7,7 @@ module Authentication
after_action :ensure_development_magic_link_not_leaked
helper_method :authenticated?
etag { Current.session.id if authenticated? }
etag { Current.identity.id if authenticated? }
include LoginHelper
end
@@ -32,7 +32,7 @@ module Authentication
private
def authenticated?
Current.session.present?
Current.identity.present?
end
def require_account
@@ -42,7 +42,7 @@ module Authentication
end
def require_authentication
resume_session || request_authentication
resume_session || authenticate_by_bearer_token || request_authentication
end
def resume_session
@@ -55,6 +55,16 @@ module Authentication
Session.find_signed(cookies.signed[:session_token])
end
def authenticate_by_bearer_token
if request.authorization.to_s.include?("Bearer")
authenticate_or_request_with_http_token do |token|
if identity = Identity.find_by_permissable_access_token(token, method: request.method)
Current.identity = identity
end
end
end
end
def request_authentication
if Current.account.present?
session[:return_to_after_authenticating] = request.url
@@ -12,7 +12,7 @@ module RequestForgeryProtection
end
def verified_request?
super || safe_fetch_site?
super || safe_fetch_site? || request.format.json?
end
SAFE_FETCH_SITES = %w[ same-origin same-site ]
@@ -0,0 +1,40 @@
class My::AccessTokensController < ApplicationController
def index
@access_tokens = my_access_tokens.order(created_at: :desc)
end
def show
@access_token = my_access_tokens.find(verifier.verify(params[:id]))
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to my_access_tokens_path, alert: "Token is no longer visible"
end
def new
@access_token = my_access_tokens.new
end
def create
access_token = my_access_tokens.create!(access_token_params)
expiring_id = verifier.generate access_token.id, expires_in: 10.seconds
redirect_to my_access_token_path(expiring_id)
end
def destroy
my_access_tokens.find(params[:id]).destroy!
redirect_to my_access_tokens_path
end
private
def my_access_tokens
Current.identity.access_tokens
end
def access_token_params
params.expect(access_token: %i[ description permission ])
end
def verifier
Rails.application.message_verifier(:access_tokens)
end
end
@@ -0,0 +1,7 @@
class My::IdentitiesController < ApplicationController
disallow_account_scope
def show
@identity = Current.identity
end
end
@@ -2,10 +2,15 @@ class Notifications::BulkReadingsController < ApplicationController
def create
Current.user.notifications.unread.read_all
if from_tray?
head :ok
else
redirect_to notifications_path
respond_to do |format|
format.html do
if from_tray?
head :ok
else
redirect_to notifications_path
end
end
format.json { head :no_content }
end
end
@@ -2,10 +2,20 @@ class Notifications::ReadingsController < ApplicationController
def create
@notification = Current.user.notifications.find(params[:notification_id])
@notification.read
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@notification = Current.user.notifications.find(params[:notification_id])
@notification.unread
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end
+8 -1
View File
@@ -1,13 +1,20 @@
class NotificationsController < ApplicationController
MAX_UNREAD_NOTIFICATIONS = 500
MAX_UNREAD_NOTIFICATIONS_VIA_API = 100
def index
@unread = Current.user.notifications.unread.ordered.preloaded.limit(MAX_UNREAD_NOTIFICATIONS) unless current_page_param
@unread = Current.user.notifications.unread.ordered.preloaded.limit(max_unread_notifications) unless current_page_param
set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded
respond_to do |format|
format.turbo_stream if current_page_param # Allows read-all action to side step pagination
format.html
format.json
end
end
private
def max_unread_notifications
request.format.json? ? MAX_UNREAD_NOTIFICATIONS_VIA_API : MAX_UNREAD_NOTIFICATIONS
end
end
+5
View File
@@ -0,0 +1,5 @@
class TagsController < ApplicationController
def index
set_page_and_extract_portion_from Current.account.tags.alphabetically
end
end
+18 -4
View File
@@ -1,7 +1,11 @@
class UsersController < ApplicationController
before_action :set_user
before_action :set_user, except: %i[ index ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from Current.account.users.active.alphabetically
end
def show
end
@@ -10,15 +14,25 @@ class UsersController < ApplicationController
def update
if @user.update(user_params)
redirect_to @user
respond_to do |format|
format.html { redirect_to @user }
format.json { head :no_content }
end
else
render :edit, status: :unprocessable_entity
respond_to do |format|
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end
def destroy
@user.deactivate
redirect_to users_path
respond_to do |format|
format.html { redirect_to users_path }
format.json { head :no_content }
end
end
private
+10 -4
View File
@@ -1,13 +1,19 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :account
attribute :session, :user, :identity, :account
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
delegate :identity, to: :session, allow_nil: true
def session=(value)
super(value)
if value.present? && account.present?
if value.present?
self.identity = session.identity
end
end
def identity=(identity)
super(identity)
if identity.present?
self.user = identity.users.find_by(account: account)
end
end
+7
View File
@@ -1,6 +1,7 @@
class Identity < ApplicationRecord
include Joinable, Transferable
has_many :access_tokens, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
has_many :users, dependent: :nullify
@@ -13,6 +14,12 @@ class Identity < ApplicationRecord
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
def self.find_by_permissable_access_token(token, method:)
if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method)
access_token.identity
end
end
def send_magic_link(**attributes)
attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for)
+10
View File
@@ -0,0 +1,10 @@
class Identity::AccessToken < ApplicationRecord
belongs_to :identity
has_secure_token
enum :permission, %w[ read write ].index_by(&:itself), default: :read
def allows?(method)
method.in?(%w[ GET HEAD ]) || write?
end
end
+2 -3
View File
@@ -1,8 +1,7 @@
json.cache! board do
json.(board, :id, :name, :all_access)
json.created_at board.created_at.utc
json.url board_url(board)
json.creator do
json.partial! "users/user", user: board.creator
end
json.creator board.creator, partial: "users/user", as: :user
end
@@ -0,0 +1 @@
json.array! @columns, partial: "columns/column", as: :column
@@ -0,0 +1 @@
json.partial! "columns/column", column: @column
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "boards/board", as: :board
+1
View File
@@ -0,0 +1 @@
json.partial! "boards/board", board: @board
+10 -16
View File
@@ -1,26 +1,20 @@
json.cache! [ card, card.column&.color ] do
json.(card, :id, :title, :status)
json.cache! card do
json.(card, :id, :number, :title, :status)
json.description card.description.to_plain_text
json.description_html card.description.to_s
json.image_url card.image.presence && url_for(card.image)
json.tags card.tags.pluck(:title).sort
json.golden card.golden?
json.last_active_at card.last_active_at.utc
json.created_at card.created_at.utc
json.url card_url(card)
json.board do
json.partial! "boards/board", locals: { board: card.board }
end
json.board card.board, partial: "boards/board", as: :board
json.column card.column, partial: "columns/column", as: :column if card.column
json.creator card.creator, partial: "users/user", as: :user
json.column do
if card.column
json.partial! "columns/column", column: card.column
else
nil
end
end
json.creator do
json.partial! "users/user", user: card.creator
end
json.comments_url card_comments_url(card)
end
@@ -9,9 +9,7 @@ json.cache! comment do
json.html comment.body.to_s
end
json.creator do
json.partial! "users/user", user: comment.creator
end
json.creator comment.creator, partial: "users/user", as: :user
json.reactions_url card_comment_reactions_url(comment.card_id, comment.id)
json.url card_comment_url(comment.card_id, comment.id)
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/comments/comment", as: :comment
@@ -0,0 +1,5 @@
json.cache! reaction do
json.(reaction, :id, :content)
json.reacter reaction.reacter, partial: "users/user", as: :user
json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction)
end
@@ -0,0 +1 @@
json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction
@@ -0,0 +1 @@
json.partial! "cards/comments/comment", comment: @comment
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "cards/card", as: :card
+2
View File
@@ -0,0 +1,2 @@
json.partial! "cards/card", card: @card
json.steps @card.steps, partial: "steps/step", as: :step
@@ -0,0 +1,3 @@
json.cache! step do
json.(step, :id, :content, :completed)
end
+1
View File
@@ -0,0 +1 @@
json.partial! "cards/steps/step", step: @step
+4 -2
View File
@@ -1,2 +1,4 @@
json.(column, :id, :name, :color)
json.created_at column.created_at.utc
json.cache! column do
json.(column, :id, :name, :color)
json.created_at column.created_at.utc
end
@@ -0,0 +1,13 @@
<tr style="view-transition-name: <%= dom_id(access_token) %>">
<td><strong><%= access_token.description %></strong></td>
<td><%= access_token.permission.humanize %></td>
<td><%= local_datetime_tag access_token.created_at, style: :datetime %></td>
<td>
<%= button_to my_access_token_path(access_token), method: :delete,
class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent",
data: { turbo_confirm: "Are you sure you want to permanently revoke this access token?" } do %>
<%= icon_tag "trash" %>
<span class="for-screen-reader">Edit this token</span>
<% end %>
</td>
</tr>
+35
View File
@@ -0,0 +1,35 @@
<% @page_title = "Personal access tokens" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<section class="panel panel--wide shadow center webhooks">
<% if @access_tokens.any? %>
<p class="margin-none-block-start">Tokens you have generated that can be used to access the Fizzy API.</p>
<table class="access_tokens_table margin-block-end-double max-width txt-small">
<thead>
<tr>
<th>Description</th>
<th>Permission</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<%= render partial: "my/access_tokens/access_token", collection: @access_tokens %>
</tbody>
</table>
<% else %>
<p class="margin-none-block-start">Personal access tokens can be used like a password to access the Fizzy developer API. You can have as many tokens as you need and revoke access to each one at any time.</p>
<% end %>
<%= link_to new_my_access_token_path, class: "btn btn--link" do %>
<%= icon_tag "add" %>
<span>Generate a new access token</span>
<% end %>
</section>
+29
View File
@@ -0,0 +1,29 @@
<% @page_title = "Generate a personal access token" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<article class="panel panel--wide shadow center txt-align-start" style="view-transition-name: <%= dom_id(@access_token) %>">
<%= form_with model: @access_token, url: my_access_tokens_path, scope: :access_token, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<div class="flex flex-column gap-half">
<strong><%= form.label :description, "Access token description" %></strong>
<%= form.text_field :description, required: true, autofocus: true, class: "input", placeholder: "e.g. Github", data: { action: "keydown.esc@document->form#cancel" } %>
</div>
<div class="flex flex-column gap-half">
<strong><%= form.label :permission %></strong>
<%= form.select :permission, options_for_select({ "Read" => "read", "Read + Write" => "write"}), {}, class: "input input--select" %>
</div>
<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
<span>Generate access token</span>
<% end %>
<%= link_to "Cancel and go back", my_access_tokens_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
+26
View File
@@ -0,0 +1,26 @@
<% @page_title = "New personal access token" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<article class="panel panel--wide shadow center txt-align-start" style="view-transition-name: <%= dom_id(@access_token) %>">
<div class="flex flex-column gap">
<label class="flex flex-column gap-half txt-align-start">
<strong><%= @access_token.description %> (<%= @access_token.permission == "write" ? "Read + Write" : "Read" %>)</strong>
<input type="text" value="<%= @access_token.token %>" class="input" readonly>
</label>
<p class="margin-none txt-small">Be sure to save this access token now because you wont be able to see it again.</p>
<%= tag.button class: "btn btn--link center", data: {
controller: "copy-to-clipboard", action: "copy-to-clipboard#copy",
copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: @access_token.token } do %>
<%= icon_tag "copy-paste" %>
<span>Copy access token</span>
<% end %>
</div>
</article>
@@ -0,0 +1,4 @@
json.cache! account do
json.(account, :id, :name, :slug)
json.created_at account.created_at.utc
end
@@ -0,0 +1,4 @@
json.accounts @identity.users do |user|
json.partial! "my/identities/account", account: user.account
json.user user, partial: "users/user", as: :user
end
@@ -0,0 +1,17 @@
json.cache! notification do
json.(notification, :id)
json.read notification.read?
json.read_at notification.read_at&.utc
json.created_at notification.created_at.utc
json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification
json.creator notification.creator, partial: "users/user", as: :user
json.card do
json.(notification.card, :id, :title, :status)
json.url card_url(notification.card)
end
json.url notification_url(notification)
end
@@ -0,0 +1 @@
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification
@@ -0,0 +1,2 @@
json.title event_notification_title(notification.source)
json.body event_notification_body(notification.source)
@@ -0,0 +1,4 @@
mention = notification.source
json.title "#{mention.mentioner.first_name} @mentioned you"
json.body mention.source.mentionable_content.truncate(200)
+5
View File
@@ -0,0 +1,5 @@
json.cache! tag do
json.(tag, :id, :title)
json.created_at tag.created_at.utc
json.url cards_url(tag_ids: [ tag ])
end
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "tags/tag", as: :tag
+8 -8
View File
@@ -1,13 +1,13 @@
<div class="flex flex-column align-center gap txt-medium--responsive txt-medium">
<div class="flex flex-column align-center gap">
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
<header class="full-width">
<h2 class="divider txt-large">Link a device</h2>
<p class="margin-none-block" id="session_transfer_label">Use this link to sign-in on another device</p>
</header>
<label class="flex flex-column gap full-width">
<div class="flex align-center gap justify-center">
<strong id="session_transfer_label" class="txt-medium">Link to automatically log in on another device</strong>
</div>
<span class="flex align-center gap margin-inline">
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
</span>
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
</label>
<div class="flex align-center gap">
@@ -34,4 +34,4 @@
<span class="for-screen-reader">Copy auto-login link</span>
<% end %>
</div>
</div>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
json.cache! user do
json.(user, :id, :name, :role, :active)
json.email_address user.identity.email_address
json.email_address user.identity&.email_address
json.created_at user.created_at.utc
json.url user_url(user)
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "users/user", as: :user
+13 -3
View File
@@ -32,9 +32,9 @@
<% if @user.verified? %>
<div class="flex-inline center justify-center flex-wrap gap">
<%= link_to "Which cards are assigned to #{me_or_you}?",
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
<%= link_to "Which cards were added by #{me_or_you}?",
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
</div>
<% end %>
</div>
@@ -44,9 +44,19 @@
<section class="panel shadow" style="--panel-size: 45ch;">
<%= render "users/transfer", user: @user %>
<div class="flex flex-column align-center gap margin-block-start-double">
<header class="full-width">
<h2 class="divider txt-large margin-none-block">API</h2>
</header>
<div class="flex align-center gap txt-normal">
<%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %>
</div>
</div>
<div class="center margin-block-start-double">
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %>
<span>Sign out</span>
<span>Sign out of Fizzy</span>
<% end %>
</div>
</section>
+1
View File
@@ -0,0 +1 @@
json.partial! "users/user", user: @user
+2 -7
View File
@@ -9,11 +9,6 @@ json.cache! @event do
end
end
json.board do
json.partial! "boards/board", locals: { board: @event.board }
end
json.creator do
json.partial! "users/user", user: @event.creator
end
json.board @event.board, partial: "boards/board", as: :board
json.creator @event.creator, partial: "users/user", as: :user
end
+4
View File
@@ -94,6 +94,8 @@ Rails.application.routes.draw do
end
end
resources :tags, only: :index
namespace :notifications do
resource :settings
resource :unsubscribe
@@ -162,6 +164,8 @@ Rails.application.routes.draw do
resource :landing
namespace :my do
resource :identity, only: :show
resources :access_tokens
resources :pins
resource :timezone
resource :menu
@@ -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
Generated
+10
View File
@@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
t.index ["email_address"], name: "index_identities_on_email_address", unique: true
end
create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description"
t.uuid "identity_id", null: false
t.string "permission"
t.string "token"
t.datetime "updated_at", null: false
t.index ["identity_id"], name: "index_access_token_on_identity_id"
end
create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "code", null: false
t.datetime "created_at", null: false
+10
View File
@@ -321,6 +321,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
t.index ["email_address"], name: "index_identities_on_email_address", unique: true
end
create_table "identity_access_tokens", id: :uuid, force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description", limit: 65535
t.uuid "identity_id", null: false
t.string "permission", limit: 255
t.string "token", limit: 255
t.datetime "updated_at", null: false
t.index ["identity_id"], name: "index_access_token_on_identity_id"
end
create_table "magic_links", id: :uuid, force: :cascade do |t|
t.string "code", limit: 255, null: false
t.datetime "created_at", null: false
+1146
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
require "test_helper"
class ApiTest < ActionDispatch::IntegrationTest
setup do
@davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token)
@jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token)
end
test "authenticate with valid access token" do
get boards_path(format: :json), env: @davids_bearer_token
assert_response :success
end
test "fail to authenticate with invalid access token" do
get boards_path(format: :json), env: bearer_token_env("nonsense")
assert_response :unauthorized
end
test "changing data requires a write-endowed access token" do
post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @jasons_bearer_token
assert_response :unauthorized
post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token
assert_response :success
end
private
def bearer_token_env(token)
{ "HTTP_AUTHORIZATION" => "Bearer #{token}" }
end
end
@@ -36,4 +36,52 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
end
test "index as JSON" do
board = boards(:writebook)
get board_columns_path(board), as: :json
assert_response :success
assert_equal board.columns.count, @response.parsed_body.count
end
test "show as JSON" do
column = columns(:writebook_in_progress)
get board_column_path(column.board, column), as: :json
assert_response :success
assert_equal column.id, @response.parsed_body["id"]
end
test "create as JSON" do
board = boards(:writebook)
assert_difference -> { board.columns.count }, +1 do
post board_columns_path(board), params: { column: { name: "New Column" } }, as: :json
end
assert_response :created
assert_equal board_column_path(board, Column.last, format: :json), @response.headers["Location"]
end
test "update as JSON" do
column = columns(:writebook_in_progress)
put board_column_path(column.board, column), params: { column: { name: "Updated Name" } }, as: :json
assert_response :no_content
assert_equal "Updated Name", column.reload.name
end
test "destroy as JSON" do
column = columns(:writebook_on_hold)
assert_difference -> { column.board.columns.count }, -1 do
delete board_column_path(column.board, column), as: :json
end
assert_response :no_content
end
end
@@ -190,4 +190,44 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]"
end
end
test "index as JSON" do
get boards_path, as: :json
assert_response :success
assert_equal users(:kevin).boards.count, @response.parsed_body.count
end
test "show as JSON" do
get board_path(boards(:writebook)), as: :json
assert_response :success
assert_equal boards(:writebook).name, @response.parsed_body["name"]
end
test "create as JSON" do
assert_difference -> { Board.count }, +1 do
post boards_path, params: { board: { name: "My new board" } }, as: :json
end
assert_response :created
assert_equal board_path(Board.last, format: :json), @response.headers["Location"]
end
test "update as JSON" do
board = boards(:writebook)
put board_path(board), params: { board: { name: "Updated Name" } }, as: :json
assert_response :no_content
assert_equal "Updated Name", board.reload.name
end
test "destroy as JSON" do
board = boards(:writebook)
assert_difference -> { Board.count }, -1 do
delete board_path(board), as: :json
end
assert_response :no_content
end
end
@@ -22,6 +22,20 @@ class Cards::AssignmentsControllerTest < ActionDispatch::IntegrationTest
end
end
test "create as JSON" do
card = cards(:logo)
assert_not card.assigned_to?(users(:david))
post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json
assert_response :no_content
assert card.reload.assigned_to?(users(:david))
post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json
assert_response :no_content
assert_not card.reload.assigned_to?(users(:david))
end
private
def assert_meta_replaced(card)
assert_turbo_stream action: :replace, target: dom_id(card, :meta)
@@ -17,4 +17,16 @@ class Cards::BoardsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to card
end
test "update as JSON" do
card = cards(:logo)
new_board = boards(:private)
assert_not_equal new_board, card.board
put card_board_path(card), params: { board_id: new_board.id }, as: :json
assert_response :no_content
assert_equal new_board, card.reload.board
end
end
@@ -9,7 +9,7 @@ class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest
card = cards(:logo)
assert_changes -> { card.reload.closed? }, from: false, to: true do
post card_closure_path(card)
post card_closure_path(card), as: :turbo_stream
assert_card_container_rerendered(card)
end
end
@@ -18,8 +18,30 @@ class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest
card = cards(:shipping)
assert_changes -> { card.reload.closed? }, from: true, to: false do
delete card_closure_path(card)
delete card_closure_path(card), as: :turbo_stream
assert_card_container_rerendered(card)
end
end
test "create as JSON" do
card = cards(:logo)
assert_not card.closed?
post card_closure_path(card), as: :json
assert_response :no_content
assert card.reload.closed?
end
test "destroy as JSON" do
card = cards(:shipping)
assert card.closed?
delete card_closure_path(card), as: :json
assert_response :no_content
assert_not card.reload.closed?
end
end
@@ -7,6 +7,11 @@ class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest
@card = @comment.card
end
test "index" do
get card_comment_reactions_path(@card, @comment)
assert_response :success
end
test "create" do
assert_difference -> { @comment.reactions.count }, 1 do
post card_comment_reactions_path(@comment.card, @comment, format: :turbo_stream), params: { reaction: { content: "Great work!" } }
@@ -30,4 +35,29 @@ class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest
assert_response :forbidden
end
end
test "index as JSON" do
get card_comment_reactions_path(@card, @comment), as: :json
assert_response :success
assert_equal @comment.reactions.count, @response.parsed_body.count
end
test "create as JSON" do
assert_difference -> { @comment.reactions.count }, 1 do
post card_comment_reactions_path(@card, @comment), params: { reaction: { content: "👍" } }, as: :json
end
assert_response :created
end
test "destroy as JSON" do
reaction = reactions(:david)
assert_difference -> { @comment.reactions.count }, -1 do
delete card_comment_reaction_path(@card, @comment, reaction), as: :json
end
assert_response :no_content
end
end
@@ -27,4 +27,51 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
assert_response :forbidden
end
test "index as JSON" do
card = cards(:logo)
get card_comments_path(card), as: :json
assert_response :success
assert_equal card.comments.count, @response.parsed_body.count
end
test "create as JSON" do
card = cards(:logo)
assert_difference -> { card.comments.count }, +1 do
post card_comments_path(card), params: { comment: { body: "New comment" } }, as: :json
end
assert_response :created
assert_equal card_comment_path(card, Comment.last, format: :json), @response.headers["Location"]
end
test "show as JSON" do
comment = comments(:logo_agreement_kevin)
get card_comment_path(cards(:logo), comment), as: :json
assert_response :success
assert_equal comment.id, @response.parsed_body["id"]
end
test "update as JSON" do
comment = comments(:logo_agreement_kevin)
put card_comment_path(cards(:logo), comment), params: { comment: { body: "Updated comment" } }, as: :json
assert_response :success
assert_equal "Updated comment", comment.reload.body.to_plain_text
end
test "destroy as JSON" do
comment = comments(:logo_agreement_kevin)
delete card_comment_path(cards(:logo), comment), as: :json
assert_response :no_content
assert_not Comment.exists?(comment.id)
end
end
@@ -18,4 +18,26 @@ class Cards::GoldnessesControllerTest < ActionDispatch::IntegrationTest
assert_card_container_rerendered(cards(:logo))
end
end
test "create as JSON" do
card = cards(:text)
assert_not card.golden?
post card_goldness_path(card), as: :json
assert_response :no_content
assert card.reload.golden?
end
test "destroy as JSON" do
card = cards(:logo)
assert card.golden?
delete card_goldness_path(card), as: :json
assert_response :no_content
assert_not card.reload.golden?
end
end
@@ -0,0 +1,31 @@
require "test_helper"
class Cards::ImagesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "destroy" do
card = cards(:logo)
card.image.attach(io: file_fixture("moon.jpg").open, filename: "moon.jpg")
assert card.image.attached?
delete card_image_path(card)
assert_redirected_to card
assert_not card.reload.image.attached?
end
test "destroy as JSON" do
card = cards(:logo)
card.image.attach(io: file_fixture("moon.jpg").open, filename: "moon.jpg")
assert card.image.attached?
delete card_image_path(card), as: :json
assert_response :no_content
assert_not card.reload.image.attached?
end
end
@@ -9,8 +9,19 @@ class Cards::NotNowsControllerTest < ActionDispatch::IntegrationTest
card = cards(:logo)
assert_changes -> { card.reload.postponed? }, from: false, to: true do
post card_not_now_path(card)
post card_not_now_path(card), as: :turbo_stream
assert_card_container_rerendered(card)
end
end
test "create as JSON" do
card = cards(:logo)
assert_not card.postponed?
post card_not_now_path(card), as: :json
assert_response :no_content
assert card.reload.postponed?
end
end
@@ -52,4 +52,47 @@ class Cards::StepsControllerTest < ActionDispatch::IntegrationTest
assert_turbo_stream action: :replace, target: dom_id(step)
end
end
test "create as JSON" do
card = cards(:logo)
assert_difference -> { card.steps.count }, +1 do
post card_steps_path(card), params: { step: { content: "New step" } }, as: :json
end
assert_response :created
assert_equal card_step_path(card, Step.last, format: :json), @response.headers["Location"]
end
test "show as JSON" do
card = cards(:logo)
step = card.steps.create!(content: "Test step")
get card_step_path(card, step), as: :json
assert_response :success
assert_equal step.id, @response.parsed_body["id"]
assert_equal "Test step", @response.parsed_body["content"]
end
test "update as JSON" do
card = cards(:logo)
step = card.steps.create!(content: "Original")
put card_step_path(card, step), params: { step: { content: "Updated" } }, as: :json
assert_response :success
assert_equal "Updated", step.reload.content
assert_equal "Updated", @response.parsed_body["content"]
end
test "destroy as JSON" do
card = cards(:logo)
step = card.steps.create!(content: "To delete")
delete card_step_path(card, step), as: :json
assert_response :no_content
assert_not Step.exists?(step.id)
end
end
@@ -23,4 +23,26 @@ class Cards::TaggingsControllerTest < ActionDispatch::IntegrationTest
assert_turbo_stream action: :replace, target: dom_id(cards(:logo), :tags)
end
end
test "toggle tag on as JSON" do
card = cards(:logo)
assert_not card.tagged_with?(tags(:mobile))
post card_taggings_path(card), params: { tag_title: tags(:mobile).title }, as: :json
assert_response :no_content
assert card.reload.tagged_with?(tags(:mobile))
end
test "toggle tag off as JSON" do
card = cards(:logo)
assert card.tagged_with?(tags(:web))
post card_taggings_path(card), params: { tag_title: tags(:web).title }, as: :json
assert_response :no_content
assert_not card.reload.tagged_with?(tags(:web))
end
end
@@ -24,4 +24,25 @@ class Cards::TriagesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to card
end
end
test "create as JSON" do
card = cards(:logo)
column = columns(:writebook_in_progress)
post card_triage_path(card, column_id: column.id), as: :json
assert_response :no_content
assert_equal column, card.reload.column
end
test "destroy as JSON" do
card = cards(:shipping)
assert card.column.present?
delete card_triage_path(card), as: :json
assert_response :no_content
assert_nil card.reload.column
end
end
@@ -9,7 +9,7 @@ class Cards::WatchesControllerTest < ActionDispatch::IntegrationTest
cards(:logo).unwatch_by users(:kevin)
assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: false, to: true do
post card_watch_path(cards(:logo))
post card_watch_path(cards(:logo)), as: :turbo_stream
end
end
@@ -17,7 +17,31 @@ class Cards::WatchesControllerTest < ActionDispatch::IntegrationTest
cards(:logo).watch_by users(:kevin)
assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: true, to: false do
delete card_watch_path(cards(:logo))
delete card_watch_path(cards(:logo)), as: :turbo_stream
end
end
test "create as JSON" do
card = cards(:logo)
card.unwatch_by users(:kevin)
assert_not card.watched_by?(users(:kevin))
post card_watch_path(card), as: :json
assert_response :no_content
assert card.reload.watched_by?(users(:kevin))
end
test "destroy as JSON" do
card = cards(:logo)
card.watch_by users(:kevin)
assert card.watched_by?(users(:kevin))
delete card_watch_path(card), as: :json
assert_response :no_content
assert_not card.reload.watched_by?(users(:kevin))
end
end
+38
View File
@@ -132,4 +132,42 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
get card_path(card)
assert_response :success
end
test "show as JSON" do
get card_path(cards(:logo)), as: :json
assert_response :success
assert_equal cards(:logo).title, @response.parsed_body["title"]
end
test "create as JSON" do
assert_difference -> { Card.count }, +1 do
post board_cards_path(boards(:writebook)),
params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } },
as: :json
end
assert_response :created
assert_equal card_path(Card.last, format: :json), @response.headers["Location"]
card = Card.last
assert_equal "My new card", card.title
assert_equal "Big if true", card.description.to_plain_text
assert_equal [ tags(:mobile), tags(:web) ].sort, card.tags.sort
end
test "update as JSON" do
card = cards(:logo)
put card_path(card, format: :json), params: { card: { title: "Update test" } }
assert_response :success
assert_equal "Update test", card.reload.title
end
test "delete as JSON" do
card = cards(:logo)
delete card_path(card, format: :json)
assert_response :no_content
assert_not Card.exists?(card.id)
end
end
@@ -0,0 +1,30 @@
require "test_helper"
class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create new token" do
get my_access_tokens_path
assert_response :success
get new_my_access_token_path
assert_response :success
assert_changes -> { identities(:kevin).access_tokens.count }, +1 do
post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } }
follow_redirect!
assert_in_body identities(:kevin).access_tokens.last.token
end
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" } }
travel_to 15.seconds.from_now
follow_redirect!
assert_equal "Token is no longer visible", flash[:alert]
end
end
end
@@ -0,0 +1,17 @@
require "test_helper"
class My::IdentitiesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "show as JSON" do
identity = identities(:kevin)
untenanted do
get my_identity_path, as: :json
assert_response :success
assert_equal identity.accounts.count, @response.parsed_body["accounts"].count
end
end
end
@@ -22,4 +22,14 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
post bulk_reading_path, params: { from_tray: true }
assert_response :ok
end
test "create as JSON" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
post bulk_reading_path, as: :json
end
end
assert_response :no_content
end
end
@@ -21,4 +21,21 @@ class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
end
test "create as JSON" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
post notification_reading_path(notifications(:logo_published_kevin)), as: :json
assert_response :no_content
end
end
test "destroy as JSON" do
notification = notifications(:logo_published_kevin)
notification.read
assert_changes -> { notification.reload.read? }, from: true, to: false do
delete notification_reading_path(notification), as: :json
assert_response :no_content
end
end
end
@@ -1,4 +1,27 @@
require "test_helper"
class NotificationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "index as JSON" do
get notifications_path, as: :json
assert_response :success
assert_kind_of Array, @response.parsed_body
assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id }
end
test "index as JSON includes notification attributes" do
get notifications_path, as: :json
notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id }
assert_not_nil notification["title"]
assert_not_nil notification["body"]
assert_not_nil notification["created_at"]
assert_not_nil notification["card"]
assert_not_nil notification["creator"]
end
end
+16
View File
@@ -0,0 +1,16 @@
require "test_helper"
class TagsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "index as JSON" do
tags = users(:kevin).account.tags.alphabetically
get tags_path, as: :json
assert_response :success
assert_equal tags.count, @response.parsed_body.count
assert_equal tags.pluck(:title), @response.parsed_body.pluck("title")
end
end
+46
View File
@@ -86,4 +86,50 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert users(:kevin).reload.avatar.attached?
assert_equal "image/png", users(:kevin).avatar.content_type
end
test "index as JSON" do
sign_in_as :kevin
get users_path, as: :json
assert_response :success
assert_equal users(:kevin).account.users.active.count, @response.parsed_body.count
end
test "show as JSON" do
sign_in_as :kevin
get user_path(users(:david)), as: :json
assert_response :success
assert_equal users(:david).name, @response.parsed_body["name"]
end
test "update as JSON" do
sign_in_as :kevin
put user_path(users(:david)), params: { user: { name: "New David" } }, as: :json
assert_response :no_content
assert_equal "New David", users(:david).reload.name
end
test "update as JSON with invalid avatar returns errors" do
sign_in_as :kevin
svg_file = fixture_file_upload("avatar.svg", "image/svg+xml")
put user_path(users(:kevin), format: :json), params: { user: { avatar: svg_file } }
assert_response :unprocessable_entity
assert @response.parsed_body["avatar"].present?
end
test "destroy as JSON" do
sign_in_as :kevin
assert_difference -> { User.active.count }, -1 do
delete user_path(users(:david)), as: :json
end
assert_response :no_content
end
end
+11
View File
@@ -0,0 +1,11 @@
jasons_api_token:
identity: jason
token: 018cf1425682700098f24f0799e3fe20
description: My Superscript
permission: read
davids_api_token:
identity: david
token: x18cf1425682700098f24f0799e3fe20
description: My Superscript
permission: write
@@ -0,0 +1,4 @@
require "test_helper"
class Identity::AccessTokenTest < ActiveSupport::TestCase
end