Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 37 additions & 0 deletions app/controllers/account/bot_access_tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class Account::BotAccessTokensController < ApplicationController
before_action :ensure_admin
before_action :set_bot

def new
@access_token = bot_access_tokens.new
end

def create
access_token = bot_access_tokens.create!(access_token_params)
expiring_id = token_verifier.generate(access_token.id, expires_in: 30.seconds)

redirect_to account_bot_path(@bot, token: expiring_id)
end

def destroy
bot_access_tokens.find(params[:id]).destroy!
redirect_to account_bot_path(@bot)
end

private
def set_bot
@bot = Current.account.users.where(role: :bot).find(params[:bot_id])
end

def bot_access_tokens
@bot.identity.access_tokens
end

def access_token_params
params.expect(access_token: %i[ description permission ])
end

def token_verifier
Rails.application.message_verifier(:bot_tokens)
end
end
61 changes: 61 additions & 0 deletions app/controllers/account/bots_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
class Account::BotsController < ApplicationController
before_action :ensure_admin
before_action :set_bot, only: %i[ show destroy ]

def new
end

def create
bot, access_token = ActiveRecord::Base.transaction do
identity = Identity.create!(email_address: "bot+#{SecureRandom.hex(4)}@fizzy.internal")
bot = Current.account.users.create!(
name: name,
role: :bot,
identity: identity,
verified_at: Time.current
)
access_token = identity.access_tokens.create!(
description: "Initial token",
permission: :write
)
[ bot, access_token ]
end

respond_to do |format|
format.html { redirect_to account_bot_path(bot, token: token_verifier.generate(access_token.id, expires_in: 30.seconds)) }
format.json { render json: { user: { id: bot.id, name: bot.name, role: bot.role }, token: access_token.token }, status: :created }
end
end

def show
@access_tokens = @bot.identity.access_tokens.order(created_at: :desc)

if params[:token]
@new_access_token = Identity::AccessToken.find(token_verifier.verify(params[:token]))
end
rescue ActiveSupport::MessageVerifier::InvalidSignature
@new_access_token = nil
end

def destroy
@bot.deactivate

respond_to do |format|
format.html { redirect_to account_settings_path, notice: "#{@bot.name} has been removed" }
format.json { head :no_content }
end
end

private
def set_bot
@bot = Current.account.users.where(role: :bot).find(params[:id])
end

def name
params.expect(:name)
end

def token_verifier
Rails.application.message_verifier(:bot_tokens)
end
end
2 changes: 1 addition & 1 deletion app/models/comment/eventable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def event_was_created(event)

private
def should_track_event?
!creator.system?
!creator.system? && !creator.bot?
end

def track_creation
Expand Down
2 changes: 1 addition & 1 deletion app/models/notification_pusher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def push
private
def should_push?
notification.user.push_subscriptions.any? &&
!notification.creator.system? &&
!notification.creator.system? && !notification.creator.bot? &&
notification.user.active? &&
notification.account.active?
end
Expand Down
2 changes: 1 addition & 1 deletion app/models/notifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,6 @@ def initialize(source)
end

def should_notify?
!creator.system?
!creator.system? && !creator.bot?
end
end
2 changes: 1 addition & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def deactivate
end

def setup?
name != identity.email_address
bot? || name != identity&.email_address
end

def verified?
Expand Down
2 changes: 1 addition & 1 deletion app/models/user/configurable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module User::Configurable
has_one :settings, class_name: "User::Settings", dependent: :destroy
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all

after_create :create_settings, unless: :system?
after_create :create_settings, unless: -> { system? || bot? }

delegate :timezone, to: :settings, allow_nil: true
end
Expand Down
5 changes: 3 additions & 2 deletions app/models/user/role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ module User::Role
extend ActiveSupport::Concern

included do
enum :role, %i[ owner admin member system ].index_by(&:itself), scopes: false
enum :role, %i[ owner admin member system bot ].index_by(&:itself), scopes: false

scope :owner, -> { where(active: true, role: :owner) }
scope :admin, -> { where(active: true, role: %i[ owner admin ]) }
scope :member, -> { where(active: true, role: :member) }
scope :active, -> { where(active: true, role: %i[ owner admin member ]) }
scope :bot, -> { where(active: true, role: :bot) }
scope :active, -> { where(active: true, role: %i[ owner admin member bot ]) }

def admin?
super || owner?
Expand Down
2 changes: 1 addition & 1 deletion app/models/user/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def bundle_aggregation_period
end

def bundling_emails?
!bundle_email_never? && !user.system? && user.active? && user.verified?
!bundle_email_never? && !user.system? && !user.bot? && user.active? && user.verified?
end

def timezone
Expand Down
29 changes: 29 additions & 0 deletions app/views/account/bot_access_tokens/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<% @page_title = "Generate an access token for #{@bot.name}" %>

<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to @bot.name, account_bot_path(@bot), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>

<h1 class="header__title" data-bridge--title-target="header"><%= @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: account_bot_access_tokens_path(@bot), 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. Agora production", 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" }, "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", account_bot_path(@bot), data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
19 changes: 19 additions & 0 deletions app/views/account/bots/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<% @page_title = "Create a bot" %>

<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>

<div class="panel panel--wide shadow center flex flex-column gap">
<header>
<h2 class="txt-large margin-none font-weight-black"><%= @page_title %></h2>
<p class="txt-medium margin-none">Bots can access Fizzy via API using a personal access token</p>
</header>

