diff --git a/app/controllers/account/bot_access_tokens_controller.rb b/app/controllers/account/bot_access_tokens_controller.rb new file mode 100644 index 000000000..db397244e --- /dev/null +++ b/app/controllers/account/bot_access_tokens_controller.rb @@ -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 diff --git a/app/controllers/account/bots_controller.rb b/app/controllers/account/bots_controller.rb new file mode 100644 index 000000000..8ddac3a52 --- /dev/null +++ b/app/controllers/account/bots_controller.rb @@ -0,0 +1,70 @@ +class Account::BotsController < ApplicationController + before_action :ensure_admin + before_action :set_bot, only: %i[ show update 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 update + @bot.update!(name: name) + + respond_to do |format| + format.html { redirect_to account_bot_path(@bot), notice: "#{@bot.name} has been updated" } + format.json { render json: { user: { id: @bot.id, name: @bot.name, role: @bot.role } } } + end + 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 diff --git a/app/models/comment/eventable.rb b/app/models/comment/eventable.rb index 7fe7abebb..a730cf1d6 100644 --- a/app/models/comment/eventable.rb +++ b/app/models/comment/eventable.rb @@ -13,7 +13,7 @@ def event_was_created(event) private def should_track_event? - !creator.system? + !creator.system? && !creator.bot? end def track_creation diff --git a/app/models/notification/pushable.rb b/app/models/notification/pushable.rb index e02f3a8de..34b292668 100644 --- a/app/models/notification/pushable.rb +++ b/app/models/notification/pushable.rb @@ -39,7 +39,7 @@ def payload private def pushable? - !creator.system? && user.active? && account.active? + !creator.system? && !creator.bot? && user.active? && account.active? end def push_to(target) diff --git a/app/models/notifier.rb b/app/models/notifier.rb index f6f356fa4..3eb83a2ed 100644 --- a/app/models/notifier.rb +++ b/app/models/notifier.rb @@ -43,6 +43,6 @@ def initialize(source) end def should_notify? - !creator.system? + !creator.system? && !creator.bot? end end diff --git a/app/models/user.rb b/app/models/user.rb index 842f7f6c8..663eb3f80 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,7 +25,7 @@ def deactivate end def setup? - name != identity.email_address + bot? || name != identity&.email_address end def verified? diff --git a/app/models/user/configurable.rb b/app/models/user/configurable.rb index c9c5adf53..4fd4f5f8c 100644 --- a/app/models/user/configurable.rb +++ b/app/models/user/configurable.rb @@ -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 diff --git a/app/models/user/role.rb b/app/models/user/role.rb index e103e94d9..21ddc0a35 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -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? diff --git a/app/models/user/settings.rb b/app/models/user/settings.rb index 598ede9c5..2d0e04523 100644 --- a/app/models/user/settings.rb +++ b/app/models/user/settings.rb @@ -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 diff --git a/app/views/account/bot_access_tokens/new.html.erb b/app/views/account/bot_access_tokens/new.html.erb new file mode 100644 index 000000000..c97d8c904 --- /dev/null +++ b/app/views/account/bot_access_tokens/new.html.erb @@ -0,0 +1,29 @@ +<% @page_title = "Generate an access token for #{@bot.name}" %> + +<% content_for :header do %> +
+ <%= back_link_to @bot.name, account_bot_path(@bot), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <%= 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| %> +
+ <%= form.label :description, "Access token description" %> + <%= form.text_field :description, required: true, autofocus: true, class: "input", placeholder: "e.g. CI/CD pipeline", data: { action: "keydown.esc@document->form#cancel" } %> +
+ +
+ <%= form.label :permission %> + <%= form.select :permission, options_for_select({ "Read" => "read", "Read + Write" => "write" }, "write"), {}, class: "input input--select" %> +
+ + <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> + Generate access token + <% end %> + + <%= link_to "Cancel and go back", account_bot_path(@bot), data: { form_target: "cancel" }, hidden: true %> + <% end %> +
diff --git a/app/views/account/bots/new.html.erb b/app/views/account/bots/new.html.erb new file mode 100644 index 000000000..33eb61324 --- /dev/null +++ b/app/views/account/bots/new.html.erb @@ -0,0 +1,19 @@ +<% @page_title = "Create a bot" %> + +<% content_for :header do %> +
+ <%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+<% end %> + +
+
+

<%= @page_title %>

+

Bots can access Fizzy via API using a personal access token

+
+ + <%= 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 %> +
diff --git a/app/views/account/bots/show.html.erb b/app/views/account/bots/show.html.erb new file mode 100644 index 000000000..26619033b --- /dev/null +++ b/app/views/account/bots/show.html.erb @@ -0,0 +1,76 @@ +<% @page_title = @bot.name %> + +<% content_for :header do %> +
+ <%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @bot.name %> Bot

+<% end %> + +
+ <% if @new_access_token %> +
+ +

Be sure to save this access token now because you won't be able to see it again.

+ + <%= 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" %> + Copy access token + <% end %> + +
+
+ <% end %> + + <% if @access_tokens.any? %> +

Access tokens for this bot.

+ + + + + + + + + + + <% @access_tokens.each do |access_token| %> + + + + + + + <% end %> + +
DescriptionPermissionCreated
<%= access_token.description %><%= access_token.permission.humanize %><%= local_datetime_tag access_token.created_at, style: :datetime %> + <%= 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" %> + Revoke this token + <% end %> +
+ <% else %> +

This bot has no access tokens. Generate one to allow API access.

+ <% end %> + + <%= link_to new_account_bot_access_token_path(@bot), class: "btn btn--link center" do %> + <%= icon_tag "add" %> + Generate a new access token + <% end %> + +
+ + <%= 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" %> + Remove bot + <% end %> +
diff --git a/app/views/account/settings/_bot_user.html.erb b/app/views/account/settings/_bot_user.html.erb new file mode 100644 index 000000000..daff1b14f --- /dev/null +++ b/app/views/account/settings/_bot_user.html.erb @@ -0,0 +1,18 @@ +
  • + <%= 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 %> +
    + <%= bot_user.name %> +
    Bot
    +
    + <% end %> + + + + <%= 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" %> + Remove <%= bot_user.name %> + <% end %> +
  • diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index 137532d75..5c4197b27 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -14,13 +14,24 @@ - <%= link_to account_join_code_path, class: "btn btn--link center" do %> - <%= icon_tag "add" %> - Invite people - <% end %> +
    + <%= link_to account_join_code_path, class: "btn btn--link" do %> + <%= icon_tag "add" %> + Invite people + <% end %> + + <% if Current.user.admin? %> + <%= link_to new_account_bot_path, class: "btn btn--link" do %> + <%= icon_tag "add" %> + Create bot + <% end %> + <% end %> +
    <% end %> diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index 0f42afdc0..e398dbd4a 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -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 -%>