diff --git a/app/controllers/settings/api_keys_controller.rb b/app/controllers/settings/api_keys_controller.rb index cb8b2c3db..ac5d53aa4 100644 --- a/app/controllers/settings/api_keys_controller.rb +++ b/app/controllers/settings/api_keys_controller.rb @@ -5,18 +5,24 @@ class Settings::ApiKeysController < ApplicationController before_action :set_api_key, only: [ :show, :destroy ] - def show + def index + @api_keys = Current.user.api_keys.active.visible.order(created_at: :desc) @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], - [ t("breadcrumbs.api_key"), nil ] + [ t("breadcrumbs.api_keys"), nil ] + ] + end + + def show + @newly_created = params[:newly_created].present? + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.api_keys"), settings_api_keys_path ], + [ @api_key.name, nil ] ] - @current_api_key = @api_key end def new - # Allow regeneration by not redirecting if user explicitly wants to create a new key - # Only redirect if user stumbles onto new page without explicit intent - redirect_to settings_api_key_path if Current.user.api_keys.active.visible.exists? && !params[:regenerate] @api_key = ApiKey.new end @@ -25,42 +31,33 @@ class Settings::ApiKeysController < ApplicationController @api_key = Current.user.api_keys.build(api_key_params) @api_key.key = @plain_key - # Temporarily revoke existing visible keys for validation to pass - # (demo monitoring key is excluded and remains active) - existing_keys = Current.user.api_keys.active.visible - existing_keys.each { |key| key.update_column(:revoked_at, Time.current) } - if @api_key.save flash[:notice] = t(".success") - redirect_to settings_api_key_path + redirect_to settings_api_key_path(@api_key, newly_created: true) else - # Restore existing keys if new key creation failed - existing_keys.each { |key| key.update_column(:revoked_at, nil) } render :new, status: :unprocessable_entity end end def destroy - if @api_key.nil? - flash[:alert] = t(".not_found") - elsif @api_key.demo_monitoring_key? - flash[:alert] = t(".cannot_revoke") - elsif @api_key.revoke! - flash[:notice] = t(".revoked_successfully") - else - flash[:alert] = t(".revoke_failed") - end - redirect_to settings_api_key_path + @api_key.revoke! + flash[:notice] = t(".revoked_successfully") + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed + flash[:alert] = t(".revoke_failed") + ensure + redirect_to settings_api_keys_path end private + # `.visible` excludes the demo monitoring key, so a demo key id 404s here + # before #destroy can revoke it — this is intentional (see the SECURITY note + # on ApiKey's `visible` scope). def set_api_key - @api_key = Current.user.api_keys.active.visible.first + @api_key = Current.user.api_keys.active.visible.find(params[:id]) end def api_key_params - # Convert single scope value to array for storage permitted_params = params.require(:api_key).permit(:name, :scopes) if permitted_params[:scopes].present? permitted_params[:scopes] = [ permitted_params[:scopes] ] diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 6d821ecdb..658b9199d 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -18,7 +18,7 @@ module SettingsHelper # Advanced section { name: -> { t("settings.settings_nav.ai_prompts_label") }, path: :settings_ai_prompts_path, condition: :admin_user? }, { name: -> { t("settings.settings_nav.llm_usage_label") }, path: :settings_llm_usage_path, condition: :admin_user? }, - { name: -> { t("settings.settings_nav.api_key_label") }, path: :settings_api_key_path, condition: :admin_user? }, + { name: -> { t("settings.settings_nav.api_key_label") }, path: :settings_api_keys_path, condition: :admin_user? }, { name: -> { t("settings.settings_nav.self_hosting_label") }, path: :settings_hosting_path, condition: :self_hosted_and_admin? }, { name: -> { t("settings.settings_nav.imports_label") }, path: :imports_path, condition: :admin_user? }, { name: -> { t("settings.settings_nav.exports_label") }, path: :family_exports_path, condition: :admin_user? }, diff --git a/app/models/api_key.rb b/app/models/api_key.rb index 4c12b4668..1b5543763 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -18,7 +18,7 @@ class ApiKey < ApplicationRecord validates :scopes, presence: true validates :source, presence: true, inclusion: { in: SOURCES } validate :scopes_not_empty - validate :one_active_key_per_user_per_source, on: :create + validate :name_unique_among_active_keys, on: :create # Callbacks before_validation :set_display_key @@ -26,6 +26,10 @@ class ApiKey < ApplicationRecord # Scopes scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) } + # SECURITY: excluding the demo monitoring key here is also the revocation guard + # in Settings::ApiKeysController#destroy — a demo key id 404s in `set_api_key` + # (it is not `.visible`) before it can be revoked. Do NOT broaden this scope to + # include monitoring keys without adding another explicit destroy guard. scope :visible, -> { where.not(display_key: DEMO_MONITORING_KEY) } # Class methods @@ -103,10 +107,11 @@ class ApiKey < ApplicationRecord end end - def one_active_key_per_user_per_source - if user&.api_keys&.active&.where(source: source)&.where&.not(id: id)&.exists? - errors.add(:user, "can only have one active API key per source (#{source})") - end + def name_unique_among_active_keys + return if name.blank? || user.blank? + scope = user.api_keys.active.visible.where(name: name) + scope = scope.where.not(id: id) if persisted? + errors.add(:name, :taken) if scope.exists? end def prevent_demo_monitoring_key_destroy! diff --git a/app/views/reports/google_sheets_instructions.html.erb b/app/views/reports/google_sheets_instructions.html.erb index ba8e79a9f..3eb8f4bb2 100644 --- a/app/views/reports/google_sheets_instructions.html.erb +++ b/app/views/reports/google_sheets_instructions.html.erb @@ -51,7 +51,7 @@ text: t("reports.google_sheets_instructions.go_to_api_keys"), variant: "primary", full_width: true, - href: settings_api_key_path, + href: settings_api_keys_path, frame: "_top" ) %> <% end %> diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index b65101ead..2af887c63 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -29,7 +29,7 @@ nav_sections = [ items: [ { label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" }, { label: t(".llm_usage_label"), path: settings_llm_usage_path, icon: "activity" }, - { label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" }, + { label: t(".api_keys_label"), path: settings_api_keys_path, icon: "key" }, { label: t(".mcp_label"), path: settings_mcp_path, icon: "plug" }, { label: t(".debug_label", default: "Debug"), path: settings_debug_path, icon: "bug", if: Current.user&.super_admin? }, { label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? }, diff --git a/app/views/settings/api_keys/_key_meta.html.erb b/app/views/settings/api_keys/_key_meta.html.erb new file mode 100644 index 000000000..df515e7dc --- /dev/null +++ b/app/views/settings/api_keys/_key_meta.html.erb @@ -0,0 +1,9 @@ +<%# locals: (api_key:) %> +

+ <%= t("settings.api_keys.shared.created_ago", time: time_ago_in_words(api_key.created_at)) %> + <% if api_key.last_used_at %> + • <%= t("settings.api_keys.shared.last_used_ago", time: time_ago_in_words(api_key.last_used_at)) %> + <% else %> + • <%= t("settings.api_keys.shared.never_used") %> + <% end %> +

diff --git a/app/views/settings/api_keys/_key_reveal.html.erb b/app/views/settings/api_keys/_key_reveal.html.erb new file mode 100644 index 000000000..8852b0021 --- /dev/null +++ b/app/views/settings/api_keys/_key_reveal.html.erb @@ -0,0 +1,17 @@ +<%# locals: (api_key:, heading:, description:, copy_label:) %> +
+

<%= heading %>

+

<%= description %>

+ +
+
+ <%= api_key.plain_key %> + <%= render DS::Button.new( + text: copy_label, + variant: "ghost", + icon: "copy", + data: { action: "clipboard#copy" } + ) %> +
+
+
diff --git a/app/views/settings/api_keys/_scope_badges.html.erb b/app/views/settings/api_keys/_scope_badges.html.erb new file mode 100644 index 000000000..bb7ef4ecd --- /dev/null +++ b/app/views/settings/api_keys/_scope_badges.html.erb @@ -0,0 +1,11 @@ +<%# locals: (scopes:) %> +
+ <% scopes.each do |scope| %> + <% label = case scope + when "read" then t("settings.api_keys.shared.scope_read_only") + when "read_write" then t("settings.api_keys.shared.scope_read_write") + else t("settings.api_keys.shared.scopes.#{scope}", default: scope.humanize) + end %> + <%= render DS::Pill.new(label: label, icon: "shield-check", tone: :indigo, style: :soft, marker: false) %> + <% end %> +
diff --git a/app/views/settings/api_keys/_status_indicator.html.erb b/app/views/settings/api_keys/_status_indicator.html.erb new file mode 100644 index 000000000..ec4bedc3e --- /dev/null +++ b/app/views/settings/api_keys/_status_indicator.html.erb @@ -0,0 +1 @@ +<%= render DS::Pill.new(label: t("settings.api_keys.shared.active"), tone: :success, show_dot: true, marker: false) %> diff --git a/app/views/settings/api_keys/_usage.html.erb b/app/views/settings/api_keys/_usage.html.erb new file mode 100644 index 000000000..2b1f41602 --- /dev/null +++ b/app/views/settings/api_keys/_usage.html.erb @@ -0,0 +1,8 @@ +<%# locals: (api_key:, title:, description:) %> +
+

<%= title %>

+

<%= description %>

+
+ curl -H "X-Api-Key: <%= api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts +
+
diff --git a/app/views/settings/api_keys/created.html.erb b/app/views/settings/api_keys/created.html.erb deleted file mode 100644 index d0c4e7bca..000000000 --- a/app/views/settings/api_keys/created.html.erb +++ /dev/null @@ -1,87 +0,0 @@ -<%= content_for :page_title, t(".page_title") %> - -<%= settings_section title: t(".success_title"), subtitle: t(".success_description") do %> -
-
-
- <%= render DS::FilledIcon.new( - icon: "check-circle", - rounded: true, - size: "lg", - variant: :success - ) %> -
-

<%= t(".success_title") %>

-

<%= t(".key_ready", name: @api_key.name) %>

-
-
-
- -
-

<%= t(".your_api_key") %>

-

<%= t(".copy_store_securely") %>

- -
-
- <%= @api_key.plain_key %> - <%= render DS::Button.new( - text: t(".copy_key"), - variant: "ghost", - icon: "copy", - data: { action: "clipboard#copy" } - ) %> -
-
-
- -
-

<%= t(".key_details_title") %>

-
-
- <%= t(".key_name_label") %> - <%= @api_key.name %> -
-
- <%= t(".permissions_label") %> - - <%= @api_key.scopes.map { |scope| - case scope - when "read_accounts" then t("settings.api_keys_controller.scope_descriptions.read_accounts") - when "read_transactions" then t("settings.api_keys_controller.scope_descriptions.read_transactions") - when "read_balances" then t("settings.api_keys_controller.scope_descriptions.read_balances") - when "write_transactions" then t("settings.api_keys_controller.scope_descriptions.write_transactions") - else scope.humanize - end - }.join(", ") %> - -
-
- <%= t(".created_label") %> - <%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %> -
-
-
- - <%= render DS::Alert.new(title: t(".security_note_title"), variant: :warning) do %> -

- <%= t(".security_note_body") %> -

- <% end %> - -
-

<%= t(".usage_instructions_title") %>

-

<%= t("settings.api_keys.show.current_api_key.usage_instructions", product_name: product_name) %>

-
- curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts -
-
- -
- <%= render DS::Link.new( - text: t(".continue"), - href: settings_api_key_path, - variant: "primary" - ) %> -
-
-<% end %> diff --git a/app/views/settings/api_keys/created.turbo_stream.erb b/app/views/settings/api_keys/created.turbo_stream.erb deleted file mode 100644 index f187a6d31..000000000 --- a/app/views/settings/api_keys/created.turbo_stream.erb +++ /dev/null @@ -1,95 +0,0 @@ -<%= turbo_stream.update "main" do %> -
-
-

- <%= t("settings.api_keys.created.page_title") %> -

- - <%= settings_section title: t("settings.api_keys.created.success_title"), subtitle: t("settings.api_keys.created.success_description") do %> -
-
-
- <%= render DS::FilledIcon.new( - icon: "check-circle", - rounded: true, - size: "lg", - variant: :success - ) %> -
-

<%= t("settings.api_keys.created.success_title") %>

-

<%= t("settings.api_keys.created.key_ready", name: @api_key.name) %>

-
-
-
- -
-

<%= t("settings.api_keys.created.your_api_key") %>

-

<%= t("settings.api_keys.created.copy_store_securely") %>

- -
-
- <%= @api_key.plain_key %> - <%= render DS::Button.new( - text: t("settings.api_keys.created.copy_key"), - variant: "ghost", - icon: "copy", - data: { action: "clipboard#copy" } - ) %> -
-
-
- -
-

<%= t("settings.api_keys.created.key_details_title") %>

-
-
- <%= t("settings.api_keys.created.key_name_label") %> - <%= @api_key.name %> -
-
- <%= t("settings.api_keys.created.permissions_label") %> - - <%= @api_key.scopes.map { |scope| - case scope - when "read_accounts" then t("settings.api_keys_controller.scope_descriptions.read_accounts") - when "read_transactions" then t("settings.api_keys_controller.scope_descriptions.read_transactions") - when "read_balances" then t("settings.api_keys_controller.scope_descriptions.read_balances") - when "write_transactions" then t("settings.api_keys_controller.scope_descriptions.write_transactions") - else scope.humanize - end - }.join(", ") %> - -
-
- <%= t("settings.api_keys.created.created_label") %> - <%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %> -
-
-
- - <%= render DS::Alert.new(title: t("settings.api_keys.created.security_note_title"), variant: :warning) do %> -

- <%= t("settings.api_keys.created.security_note_body") %> -

- <% end %> - -
-

<%= t("settings.api_keys.created.usage_instructions_title") %>

-

<%= t("settings.api_keys.show.current_api_key.usage_instructions", product_name: product_name) %>

-
- curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts -
-
- -
- <%= render DS::Link.new( - text: t("settings.api_keys.created.continue"), - href: settings_api_key_path, - variant: "primary" - ) %> -
-
- <% end %> -
-
-<% end %> diff --git a/app/views/settings/api_keys/index.html.erb b/app/views/settings/api_keys/index.html.erb new file mode 100644 index 000000000..7a6cbd2a4 --- /dev/null +++ b/app/views/settings/api_keys/index.html.erb @@ -0,0 +1,72 @@ +<%= content_for :page_title, t(".title") %> +<% unless @api_keys.empty? %> + <%= content_for :page_actions do %> + <%= render DS::Link.new( + text: t(".new_key"), + href: new_settings_api_key_path, + variant: "primary", + icon: "plus" + ) %> + <% end %> +<% end %> + +<% if @api_keys.empty? %> +
+
+
+ <%= render DS::FilledIcon.new( + icon: "key", + rounded: true, + size: "lg" + ) %> +
+

<%= t(".empty_heading") %>

+

<%= t(".empty_description") %>

+
+ <%= render DS::Link.new( + text: t(".new_key"), + href: new_settings_api_key_path, + variant: "primary", + icon: "plus" + ) %> +
+
+
+<% else %> +
+ <% @api_keys.each do |api_key| %> +
+
+
+ <%= render DS::FilledIcon.new( + icon: "key", + rounded: true, + size: "lg" + ) %> +
+ <%= link_to api_key.name, settings_api_key_path(api_key), class: "text-primary font-medium hover:underline break-all" %> + <%= render "settings/api_keys/key_meta", api_key: api_key %> +
+ <%= render "settings/api_keys/scope_badges", scopes: api_key.scopes %> +
+
+
+ +
+ <%= render "settings/api_keys/status_indicator" %> + <%= render DS::Button.new( + text: t(".revoke_key"), + href: settings_api_key_path(api_key), + method: :delete, + variant: "destructive", + size: "sm", + data: { + turbo_confirm: t(".revoke_confirmation", name: api_key.name) + } + ) %> +
+
+
+ <% end %> +
+<% end %> diff --git a/app/views/settings/api_keys/new.html.erb b/app/views/settings/api_keys/new.html.erb index d37a24f70..656640436 100644 --- a/app/views/settings/api_keys/new.html.erb +++ b/app/views/settings/api_keys/new.html.erb @@ -1,7 +1,7 @@ <%= content_for :page_title, t(".create_new_api_key") %> <%= settings_section title: nil, subtitle: t(".subtitle") do %> - <%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %> + <%= styled_form_with model: @api_key, url: settings_api_keys_path, class: "space-y-4" do |form| %> <%= form.text_field :name, placeholder: t(".name_placeholder"), label: t(".name_label"), @@ -39,7 +39,7 @@
<%= render DS::Link.new( text: t(".cancel"), - href: settings_api_key_path, + href: settings_api_keys_path, variant: "ghost" ) %> diff --git a/app/views/settings/api_keys/show.html.erb b/app/views/settings/api_keys/show.html.erb index c32e87349..f27dc4166 100644 --- a/app/views/settings/api_keys/show.html.erb +++ b/app/views/settings/api_keys/show.html.erb @@ -1,4 +1,13 @@ -<% if @newly_created && @plain_key %> +<%= content_for :page_actions do %> + <%= render DS::Link.new( + text: t(".current_api_key.back_to_keys"), + href: settings_api_keys_path, + variant: "secondary", + icon: "chevron-left" + ) %> +<% end %> + +<% if @newly_created %> <%= content_for :page_title, t(".newly_created.page_title") %>
@@ -13,54 +22,33 @@ ) %>

