352 lines
19 KiB
Gherkin
352 lines
19 KiB
Gherkin
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 <failure>
|
||
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 <status>
|
||
When the user navigates to /integration/api-keys
|
||
Then <outcome>
|
||
|
||
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 "<name>" and expiry "<expiry>"
|
||
Then no request is sent
|
||
And <name_error>
|
||
And <expiry_error>
|
||
|
||
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 <date>
|
||
Then <outcome>
|
||
|
||
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 <modal> is open
|
||
When the user <action>
|
||
Then <outcome>
|
||
|
||
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 <failure>
|
||
Then an alert in the modal reads "<copy>"
|
||
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 <failure>
|
||
Then a notice in the dialog reads "<copy>"
|
||
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
|