<%= form_with url: account_bots_path, method: :post, class: "flex align-center gap-half" do |form| %>
<%= form.text_field :name, placeholder: "Bot name…", required: true, autofocus: true, class: "input flex-item-grow" %>
<%= form.submit "Create bot", class: "btn btn--link" %>
<% end %>
</div>
76 changes: 76 additions & 0 deletions app/views/account/bots/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<% @page_title = @bot.name %>

<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>

<h1 class="header__title" data-bridge--title-target="header"><%= @bot.name %> <span class="txt-normal font-weight-normal">Bot</span></h1>
<% end %>

<section class="panel panel--wide shadow center webhooks">
<% if @new_access_token %>
<article class="flex flex-column gap-half margin-block-end">
<label class="flex flex-column gap-half txt-align-start">
<strong><%= @new_access_token.description %> (<%= @new_access_token.permission == "write" ? "Read + Write" : "Read" %>)</strong>
<input type="text" value="<%= @new_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: @new_access_token.token } do %>
<%= icon_tag "copy-paste" %>
<span>Copy access token</span>
<% end %>

<hr class="separator--horizontal full-width" style="--border-color: var(--color-ink-lighter)">
</article>
<% end %>

<% if @access_tokens.any? %>
<p class="margin-none-block-start">Access tokens for this bot.</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>
<% @access_tokens.each do |access_token| %>
<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 account_bot_access_token_path(@bot, 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">Revoke this token</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="margin-none-block-start">This bot has no access tokens. Generate one to allow API access.</p>
<% end %>

<%= link_to new_account_bot_access_token_path(@bot), class: "btn btn--link center" do %>
<%= icon_tag "add" %>
<span>Generate a new access token</span>
<% end %>

<hr class="separator--horizontal full-width margin-block" style="--border-color: var(--color-ink-lighter)">

<%= button_to account_bot_path(@bot), method: :delete, class: "btn btn--negative txt-small center",
data: { turbo_confirm: "Are you sure you want to remove #{@bot.name}?" } do %>
<%= icon_tag "minus" %>
<span>Remove bot</span>
<% end %>
</section>
18 changes: 18 additions & 0 deletions app/views/account/settings/_bot_user.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<li class="flex align-center gap-half" data-filter-target="item" data-navigable-list-target="item" role="option">
<%= link_to account_bot_path(bot_user), class: "txt-ink flex gap-half align-center min-width" do %>
<%= avatar_preview_tag bot_user, hidden_for_screen_reader: true %>
<div class="txt-align-start overflow-ellipsis">
<strong><%= bot_user.name %></strong>
<div class="txt-x-small">Bot</div>
</div>
<% end %>

<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-ink-medium); --border-style: dashed" aria-hidden="true">

<%= button_to account_bot_path(bot_user), method: :delete, class: "btn btn--circle btn--negative",
disabled: !Current.user.admin?,
data: { turbo_confirm: "Are you sure you want to remove #{bot_user.name}?" } do %>
<%= icon_tag "minus" %>
<span class="for-screen-reader">Remove <%= bot_user.name %></span>
<% end %>
</li>
2 changes: 1 addition & 1 deletion app/views/account/settings/_user.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<%= avatar_preview_tag user, hidden_for_screen_reader: true %>
<div class="txt-align-start overflow-ellipsis">
<strong><%= user.name %></strong>
<div class="txt-x-small"><%= user.identity.email_address %></div>
<div class="txt-x-small"><%= user.identity&.email_address %></div>
</div>
<% end %>

Expand Down
21 changes: 16 additions & 5 deletions app/views/account/settings/_users.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,24 @@
<input placeholder="Filter…" class="input input--transparent full-width txt-small" type="search" autocorrect="off" autocomplete="off" data-1p-ignore="true" data-filter-target="input" data-action="input->filter#filter">

<ul class="settings__scrollable-list margin-block-half" data-filter-target="list" role="listbox">
<%= render partial: "account/settings/user", collection: users %>
<% users.each do |user| %>
<%= render "account/settings/#{user.bot? ? 'bot_user' : 'user'}", user.bot? ? { bot_user: user } : { user: user } %>
<% end %>
</ul>
</div>

<%= link_to account_join_code_path, class: "btn btn--link center" do %>
<%= icon_tag "add" %>
<span>Invite people</span>
<% end %>
<div class="flex justify-center gap">
<%= link_to account_join_code_path, class: "btn btn--link" do %>
<%= icon_tag "add" %>
<span>Invite people</span>
<% end %>

<% if Current.user.admin? %>
<%= link_to new_account_bot_path, class: "btn btn--link" do %>
<%= icon_tag "add" %>
<span>Create bot</span>
<% end %>
<% end %>
</div>
<% end %>
</section>
2 changes: 1 addition & 1 deletion app/views/cards/comments/_comment.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<% cache comment do %>
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= turbo_frame_tag comment, :container, class: { "comment-by-system": comment.creator.system? } do %>
<%= turbo_frame_tag comment, :container, class: { "comment-by-system": comment.creator.system?, "comment-by-bot": comment.creator.bot? } do %>
<%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%>
<div id="<%= dom_id(comment) %>" data-creator-id="<%= comment.creator_id %>" class="comment align-start full-width">
<figure class="comment__avatar flex-item-no-shrink" aria-hidden="true">
Expand Down
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
resource :settings
resources :exports, only: [ :create, :show ]
resources :imports, only: [ :new, :create, :show ]
resources :bots, only: %i[ new create show destroy ] do
resources :access_tokens, only: %i[ new create destroy ], controller: "bot_access_tokens"
end
resources :service_accounts, only: :create
end

resources :users do
Expand Down
Loading
Loading