<%= t(".newly_created.heading") %>

-

<%= t(".newly_created.key_ready", name: @current_api_key.name) %>

+

<%= t(".newly_created.key_ready", name: @api_key.name) %>

-
-

<%= t(".newly_created.your_api_key") %>

-

<%= t(".newly_created.copy_store_securely") %>

+ <%= render "settings/api_keys/key_reveal", + api_key: @api_key, + heading: t(".newly_created.your_api_key"), + description: t(".newly_created.copy_store_securely"), + copy_label: t(".newly_created.copy_api_key") %> -
-
- <%= @current_api_key.plain_key %> - <%= render DS::Button.new( - text: t(".newly_created.copy_api_key"), - variant: "ghost", - icon: "copy", - data: { action: "clipboard#copy" } - ) %> -
-
-
- -
-

<%= t(".newly_created.how_to_use") %>

-

<%= t(".current_api_key.usage_instructions", product_name: product_name) %>

-
- curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts -
-
+ <%= render "settings/api_keys/usage", + api_key: @api_key, + title: t(".newly_created.how_to_use"), + description: t(".current_api_key.usage_instructions", product_name: product_name) %>
<%= render DS::Link.new( text: t(".newly_created.continue"), - href: settings_api_key_path, + href: settings_api_keys_path, variant: "primary" ) %>
-<% elsif @current_api_key %> +<% else %> <%= content_for :page_title, t(".current_api_key.title") %> - <%= content_for :page_actions do %> - <%= render DS::Link.new( - text: t(".current_api_key.regenerate_key"), - href: new_settings_api_key_path(regenerate: true), - variant: "secondary" - ) %> - <% end %>
@@ -73,68 +61,34 @@ ) %>
-

