Files
sure/app/views/settings/api_keys/show.html.erb
Will Wilson 9487e6cbfb Allow multiple active API keys per user (#2077)
* feat(api): allow multiple active API keys per user

Previously a user could hold only one active API key; creating a new one
silently revoked the existing key, breaking any app using it. Allow
multiple named active keys instead.

- Drop the one-active-key-per-source validation; add name uniqueness
  among the user's active visible keys (revoked names are reusable,
  same name allowed across users).
- Rewrite Settings::ApiKeysController as a RESTful collection
  (index/show/new/create/destroy); create no longer revokes existing
  keys, and key lookup is scoped to the current user's active keys.
- resource :api_key -> resources :api_keys.
- Add an API keys list view with per-key revoke and an empty state;
  rewrite the show page for a single key.
- Update i18n (remove single-key copy, pluralise nav label) and tests.

* refactor(api): address review on multiple API keys

- Remove unreachable destroy branches (cannot_revoke is guarded by the
  .visible 404; revoke! raises rather than returning false) and document
  that .visible is the demo-key revocation guard.
- Delete the orphaned created.html.erb / created.turbo_stream.erb
  templates (no action renders them) and their unused locale keys.
- Extract shared partials (_scope_badges, _status_indicator, _key_meta,
  _key_reveal, _usage) to de-duplicate the index and show views; unify
  the active-status indicator on the standard dot.
- Carry forward the @container / @lg:flex-row / min-w-0 responsive
  fixes from #2079 into the shared key-reveal partial.

* test(api): cover newly-created API key confirmation render

* refactor(api): harden demo-key guard and address review nits

- Document the demo-key revocation guard on ApiKey's `visible` scope (the
  authoritative spot) and add a model test locking the invariant that
  `.visible` excludes the demo monitoring key.
- _scope_badges: use an i18n lookup with a humanize fallback instead of
  bypassing translation for unknown scopes.
- _key_reveal: drop the hard-coded `id` from the shared partial; the
  system test now locates the key via its data-clipboard-target.

* refactor(api-keys): migrate hand-rolled badges to DS::Pill

Replace raw span elements in scope badges and status indicator
with DS::Pill to align with the design system migration convention.

* fix(loans): opening anchor now uses current balance, not original principal

When creating a loan manually, the opening anchor valuation was being
set to `initial_balance` (the original loan principal) instead of
`account.balance` (the current outstanding balance). After the sync
job ran, `account.balance` was overwritten to match the anchor,
making every manually-created loan show its original principal as
the current balance.

Fix by always using `account.balance` for the opening anchor in
`create_and_sync`, and reading `Loan#original_balance` from the
`loans.initial_balance` column directly (with a fallback to
`first_valuation_amount` for provider-synced loans that may not
have the column populated).

* fix(api-keys): strip accidental loan changes; rescue revoke! failures

The fix(loans) commit was accidentally committed into this branch.
Remove the loan-related changes from account.rb, loan.rb, and both
test files, restoring them to their pre-loan-commit state.

Also fix the destroy action: revoke! uses update! internally which
raises ActiveRecord::RecordInvalid on failure rather than returning
false. Add rescue for RecordInvalid and RecordNotDestroyed so failures
produce a flash alert instead of a 500. Re-adds the revoke_failed
locale key that was dropped from settings.api_keys.destroy.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-26 06:47:38 +02:00

102 lines
3.5 KiB
Plaintext

<%= 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") %>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render DS::FilledIcon.new(
icon: "check-circle",
rounded: true,
size: "lg",
variant: :success
) %>
<div class="flex-1">
<h3 class="font-medium text-primary"><%= t(".newly_created.heading") %></h3>
<p class="text-secondary text-sm mt-1"><%= t(".newly_created.key_ready", name: @api_key.name) %></p>
</div>
</div>
</div>
<%= 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") %>
<%= 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) %>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render DS::Link.new(
text: t(".newly_created.continue"),
href: settings_api_keys_path,
variant: "primary"
) %>
</div>
</div>
</div>
<% else %>
<%= content_for :page_title, t(".current_api_key.title") %>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
<div class="flex items-center gap-3">
<%= render DS::FilledIcon.new(
icon: "key",
rounded: true,
size: "lg"
) %>
<div class="text-sm space-y-1">
<p class="text-primary font-medium"><%= @api_key.name %></p>
<%= render "settings/api_keys/key_meta", api_key: @api_key %>
</div>
</div>
<%= render "settings/api_keys/status_indicator" %>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3"><%= t(".current_api_key.permissions") %></h4>
<%= render "settings/api_keys/scope_badges", scopes: @api_key.scopes %>
</div>
<%= 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") %>
<%= 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) %>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render DS::Button.new(
text: t(".current_api_key.revoke_key"),
href: settings_api_key_path(@api_key),
method: :delete,
variant: "destructive",
data: {
turbo_confirm: t(".current_api_key.revoke_confirmation")
}
) %>
</div>
</div>
</div>
<% end %>