Feature: API keys screen As a verified RenewCMS owner or admin I want /integration/api-keys to list my API keys, create new ones and revoke them So that a script or service can call the API with my rights, and I can cut it off at any time Background: Given /integration/api-keys is a verified route (meta.auth "verified") rendered as ApiKeysPage with CreateApiKeyModal, ApiKeyReveal and a revoke confirmation dialog, inside DashboardShell, per docs/design/routes.md And the screen is a sub-page of Integration, per docs/design/screens/integration.md And the screen calls, with relative URLs through useApi only, GET /v1/api-key, POST /v1/api-key and DELETE /v1/api-key/{id} And a GET /v1/api-key entry has the fields id, user_id, name, prefix (12 characters), status ("active", "expired" or "revoked"), created_at, last_used_at, expires_at and revoked_at, and never a key or a hash, per features/api_keys.feature And an owner's entries also carry created_by_name, the creator's display name or username (see the API scenarios below) And the list arrives newest first; an owner receives every user's keys, an admin only their own And the PageHeader shows breadcrumb "Integration › API keys" (Integration links to /integration), title "API keys" and a primary "Create key" button on the right at desktop And the description reads "Keys act as the user who created them. As an owner you see everyone's keys." for an owner, and "Keys act as you, with your role. You only see your own keys." for an admin; a session without a role gets the admin rendering And the table, on desktop, has the columns Name, Key, Status, Created by (owner only), Created, Last used, Expires and an action column, in a UTable with a 36 px header and 48 px rows And below md, each key is a card: name and status badge, prefix in monospace, then "Created {date}[ by {name}] · Last used {date} · Expires {date}", then a 44 px "Revoke" button And the Key column and the card show the prefix cut to its first 9 characters followed by "…", for example "rcms_Kf3x…", and the full key is never shown in the list And the revoke text uses the same 9-character prefix plus "…" And dates are absolute and formatted for the current locale, for example "12 Mar 2026" in English; a null expires_at reads "None" and a null last_used_at reads "Never" And a Created by cell with an empty created_by_name shows "—" And the Status badge is a UBadge: active success soft, expired warning soft, revoked neutral with a muted row And "Revoke" is offered on active and expired keys, never on revoked keys And a note with an info icon always closes the page: "Resetting a user's password revokes all of their keys." And the Integration page (/integration, features/integration.feature) gains a "Manage API keys" link to /integration/api-keys (see the Integration link scenario) And on /integration/api-keys the sidebar and drawer item "Integration" carries aria-current="page" And every visible string goes through t(...), with the English text as the key and its French entry in web/src/i18n/messages.ts And the screen uses Nuxt UI components and design tokens only, with no raw colors And the owner fixture is, newest first as the board draws it: | name | prefix | status | created by | created | last used | expires | | Website build | rcms_Kf3x… | active | Marta Alves | 12 Mar 2026 | 3 Oct 2026 | None | | Nightly media import | rcms_Mq7b… | active | Jonas | 2 Apr 2026 | 3 Oct 2026 | 31 Dec 2026 | | Staging preview | rcms_Zt2d… | expired | Marta Alves | 14 Jun 2026 | 20 Aug 2026 | 30 Aug 2026 | | Old migration script | rcms_Hn5w… | revoked | Inès Duarte | 3 Apr 2026 | 9 May 2026 | None | And the admin fixture (user "Jonas") is: | name | prefix | status | created | last used | expires | | Nightly media import | rcms_Mq7b… | active | 2 Apr 2026 | 3 Oct 2026 | 31 Dec 2026 | | Old CI token | rcms_Lp9c… | revoked | 18 Jan 2026 | 2 Feb 2026 | None | Scenario: Owner: list Given the session role is "owner" And GET /v1/api-key answers 200 with the owner fixture When the user navigates to /integration/api-keys Then the four rows show in fixture order with the columns Name, Key, Status, Created by, Created, Last used, Expires And "Staging preview" shows an "expired" badge and "Revoke" And "Old migration script" shows a "revoked" badge, a muted row and no "Revoke" And the description is the owner description Scenario: Admin: own keys only Given the session role is "admin" And GET /v1/api-key answers 200 with the admin fixture When the user navigates to /integration/api-keys Then the two rows show with no "Created by" column And on mobile the cards read "Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026" without " by {name}" And the description is the admin description Scenario: Loading Given GET /v1/api-key is pending When the user navigates to /integration/api-keys Then four skeleton rows are drawn in the table (two-bar cards below md) and the table has aria-busy "true" And the header "Create key" button is shown Scenario: Empty Given GET /v1/api-key answers 200 with [] When the user navigates to /integration/api-keys Then a panel shows title "No API keys yet", text "Create a key to call the API from a script or service." and a primary "Create key" button And no table is drawn Scenario Outline: Error on load Given GET /v1/api-key fails with When the user navigates to /integration/api-keys Then an alert (role "alert") shows "Couldn't load your API keys" and "Check your connection and try again." with a "Try again" action And no table, no cards and no empty panel are drawn Examples: | failure | | status 500 | | a network error | Scenario: Try again after an error Given the load failed and the error alert is shown When the user activates "Try again" Then GET /v1/api-key is requested again and, on 200, the list replaces the alert Scenario Outline: Session problems use the app-wide handling Given GET /v1/api-key answers When the user navigates to /integration/api-keys Then Examples: | status | outcome | | 401 | the user lands on /login?redirect=/integration/api-keys&reason=expired | | 403 | the user lands on /register/pending | Scenario: Create key: modal opened Given the list is shown When the user activates "Create key" Then a modal titled "Create API key" opens with the text "The key acts as you, with your role. It is shown in full once." And it has a Name field with a "0 / 60" counter and help "Required. Up to 60 characters." And it has an "Expiry date (optional)" date input with help "Optional. Must be in the future. Leave it empty for a key that does not expire." And it has the buttons "Cancel" and "Create key" And Name has focus Scenario Outline: Create key: validation Given the Create API key modal is open When the user submits with name "" and expiry "" Then no request is sent And And Examples: | name | expiry | name_error | expiry_error | | | 12 Mar 2026 | Name shows the error "Enter a name." in red | Expiry shows the error "Choose a date in the future." in red | | | | Name shows the error "Enter a name." in red | Expiry shows no error | | Deploy | 12 Mar 2026 | Name shows no error | Expiry shows the error "Choose a date in the future." in red | Scenario: Create key: name too long Given the Create API key modal is open When the user types a name of 75 characters Then the counter reads "75 / 60" in the error color And Name shows the error "Use 60 characters or fewer." in red And submitting sends no request Scenario: Create key: success reveals the key once Given the Create API key modal is open When the user submits name "Nightly media import" and an empty expiry Then exactly one POST /v1/api-key is sent with body {"name": "Nightly media import"} and no expires_at And on 201 the form modal is replaced by a modal titled "Your new API key" with the text "\"Nightly media import\" is ready. Copy the key and store it somewhere safe." And a warning alert (UAlert, color warning, triangle-alert icon) reads "You won't be able to see this key again." And the full key from the response is shown in a CodeSnippet with the single tab "API key" and a "Copy" button (icon copy, accessible name "Copy code"), focused when the modal opens And the note "Closing this window discards the key from the screen. Copy it first." is shown And the only button is a primary "Done" Scenario: Create key: with an expiry Given the Create API key modal is open And the local date is D When the user submits name "temp" and the expiry date D + 30 days Then the POST body carries name "temp" and expires_at equal to 23:59:59 local time on D + 30 days, as an RFC3339 instant with the local offset Scenario Outline: The expiry date is checked against the same instant the API checks Given the Create API key modal is open and the current time is T When the user submits a name and the expiry date Then Examples: | date | outcome | | today | the request is sent, because 23:59:59 today is after T | | yesterday | no request is sent and Expiry shows "Choose a date in the future." | | tomorrow | the request is sent | Scenario: One-time reveal: copied Given the key reveal is open When the user activates "Copy" Then the full key is written to the clipboard And the button shows icon check and text "Copied" in the success color, announced politely When 2 seconds elapse Then the button reverts to icon copy and text "Copy" Scenario: One-time reveal: Done discards the key Given the key reveal is open When the user activates "Done" Then the modal closes And the full key is no longer in the DOM, the component state, the URL or any storage And the new key is listed first with its 9-character prefix and "…" only, status "active", created by the current user (owner view), last used "Never", expires per the request, and no second GET /v1/api-key is made And reopening "Create key" shows an empty form Scenario Outline: Dismissing the modals Given is open When the user Then Examples: | modal | action | outcome | | the key reveal | presses Escape | the modal closes and the key is discarded, as for Done | | the key reveal | clicks the backdrop | the modal stays open | | the Create key form | presses Escape | the modal closes with no request | | the Create key form | clicks the backdrop | the modal stays open | Scenario: Create key: Cancel discards the form Given the Create API key modal is open with typed values When the user activates "Cancel" Then the modal closes without a request, focus returns to the opening button, and reopening it shows an empty form Scenario: Revoke: confirm Given the owner list is shown When the user activates "Revoke" on "Nightly media import" Then a dialog titled "Revoke this key?" opens with the text "\"Nightly media import\" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." And its buttons are "Keep key" and a destructive "Revoke key" And "Keep key" closes the dialog with no request and the row unchanged Scenario: Revoked Given the revoke dialog is open for "Nightly media import" (id 2) When the user activates "Revoke key" Then exactly one DELETE /v1/api-key/2 is sent And on 204 the dialog closes and the row stays listed with status "revoked", a muted row and no "Revoke" button And a success toast shows title "Key revoked" and text "\"Nightly media import\" can no longer be used." And no second GET /v1/api-key is made Scenario: Mobile Create key button, not drawn on board Given the viewport is below md When the list, the loading skeleton or the error alert is shown Then a primary full-width 48 px "Create key" button is pinned to the bottom of the screen And it is hidden while any modal or dialog is open, and in the Empty state where the panel has its own "Create key" button And the header has no "Create key" button below md Scenario: Integration page links to the keys screen Given the user is on /integration Then a "Manage API keys" link (UButton variant link, arrow-right trailing icon) is shown below the three info notes When the user activates it Then the user lands on /integration/api-keys and the sidebar "Integration" item keeps aria-current="page" Scenario: Language: every string has a French entry Given the language is "fr" When the user opens each state of the screen Then every visible string is the French message and none falls back to English And dates are formatted for "fr" Scenario: Create key: sending, not drawn on board, no visual state Given the Create API key modal is open with a valid name When the user submits and POST /v1/api-key is pending Then the primary button reads "Creating…", Name, Expiry and "Cancel" are disabled And Escape does not close the modal Scenario Outline: Create key: failed, not drawn on board, no visual state Given the Create API key modal is open with name "Nightly media import" When POST /v1/api-key fails with Then an alert in the modal reads "" And the typed values are kept, the form is enabled again, no row is added, and no field error is shown Examples: | failure | copy | | status 500 | Couldn't create the key. Try again in a moment. | | a network error | Couldn't reach RenewCMS. Check your connection and try again. | | status 429 | Too many requests. Wait a moment, then try again. | | status 400 | Something in the form isn't valid. Check the highlighted fields. | Scenario: Create key: retry after failure, not drawn on board, no visual state Given a failed create left the modal open When the user submits again and POST answers 201 Then the reveal opens and the row is added Scenario: Revoke: sending, not drawn on board, no visual state Given the revoke dialog is open When the user activates "Revoke key" and DELETE is pending Then "Revoke key" reads "Revoking…", both buttons are disabled, and Escape does not close the dialog Scenario Outline: Revoke: failed, not drawn on board, no visual state Given the revoke dialog is open for "Nightly media import" When DELETE fails with Then a notice in the dialog reads "" And the dialog stays open, the buttons are enabled again, and the row is unchanged Examples: | failure | copy | | status 500 | Couldn't revoke the key. Try again in a moment. | | a network error | Couldn't reach RenewCMS. Check your connection and try again. | Scenario: Revoke: key already gone, 404, not drawn on board, no visual state Given the revoke dialog is open for "Nightly media import" (id 2) When DELETE /v1/api-key/2 answers 404 Then the dialog closes, the row is removed from the list, and an error toast reads "That key no longer exists." Scenario: Last used, never, not drawn on board, no visual state Given an entry whose last_used_at is null Then its Last used cell and its mobile card text read "Never" Scenario: API: an owner's list carries the creator's name Given owner 1 "marta" with display name "Marta Alves", admin 2 "dev" with display name "Dev Two" and admin 3 "pat" with an empty display name And keys "a" by user 1, "b" by user 2 and "c" by user 3 When owner 1 sends GET /v1/api-key with a jwt cookie Then each entry has the key created_by_name And entry "a" has "Marta Alves", "b" has "Dev Two" and "c" has "pat" And the other nine fields are unchanged from features/api_keys.feature Scenario: API: a whitespace-only display name falls back to the username Given admin 3 "pat" with display name " " and a key "c" When owner 1 sends GET /v1/api-key Then entry "c" has created_by_name "pat" Scenario: API: an admin's list is unchanged Given admin 2 with keys "a" and "b" When admin 2 sends GET /v1/api-key with a jwt cookie Then every entry has exactly the keys id, user_id, name, prefix, status, created_at, last_used_at, expires_at, revoked_at And no entry has created_by_name Scenario: API: a key whose creator no longer exists Given a key whose user record was deleted When owner 1 sends GET /v1/api-key Then its entry has created_by_name "" And the response status is 200 and the other entries are unaffected Scenario: API: a failing user lookup fails the list Given the user repository fake returns an unexpected error on Get for a creator When owner 1 sends GET /v1/api-key Then the response status is 500 and the body is "Internal server error" Scenario: API: the creator name is resolved once per user Given owner 1 has 3 keys and admin 2 has 2 keys When owner 1 sends GET /v1/api-key Then the user repository received one Get per distinct creator, not one per key Scenario: Use case: ListAPIKeys returns the creator name only to an owner Given a fake apikey.Repository and a fake user.Repository When ListAPIKeys is called for an owner Then every APIKeyEntry carries the creator's name When ListAPIKeys is called for an admin Then every APIKeyEntry has an empty creator name and the handler omits the field