<%= @current_api_key.name %>

-

- <%= t(".current_api_key.created_ago", time: time_ago_in_words(@current_api_key.created_at)) %> - <% if @current_api_key.last_used_at %> - • <%= t(".current_api_key.last_used_ago", time: time_ago_in_words(@current_api_key.last_used_at)) %> - <% else %> - • <%= t(".current_api_key.never_used") %> - <% end %> -

+

<%= @api_key.name %>

+ <%= render "settings/api_keys/key_meta", api_key: @api_key %>
-
-

<%= t(".current_api_key.active") %>

-
+ <%= render "settings/api_keys/status_indicator" %>

<%= t(".current_api_key.permissions") %>

-
- <% @current_api_key.scopes.each do |scope| %> - - <%= icon("shield-check", class: "w-3 h-3") %> - <%= case scope - when "read" then t(".current_api_key.scope_read_only") - when "read_write" then t(".current_api_key.scope_read_write") - else scope.humanize - end %> - - <% end %> -
+ <%= render "settings/api_keys/scope_badges", scopes: @api_key.scopes %>
-
-

<%= t(".current_api_key.title") %>

-

<%= t(".current_api_key.copy_store_securely") %>

+ <%= render "settings/api_keys/key_reveal", + api_key: @api_key, + heading: t(".current_api_key.title"), + description: t(".current_api_key.copy_store_securely"), + copy_label: t(".current_api_key.copy_api_key") %> -
-
- <%= @current_api_key.plain_key %> - <%= render DS::Button.new( - text: t(".current_api_key.copy_api_key"), - variant: "ghost", - icon: "copy", - data: { action: "clipboard#copy" } - ) %> -
-
-
- -
-

<%= t(".current_api_key.usage_instructions_title") %>

-

<%= t(".current_api_key.usage_instructions", product_name: product_name) %>

-
- curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts -
-
+ <%= render "settings/api_keys/usage", + api_key: @api_key, + title: t(".current_api_key.usage_instructions_title"), + description: t(".current_api_key.usage_instructions", product_name: product_name) %>
<%= render DS::Button.new( text: t(".current_api_key.revoke_key"), - href: settings_api_key_path, + href: settings_api_key_path(@api_key), method: :delete, variant: "destructive", data: { @@ -144,54 +98,4 @@
-<% else %> - <%= content_for :page_title, t(".no_api_key.title") %> - <%= content_for :page_actions do %> - <%= render DS::Link.new( - text: t(".no_api_key.create_api_key"), - href: new_settings_api_key_path, - variant: "primary" - ) %> - <% end %> - -
-
-
-
- <%= render DS::FilledIcon.new( - icon: "key", - rounded: true, - size: "lg" - ) %> -
-

<%= t(".no_api_key.heading", product_name: product_name) %>

-

<%= t(".no_api_key.description") %>

-
-
-
- -
-

<%= t(".no_api_key.what_you_can_do") %>

-
    -
  • - <%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %> - <%= t(".no_api_key.feature_1") %> -
  • -
  • - <%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %> - <%= t(".no_api_key.feature_2") %> -
  • -
  • - <%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %> - <%= t(".no_api_key.feature_3") %> -
  • -
-
- -
-

<%= t(".no_api_key.security_note_title") %>

-

<%= t(".no_api_key.security_note") %>

-
-
-
<% end %> diff --git a/config/locales/views/settings/api_keys/en.yml b/config/locales/views/settings/api_keys/en.yml index ebc072b88..c9fd6eba5 100644 --- a/config/locales/views/settings/api_keys/en.yml +++ b/config/locales/views/settings/api_keys/en.yml @@ -5,32 +5,29 @@ en: success: "Your API key has been created successfully" revoked_successfully: "API key has been revoked successfully" revoke_failed: "Failed to revoke API key" - scope_descriptions: - read_accounts: "View Accounts" - read_transactions: "View Transactions" - read_balances: "View Balances" - write_transactions: "Create Transactions" api_keys: create: success: "Your API key has been created successfully" destroy: - not_found: "API key not found" - cannot_revoke: "This API key cannot be revoked" revoked_successfully: "API key has been revoked successfully" revoke_failed: "Failed to revoke API key" + shared: + active: "Active" + created_ago: "Created %{time} ago" + last_used_ago: "Last used %{time} ago" + never_used: "Never used" + scope_read_only: "Read Only" + scope_read_write: "Read/Write" + index: + title: "API Keys" + subtitle: "Manage API keys for programmatic access to your data." + new_key: "New API Key" + empty_heading: "No API keys yet" + empty_description: "Create an API key to access your data programmatically." + revoke_key: "Revoke" + revoke_confirmation: "Are you sure you want to revoke \"%{name}\"? This will immediately disable any application using this key." show: title: "API Key Management" - no_api_key: - title: "API Key" - heading: "Access your account data programmatically" - description: "Get programmatic access to your Sure data with a secure API key." - what_you_can_do: "What you can do with the API:" - feature_1: "Access your account data programmatically" - feature_2: "Build custom integrations and applications" - feature_3: "Automate data retrieval and analysis" - security_note_title: "Security First" - security_note: "Your API key will have restricted permissions based on the scopes you select. You can only have one active API key at a time." - create_api_key: "Create API Key" newly_created: page_title: "API Key Created Successfully" heading: "API Key Created Successfully!" @@ -42,25 +39,12 @@ en: continue: "Continue to API Key Settings" current_api_key: title: "Your API Key" - description: "Your active API key is ready to use. Keep it secure and never share it publicly." - active: "Active" - key_name: "Name" - created_at: "Created" - created_ago: "Created %{time} ago" - last_used: "Last Used" - last_used_ago: "Last used %{time} ago" - expires: "Expires" - ago: "ago" - never_used: "Never used" - never_expires: "Never expires" permissions: "Permissions" - scope_read_only: "Read Only" - scope_read_write: "Read/Write" copy_api_key: "Copy API Key" copy_store_securely: "Copy and store this key securely. You'll need it to authenticate your API requests." usage_instructions_title: "How to use your API key" usage_instructions: "Include your API key in the X-Api-Key header when making requests to the %{product_name} API:" - regenerate_key: "Create New Key" + back_to_keys: "Back to API Keys" revoke_key: "Revoke Key" revoke_confirmation: "Are you sure you want to revoke this API key? This action cannot be undone and will immediately disable all applications using this key." new: @@ -88,28 +72,4 @@ en: security_warning: "Your API key will be shown only once after creation. Store it securely and never share it publicly. If you lose it, you'll need to create a new one." create_key: "Create API Key" cancel: "Cancel" - save_api_key: "Save API Key" - created: - page_title: "API Key Created" - title: "API Key Created" - success_title: "API Key Created Successfully" - success_description: "Your new API key has been generated successfully." - key_ready: "Your new API key \"%{name}\" has been created and is ready to use." - your_api_key: "Your API Key" - copy_store_securely: "Copy and store this key securely. You'll need it to authenticate your API requests." - key_details_title: "Key Details" - key_name_label: "Name:" - permissions_label: "Permissions:" - created_label: "Created:" - security_note_title: "Important Security Note" - security_note_body: "This is the only time your API key will be displayed. Make sure to copy it now and store it securely. If you lose this key, you'll need to generate a new one." - usage_instructions_title: "How to use your API key" - key_name: "Name" - permissions: "Permissions" - critical_warning_title: "Critical: Save Your API Key Now" - critical_warning_1: "This is the only time you'll see your API key in plain text." - critical_warning_2: "Copy and store it securely in your password manager or application." - critical_warning_3: "If you lose this key, you'll need to create a new one." - usage_instructions: "Use your API key by including it in the X-Api-Key header:" - copy_key: "Copy API Key" - continue: "Continue to API Key Settings" \ No newline at end of file + save_api_key: "Save API Key" \ No newline at end of file diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index c9913ee8e..ee825a7d0 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -262,7 +262,7 @@ en: accounts_label: Accounts advanced_section_title: Advanced ai_prompts_label: AI Prompts - api_key_label: API Key + api_key_label: API Keys payment_label: Payment categories_label: Categories feedback_label: Feedback @@ -288,7 +288,7 @@ en: transactions_section_title: Transactions users_label: Users whats_new_label: What's new - api_keys_label: API Key + api_keys_label: API Keys appearance_label: Appearance bank_sync_label: Bank sync debug_label: Debug diff --git a/config/routes.rb b/config/routes.rb index 3570ce392..8dbef6d5a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -258,7 +258,7 @@ Rails.application.routes.draw do post :options, on: :collection end resources :sso_identities, only: :destroy - resource :api_key, only: [ :show, :new, :create, :destroy ] + resources :api_keys, only: [ :index, :show, :new, :create, :destroy ] resource :mcp, controller: "mcp", only: :show do delete "tokens/:token_id", to: "mcp#revoke", as: :revoke_token end diff --git a/test/controllers/settings/api_keys_controller_test.rb b/test/controllers/settings/api_keys_controller_test.rb index 1905a4cab..df20dd313 100644 --- a/test/controllers/settings/api_keys_controller_test.rb +++ b/test/controllers/settings/api_keys_controller_test.rb @@ -7,29 +7,20 @@ class Settings::ApiKeysControllerTest < ActionDispatch::IntegrationTest sign_in @user end - test "should show no API key page when user has no active keys" do - get settings_api_key_path - assert_response :success - end - - test "should show current API key when user has active key" do - @api_key = ApiKey.create!( + test "index shows api keys list" do + ApiKey.create!( user: @user, - name: "Test API Key", - display_key: "test_key_123", + name: "Listed Key", + display_key: "listed_key_123", scopes: [ "read" ] ) - get settings_api_key_path + get settings_api_keys_path assert_response :success + assert_includes response.body, "Listed Key" end - test "should show new API key form" do - get new_settings_api_key_path - assert_response :success - end - - test "should redirect to show when user already has active key and tries to visit new" do + test "new always renders form (no redirect when key exists)" do ApiKey.create!( user: @user, name: "Existing API Key", @@ -38,57 +29,38 @@ class Settings::ApiKeysControllerTest < ActionDispatch::IntegrationTest ) get new_settings_api_key_path - assert_redirected_to settings_api_key_path + assert_response :success end - test "should create new API key with valid parameters" do + test "create makes a new key without revoking existing keys" do + existing = ApiKey.create!( + user: @user, + name: "Existing API Key", + display_key: "existing_key_123", + scopes: [ "read" ] + ) + assert_difference "ApiKey.count", 1 do - post settings_api_key_path, params: { + post settings_api_keys_path, params: { api_key: { - name: "Test Integration Key", + name: "Brand New Key", scopes: "read_write" } } end - assert_redirected_to settings_api_key_path - follow_redirect! - assert_response :success + new_key = @user.api_keys.active.visible.find_by(name: "Brand New Key") + assert new_key.present? + assert_redirected_to settings_api_key_path(new_key, newly_created: true) - api_key = @user.api_keys.active.first - assert_equal "Test Integration Key", api_key.name - assert_includes api_key.scopes, "read_write" + existing.reload + refute existing.revoked? + assert_includes new_key.scopes, "read_write" end - test "should revoke existing key when creating new one" do - old_key = ApiKey.create!( - user: @user, - name: "Old API Key", - display_key: "old_key_123", - scopes: [ "read" ] - ) - - post settings_api_key_path, params: { - api_key: { - name: "New API Key", - scopes: "read_write" - } - } - - assert_redirected_to settings_api_key_path - follow_redirect! - assert_response :success - - old_key.reload - assert old_key.revoked? - - new_key = @user.api_keys.active.first - assert_equal "New API Key", new_key.name - end - - test "should not create API key without name" do + test "create rejects blank name" do assert_no_difference "ApiKey.count" do - post settings_api_key_path, params: { + post settings_api_keys_path, params: { api_key: { name: "", scopes: "read" @@ -99,93 +71,133 @@ class Settings::ApiKeysControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity end - test "should not create API key without scopes" do - # Ensure clean state for this specific test - @user.api_keys.destroy_all - initial_user_count = @user.api_keys.count - - assert_no_difference "@user.api_keys.count" do - post settings_api_key_path, params: { - api_key: { - name: "Test Key", - scopes: [] + test "create rejects blank scopes" do + assert_no_difference "ApiKey.count" do + post settings_api_keys_path, params: { + api_key: { + name: "No Scopes Key", + scopes: [] + } } - } + end + + assert_response :unprocessable_entity end - assert_response :unprocessable_entity - assert_equal initial_user_count, @user.api_keys.reload.count -end + test "create rejects duplicate active name" do + ApiKey.create!( + user: @user, + name: "Dup", + display_key: "dup_key_123", + scopes: [ "read" ] + ) - test "should revoke API key" do - @api_key = ApiKey.create!( + assert_no_difference "ApiKey.count" do + post settings_api_keys_path, params: { + api_key: { + name: "Dup", + scopes: "read_write" + } + } + end + + assert_response :unprocessable_entity + end + + test "show renders a key" do + created_key = ApiKey.create!( user: @user, name: "Test API Key", display_key: "test_key_123", scopes: [ "read" ] ) - delete settings_api_key_path - - assert_redirected_to settings_api_key_path - follow_redirect! + get settings_api_key_path(created_key) assert_response :success - - @api_key.reload - assert @api_key.revoked? + assert_includes response.body, "Test API Key" end - test "should handle revoke when no API key exists" do - delete settings_api_key_path + test "show renders the newly created confirmation" do + created_key = ApiKey.create!( + user: @user, + name: "Fresh Key", + display_key: "fresh_key_123", + scopes: [ "read" ] + ) - assert_redirected_to settings_api_key_path - # Should not error even when no API key exists + get settings_api_key_path(created_key, newly_created: true) + assert_response :success + assert_includes response.body, created_key.plain_key + assert_select "h3", text: I18n.t("settings.api_keys.show.newly_created.heading") end - test "should only allow one active API key per user" do - # Create first API key - post settings_api_key_path, params: { - api_key: { - name: "First Key", - scopes: "read" - } - } + test "show 404s on another user's key" do + other_user = users(:family_member) + other_user.api_keys.destroy_all + other_key = ApiKey.create!( + user: other_user, + name: "Other User Key", + display_key: "other_user_key_123", + scopes: [ "read" ] + ) - first_key = @user.api_keys.active.first - - # Create second API key - post settings_api_key_path, params: { - api_key: { - name: "Second Key", - scopes: "read_write" - } - } - - # First key should be revoked - first_key.reload - assert first_key.revoked? - - # Only one active key should exist - assert_equal 1, @user.api_keys.active.count - assert_equal "Second Key", @user.api_keys.active.first.name + get settings_api_key_path(other_key) + assert_response :not_found end - test "should generate secure random API key" do - post settings_api_key_path, params: { + test "destroy revokes the targeted key only" do + key1 = ApiKey.create!( + user: @user, + name: "Key One", + display_key: "key_one_123", + scopes: [ "read" ] + ) + key2 = ApiKey.create!( + user: @user, + name: "Key Two", + display_key: "key_two_123", + scopes: [ "read_write" ] + ) + + delete settings_api_key_path(key1) + assert_redirected_to settings_api_keys_path + + key1.reload + key2.reload + assert key1.revoked? + refute key2.revoked? + end + + test "destroy cannot revoke demo monitoring key" do + # set_api_key scopes to .visible which EXCLUDES the demo key, so the + # demo key id is not found by the controller and the request 404s + # before reaching the cannot_revoke branch. + demo_key = ApiKey.create!( + user: @user, + name: "Demo Monitoring Key", + display_key: ApiKey::DEMO_MONITORING_KEY, + scopes: [ "read" ] + ) + + delete settings_api_key_path(demo_key) + assert_response :not_found + + demo_key.reload + refute demo_key.revoked? + end + + test "create generates a secure random API key" do + post settings_api_keys_path, params: { api_key: { name: "Random Key Test", scopes: "read" } } - assert_redirected_to settings_api_key_path - follow_redirect! - assert_response :success - - # Verify the API key was created with expected properties - api_key = @user.api_keys.active.first - assert api_key.present? - assert_equal "Random Key Test", api_key.name - assert_includes api_key.scopes, "read" + created_key = @user.api_keys.active.visible.find_by(name: "Random Key Test") + assert created_key.present? + assert_redirected_to settings_api_key_path(created_key, newly_created: true) + assert_includes created_key.scopes, "read" + assert_equal 64, created_key.plain_key.length end end diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb index edd60389a..46087f790 100644 --- a/test/models/api_key_test.rb +++ b/test/models/api_key_test.rb @@ -135,7 +135,7 @@ class ApiKeyTest < ActiveSupport::TestCase assert @api_key.last_used_at > (original_time || Time.at(0)) end - test "should prevent user from having multiple active api keys" do + test "allows multiple active api keys for the same user" do @api_key.save! second_key = ApiKey.new( @@ -145,22 +145,69 @@ class ApiKeyTest < ActiveSupport::TestCase scopes: [ "read" ] ) - assert_not second_key.valid? - assert_includes second_key.errors[:user], "can only have one active API key per source (web)" - end - - test "should allow user to have new active key after revoking old one" do - @api_key.save! - @api_key.revoke! - - second_key = ApiKey.new( - user: @user, - name: "Second API Key", - key: "another_key_123", - scopes: [ "read" ] - ) - assert second_key.valid? + assert second_key.save + assert second_key.active? + assert_equal 2, @user.api_keys.active.count + end + + test "rejects duplicate name among active keys for the same user" do + ApiKey.create!( + user: @user, + name: "Dup", + key: "dup_key_123", + scopes: [ "read" ] + ) + + duplicate = ApiKey.new( + user: @user, + name: "Dup", + key: "dup_key_456", + scopes: [ "read" ] + ) + + assert_not duplicate.valid? + assert_includes duplicate.errors.attribute_names, :name + end + + test "allows reusing the name of a revoked key" do + first = ApiKey.create!( + user: @user, + name: "Reuse", + key: "reuse_key_123", + scopes: [ "read" ] + ) + first.revoke! + + reused = ApiKey.new( + user: @user, + name: "Reuse", + key: "reuse_key_456", + scopes: [ "read" ] + ) + + assert reused.valid? + end + + test "allows the same name across different users" do + ApiKey.create!( + user: @user, + name: "Shared", + key: "shared_key_123", + scopes: [ "read" ] + ) + + other_user = users(:family_member) + other_user.api_keys.destroy_all + + other_key = ApiKey.new( + user: other_user, + name: "Shared", + key: "shared_key_456", + scopes: [ "read" ] + ) + + assert other_key.valid? end test "should allow active monitoring key alongside active web key" do @@ -177,6 +224,21 @@ class ApiKeyTest < ActiveSupport::TestCase assert monitoring_key.valid? end + test "visible scope excludes the demo monitoring key" do + # The demo key's exclusion from `.visible` is the revocation guard used by + # Settings::ApiKeysController#destroy. Lock the invariant so broadening the + # scope can't silently make the demo key revocable. + demo_key = ApiKey.create!( + user: @user, + name: "Demo Monitoring Key", + display_key: ApiKey::DEMO_MONITORING_KEY, + scopes: [ "read" ] + ) + + refute_includes ApiKey.visible, demo_key + refute_includes @user.api_keys.active.visible, demo_key + end + test "should include active api keys in active scope" do @api_key.save! active_keys = ApiKey.active diff --git a/test/system/settings/api_keys_test.rb b/test/system/settings/api_keys_test.rb index e558cb945..bf296309f 100644 --- a/test/system/settings/api_keys_test.rb +++ b/test/system/settings/api_keys_test.rb @@ -7,200 +7,75 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase login_as @user end - test "should show no API key state when user has no active keys" do - visit settings_api_key_path + test "should show empty state when user has no API keys" do + visit settings_api_keys_path - assert_text "API Key" - assert_link "Create API Key", href: new_settings_api_key_path - assert_text "Access your account data programmatically" + assert_text "API Keys" + assert_text "No API keys yet" + assert_link "New API Key", href: new_settings_api_key_path end - test "should navigate to create new API key form" do - visit settings_api_key_path - click_link "Create API Key" + test "should create multiple keys, list them, and revoke one" do + # Create the first key + visit settings_api_keys_path + click_link "New API Key" assert_current_path new_settings_api_key_path assert_text "Create New API Key" - assert_field "API Key Name" - assert_text "Read Only" - assert_text "Read/Write" - end - test "should create a new API key with selected scopes" do - visit new_settings_api_key_path - - fill_in "API Key Name", with: "Test Integration Key" + fill_in "API Key Name", with: "First Key" choose "Read/Write" - click_button "Save API Key" - # Should redirect to show page with the API key details - assert_current_path settings_api_key_path - assert_text "Test Integration Key" - assert_text "Your API Key" - - # Should show the actual API key value - api_key_display = find("#api-key-display") + # Newly-created confirmation page shows the plain key + assert_text "API Key Created Successfully" + assert_text "First Key" + api_key_display = find("[data-clipboard-target='source']") assert api_key_display.text.length > 30 # Should be a long hex string - # Should show copy buttons - assert_button "Copy API Key" - assert_link "Create New Key" - end + # Back to the list, the first key is shown + click_link "Continue to API Key Settings" + assert_current_path settings_api_keys_path + assert_text "First Key" - test "should show current API key details after creation" do - # Create an API key first - api_key = ApiKey.create!( - user: @user, - name: "Production API Key", - display_key: "test_plain_key_123", - scopes: [ "read_write" ] - ) - - visit settings_api_key_path - - assert_text "Your API Key" - assert_text "Production API Key" - assert_text "Active" - assert_text "Read/Write" - assert_text "Never used" - assert_link "Create New Key" - assert_button "Revoke Key" - end - - test "should show usage instructions and example curl command" do - api_key = ApiKey.create!( - user: @user, - name: "Test API Key", - display_key: "test_key_123", - scopes: [ "read" ] - ) - - visit settings_api_key_path - - assert_text "How to use your API key" - assert_text "curl -H \"X-Api-Key: test_key_123\"" - assert_text "/api/v1/accounts" - end - - test "should allow regenerating API key" do - api_key = ApiKey.create!( - user: @user, - name: "Old API Key", - display_key: "old_key_123", - scopes: [ "read" ] - ) - - visit settings_api_key_path - click_link "Create New Key" - - # Should be on the new API key form - assert_text "Create New API Key" - - fill_in "API Key Name", with: "New API Key" + # Create a SECOND key with a different name + click_link "New API Key" + fill_in "API Key Name", with: "Second Key" choose "Read Only" click_button "Save API Key" - # Should redirect to show page with new key - assert_text "New API Key" - assert_text "Your API Key" + assert_text "API Key Created Successfully" + click_link "Continue to API Key Settings" - # Old key should be revoked - api_key.reload - assert api_key.revoked? - end + # Both keys appear in the list + assert_current_path settings_api_keys_path + assert_text "First Key" + assert_text "Second Key" - test "should allow revoking API key with confirmation" do - api_key = ApiKey.create!( - user: @user, - name: "Test API Key", - display_key: "test_key_123", - scopes: [ "read" ] - ) + # Revoke the first key via the custom confirmation dialog + within all(".bg-container", text: "First Key").first do + click_button "Revoke" + end - visit settings_api_key_path - - # Click the revoke button to open the modal - click_button "Revoke Key" - - # Wait for the dialog and then confirm assert_selector "#confirm-dialog", visible: true within "#confirm-dialog" do click_button "Confirm" end - - # Wait for redirect after revoke assert_no_selector "#confirm-dialog" - assert_text "API Key" - assert_text "Access your account data programmatically" + # First key gone, second remains + assert_no_text "First Key" + assert_text "Second Key" - # Key should be revoked in the database - api_key.reload - assert api_key.revoked? + assert @user.api_keys.where(name: "First Key").first.revoked? + refute @user.api_keys.active.where(name: "Second Key").empty? end - test "should redirect to show when user already has active key and tries to visit new" do - api_key = ApiKey.create!( - user: @user, - name: "Existing API Key", - display_key: "existing_key_123", - scopes: [ "read" ] - ) - - visit new_settings_api_key_path - - assert_current_path settings_api_key_path - end - - test "should show API key in navigation" do - visit settings_api_key_path + test "should show API keys in navigation" do + visit settings_api_keys_path within("nav") do - assert_text "API Key" + assert_text "API Keys" end end - - test "should validate API key name is required" do - visit new_settings_api_key_path - - # Try to submit without name - choose "Read Only" - click_button "Save API Key" - - # Should stay on form with validation error - assert_current_path new_settings_api_key_path - assert_field "API Key Name" # Form should still be visible - # The form might not show the validation error inline, but should remain on the form - end - - test "should show last used timestamp when API key has been used" do - api_key = ApiKey.create!( - user: @user, - name: "Used API Key", - display_key: "used_key_123", - scopes: [ "read" ], - last_used_at: 2.hours.ago - ) - - visit settings_api_key_path - - assert_text "2 hours ago" - assert_no_text "Never used" - end - - test "should show expiration date when API key has expiration" do - api_key = ApiKey.create!( - user: @user, - name: "Expiring API Key", - display_key: "expiring_key_123", - scopes: [ "read" ], - expires_at: 30.days.from_now - ) - - visit settings_api_key_path - - # Should show some indication of expiration (exact format may vary) - assert_no_text "Never expires" - end end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 96ec24dcc..954c15266 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -30,7 +30,7 @@ class SettingsTest < ApplicationSystemTestCase @settings_links.insert(merchants_index + 1, [ "Statement Vault", account_statements_path ]) @settings_links += [ [ "AI Prompts", settings_ai_prompts_path ], - [ "API Key", settings_api_key_path ] + [ "API Keys", settings_api_keys_path ] ] end end @@ -93,7 +93,7 @@ class SettingsTest < ApplicationSystemTestCase # Assert that admin-only settings are not present in the navigation assert_no_selector "li", text: "AI Prompts" - assert_no_selector "li", text: "API Key" + assert_no_selector "li", text: "API Keys" assert_no_selector "li", text: "Bank sync" assert_no_selector "li", text: "Statement Vault" end