feat(SPA): screen to manage API keys (PART 2/3)

This commit is contained in:
fsylvain committed 2026-10-05 00:05:12 +02:00
1 parent 844842fdae
commit 514a082c96
30 files changed
+3844 -83

No files matched your search

+16 -11
View File
@@ -37,15 +37,16 @@ type createdKeyResponse struct {
} }
type keyListItem struct { type keyListItem struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
UserID uint32 `json:"user_id"` UserID uint32 `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
Status domain.Status `json:"status"` Status domain.Status `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"` LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *time.Time `json:"expires_at"` ExpiresAt *time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"` RevokedAt *time.Time `json:"revoked_at"`
CreatedByName *string `json:"created_by_name,omitempty"`
} }
func NewHandler(create *useCases.CreateAPIKeyUseCase, list *useCases.ListAPIKeysUseCase, revoke *useCases.RevokeAPIKeyUseCase) *Handler { func NewHandler(create *useCases.CreateAPIKeyUseCase, list *useCases.ListAPIKeysUseCase, revoke *useCases.RevokeAPIKeyUseCase) *Handler {
@@ -121,7 +122,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
items := make([]keyListItem, 0, len(entries)) items := make([]keyListItem, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {
key := entry.Key key := entry.Key
items = append(items, keyListItem{ item := keyListItem{
ID: key.ID, ID: key.ID,
UserID: key.UserID, UserID: key.UserID,
Name: key.Name, Name: key.Name,
@@ -131,7 +132,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
LastUsedAt: key.LastUsedAt, LastUsedAt: key.LastUsedAt,
ExpiresAt: key.ExpiresAt, ExpiresAt: key.ExpiresAt,
RevokedAt: key.RevokedAt, RevokedAt: key.RevokedAt,
}) }
if entry.ListsCreator {
item.CreatedByName = &entry.CreatorName
}
items = append(items, item)
} }
body, err := json.Marshal(items) body, err := json.Marshal(items)
+1 -1
View File
@@ -32,7 +32,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` | | [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` | | [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` | | [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
| [API keys](screens/integration.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` | | [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` | | [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` | | [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` | | [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
@@ -0,0 +1,58 @@
# Integration: API keys
Route: `/integration/api-keys`. Component: `ApiKeysPage.dc.html`, mounted by `AppFrame`.
- **Visual reference:** `source/RenewCMS Screens API keys.dc.html`, group "API keys", drawn by `source/ApiKeysPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
## API
`POST /v1/api-key` · `GET /v1/api-key` · `DELETE /v1/api-key/{id}`. Access: Session only. An API key cannot manage keys; with a key these routes answer 403 "This action needs a real session."
- `POST /v1/api-key` body: `name` (string, required, max 60), `expires_at` (RFC3339 string, optional, in the future). A past date answers 400 "The expiry must be a date in the future."
- `DELETE /v1/api-key/{id}`: unknown id answers 404 "API key not found".
## Placement
Under Integration, as a sub-page linked from the Authentication section ("Manage API keys") and the sidebar item. Keys exist to call the API and admins can create them; Settings is owner-only today. The mobile top bar has a back arrow that returns to Integration.
## Content
- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own.
- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future."
- The Expiry field is a native date input. The displayed format follows the browser's locale, so there is no custom format. The board draws one example date only.
- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done".
- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked".
- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again."
- Note: "Resetting a user's password revokes all of their keys."
## Mobile: pinned Create key
On mobile the "Create key" button is pinned to the bottom of the viewport (44 px high, full width, on a bar with a top border). It shows in Owner list, Admin own keys only, Loading, Empty, Error and Revoked, and is hidden behind every modal state. In Empty it replaces the inline button. The content has bottom padding so nothing sits under the bar, and the toast sits above it. Desktop keeps "Create key" in the page header (and in the Empty card).
## States
Names are matched exactly by the visual-check script.
### Owner: list
### Admin: own keys only
### Loading
the list request is in flight. Skeleton rows appear only after 150 ms.
### Empty
### Error
a 500 or network failure on the list request. If a key is created while this state is showing, the list reloads, the new key appears and the error alert is cleared.
### Create key
### Create key: validation
### Create key: name too long
### One-time reveal
### One-time reveal: copied
### Revoke: confirm
### Revoked
## Component inventory (Nuxt UI v4)
`UTable`, `UBadge`, `UModal` with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, `useClipboard`.
+49 -17
View File
@@ -1,6 +1,8 @@
# Integration (API reference) and API keys # Integration (API reference)
Routes: `/integration` and `/integration/api-keys`. Boards: `RenewCMS Screens Back-office.dc.html` (group "Integration") and `RenewCMS Screens API keys.dc.html`. Components: `IntegrationPage.dc.html`, `ApiKeysPage.dc.html`, mounted by `AppFrame`. Route: `/integration`. Component: `IntegrationPage.dc.html`, mounted by `AppFrame`. The API keys sub-page is in `screens/integration-api-keys.md`.
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration", drawn by `source/IntegrationPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
## Navigation decision ## Navigation decision
Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position. Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position.
@@ -13,9 +15,9 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeBlock tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys". - Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeBlock tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified." - Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header." - Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."" - Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
- Errors: table 400, 401, 403, 404, 409, 422, 429, 500 plus the JSON body. - Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=html` or `?format=markdown` to choose how `body` is returned." - Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
## EndpointCard states ## EndpointCard states
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips). - Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
@@ -24,27 +26,57 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted). - Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only. - Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
## Board states (desktop 1440×900, mobile 390×844, light and dark) ## States
1 Default · 2 Media group, POST /v1/media/ expanded with curl tab · 3a Base URL copied · 3b CodeBlock copied · 4 Authentication · 5 Session only · 6 Errors · 7 Navigation (index active item Media; mobile select open). Desktop 1440×900, mobile 390×844, light and dark. Names are matched exactly by the visual-check script.
## API keys screen ### 1. Default
Placement: under Integration (sub-page), because keys exist to call the API and admins can create them; Settings is owner-only today. Session only.
- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own. ### 2. Group scrolled, one card expanded
- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future."
- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done". ### 3a. Copied: base URL
- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked".
- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again." ### 3b. Copied: CodeBlock
- Note: "Resetting a user's password revokes all of their keys."
### 4. Authentication
### 5. Session only
### 6. Errors
### 7. Navigation
## Error messages
Plain text, one sentence. Used in the table and in the per-endpoint error captions.
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future."
- 401: "You must be logged in to access this resource."
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource."
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found"
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
- 500: "Internal server error"
- 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table.
- 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known.
## Request bodies
- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, required, max 10000).
- `PATCH /v1/article/{id}`: `body` (string, required, max 10000).
- `PATCH /v1/media/{id}` (rename): `filename` (string, required).
- `POST /v1/media/merge`: `keep_id` (number, required, > 0), `merge_ids` (number[], required, at least 1, each > 0).
- `POST /v1/media/`: multipart/form-data, 1 file field (shown as "multipart/form-data, 1 file field").
- `PATCH /v1/user/me`: `display_name` (string, required, max 60 characters).
- `POST /v1/user/invite`: `email` (string, required, valid email).
- `POST /v1/system/update/restart`: optional body `{ "kind": "update" | "restart" }`.
## Component inventory and Nuxt UI v4 ## Component inventory and Nuxt UI v4
- EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), `UTabs` (fetch, curl, Response), CodeBlock. - EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), `UTabs` (fetch, curl, Response), CodeBlock.
- SectionIndex (new): custom `<nav>` of anchor buttons with `aria-current`; sticky, scroll-spy. Mobile: `USelectMenu` labelled "Jump to". - SectionIndex (new): custom `<nav>` of anchor buttons with `aria-current`; sticky, scroll-spy. Mobile: `USelectMenu` labelled "Jump to".
- SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour. - SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour.
- ErrorTable: `UTable`. Info notes: existing note style (or `UAlert` variant `naked`). - ErrorTable: `UTable`. Info notes: existing note style (or `UAlert` variant `naked`).
- API keys: `UTable`, `UBadge`, `UModal` with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, `useClipboard`.
## Assumptions to confirm ## Assumptions to confirm
Paths not in the brief: `/v1/settings`, `/v1/auth/api-key`, `/v1/system/update/*`, `POST /v1/auth/register|verify|logout`; body fields `content`, `keepId`, `mergeIds`, `displayName`, `name`; error body shape `{ "statusCode", "message" }`. Code samples use a simple keyword and string colouring. - Paths not in the brief: `/v1/settings`, `/v1/system/update/*`, `POST /v1/auth/register|verify|logout`.
- [to confirm] The multipart field name on `POST /v1/media/` is written as `file` as a placeholder.
- Response field names in the samples (for example `articlesUpdated`, `display_name` in the profile response) are illustrative.
- Code samples use a simple keyword and string colouring.
## Tokens ## Tokens
No new tokens. Foundations check: colours, radius (0) and spacing come from existing `--ui-*` and `--rc-*` values; every control has a 2 px primary focus ring; index, select and cards are keyboard operable. Open question: the board hard-codes a few pixel sizes, as the other screens do. No new tokens. Foundations check: colours, radius (0) and spacing come from existing `--ui-*` and `--rc-*` values; every control has a 2 px primary focus ring; index, select and cards are keyboard operable. Open question: the board hard-codes a few pixel sizes, as the other screens do.
+5 -4
View File
@@ -7,13 +7,13 @@
</head> </head>
<body> <body>
<x-dc> <x-dc>
<div style="flex:1;min-height:0;overflow:hidden;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;padding:{{ pad }};display:flex;flex-direction:column;gap:16px"> <div style="flex:1;min-height:0;overflow:hidden;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;padding:{{ pad }};position:relative;display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap"> <div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">
<div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:4px"><span style="color:var(--ui-text-muted)">Integration › API keys</span><span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API keys</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ sub }}</span></div> <div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:4px"><span style="color:var(--ui-text-muted)">Integration › API keys</span><span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API keys</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ sub }}</span></div>
<sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if> <sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if>
</div> </div>
<sc-if value="{{ err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="flex:1"><strong>Couldn't load your API keys</strong><br>Check your connection and try again.</span><span style="font-weight:500;color:var(--ui-text-highlighted)">Try again</span></div></sc-if> <sc-if value="{{ err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="flex:1"><strong>Couldn't load your API keys</strong><br>Check your connection and try again.</span><span style="font-weight:500;color:var(--ui-text-highlighted)">Try again</span></div></sc-if>
<sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></div></sc-if> <sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if></div></sc-if>
<sc-if value="{{ showList }}"> <sc-if value="{{ showList }}">
<sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"> <sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Created</span><span>Last used</span><span>Expires</span><span></span></div> <div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
@@ -35,6 +35,7 @@
<div style="display:flex;flex-direction:column;gap:8px;font-size:14px;line-height:22px;color:var(--ui-text-toned)"> <div style="display:flex;flex-direction:column;gap:8px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Resetting a user's password revokes all of their keys.</span></div> <div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Resetting a user's password revokes all of their keys.</span></div>
</div> </div>
<sc-if value="{{ pinned }}"><div style="position:absolute;left:0;right:0;bottom:0;z-index:40;padding:12px 16px;background:var(--ui-bg);border-top:1px solid var(--ui-border)"><span style="height:44px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></div></sc-if>
<sc-if value="{{ toast }}"><div role="status" style="position:absolute;{{ toastPos }};z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);color:var(--ui-success)"><span style="display:flex;flex-direction:column"><strong>Key revoked</strong><span style="color:var(--ui-text-toned)">"Nightly media import" can no longer be used.</span></span></div></sc-if> <sc-if value="{{ toast }}"><div role="status" style="position:absolute;{{ toastPos }};z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);color:var(--ui-success)"><span style="display:flex;flex-direction:column"><strong>Key revoked</strong><span style="color:var(--ui-text-toned)">"Nightly media import" can no longer be used.</span></span></div></sc-if>
</div> </div>
<sc-if value="{{ modal }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:{{ mAlign }};justify-content:center;padding:{{ mPad }}"> <sc-if value="{{ modal }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:{{ mAlign }};justify-content:center;padding:{{ mPad }}">
@@ -79,11 +80,11 @@ class Component extends DCLogic {
} }
const m = k.modal ? M[k.modal] : null const m = k.modal ? M[k.modal] : null
return { return {
mob, desk: !mob, owner, rows, pad: mob ? '20px 16px' : '32px', h1: mob ? '20px/28px' : '28px/36px', mob, desk: !mob, owner, rows, pad: mob ? '20px 16px 84px' : '32px', pinned: mob && !m, h1: mob ? '20px/28px' : '28px/36px',
sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.', sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.',
err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading', err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading',
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 100px 100px 100px 80px', cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 100px 100px 100px 80px',
toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:320px', toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:84px' : 'right:24px;bottom:24px;width:320px',
modal: !!m, m: m || {}, mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '16px' : '120px 16px 16px', mW: mob ? '100%' : '480px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row' modal: !!m, m: m || {}, mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '16px' : '120px 16px 16px', mW: mob ? '100%' : '480px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row'
} }
} }
+29 -28
View File
@@ -108,12 +108,12 @@
<section data-anchor="errors" aria-labelledby="h-err" style="display:flex;flex-direction:column;gap:10px"> <section data-anchor="errors" aria-labelledby="h-err" style="display:flex;flex-direction:column;gap:10px">
<h2 id="h-err" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Errors</h2> <h2 id="h-err" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Errors</h2>
<span style="color:var(--ui-text-toned);text-wrap:pretty">Errors use the HTTP status and a JSON body.</span> <span style="color:var(--ui-text-toned);text-wrap:pretty">Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.</span>
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden"> <div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div> <div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span></div></sc-for> <sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span></div></sc-for>
</div> </div>
<div style="border:1px solid var(--ui-border);min-width:0"><div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 12px;display:flex;align-items:center;border-bottom:1px solid var(--ui-border);font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">Error body · JSON</div> <div style="border:1px solid var(--ui-border);min-width:0"><div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 12px;display:flex;align-items:center;border-bottom:1px solid var(--ui-border);font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">Error response · text/plain</div>
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ errBody }}</pre></div></div> <pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ errBody }}</pre></div></div>
</section> </section>
@@ -122,7 +122,7 @@
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)"> <div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Images in <code>body</code> use paths like <code>/static/uploadedImages/&lt;uuid&gt;.png</code>. Prefix them with the base URL before rendering.</span></div> <div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Images in <code>body</code> use paths like <code>/static/uploadedImages/&lt;uuid&gt;.png</code>. Prefix them with the base URL before rendering.</span></div>
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.</span></div> <div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.</span></div>
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Article routes accept <code>?format=html</code> or <code>?format=markdown</code> to choose how <code>body</code> is returned.</span></div> <div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Article routes accept <code>?format=markdown</code>, <code>?format=html</code> or <code>?format=json</code> to choose how <code>body</code> is returned. With <code>json</code>, <code>body</code> is the stored JSON structure. Articles have <code>body_format: markdown</code> by default.</span></div>
</div> </div>
</section> </section>
@@ -176,36 +176,39 @@ class Component extends DCLogic {
out.push(code.slice(last)); return out out.push(code.slice(last)); return out
} }
eps() { eps() {
const A = (id, m, p, d, acc, key, errs, fields, x = {}) => ({ id, m, p, d, acc, key, errs, fields, ...x }) const A = (id, m, p, d, acc, key, errs, fields, x = {}) => ({ id, m, p, d, acc, key, errs: acc === 'Verified user' ? errs.flatMap(e => e[0] === 401 ? [e, e403] : [e]) : errs, fields, ...x })
const S = (n, t, req, rule) => ({ n, t, req, rule }) const S = (n, t, req, rule) => ({ n, t, req, rule })
const e401 = [401, 'The key is invalid, unknown, expired or revoked, or its user was deleted or unverified.'] const e401 = [401, 'You must be logged in to access this resource.']
const e404 = [404, 'No article with this id.'] const e403 = [403, 'You must verify your account to access this resource.']
const m404 = [404, 'No image with this id.'] const e404 = [404, 'Article not found']
const m404 = [404, 'Image not found']
const i404 = [404, 'Invite not found']
const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
const j = o => JSON.stringify(o, null, 2) const j = o => JSON.stringify(o, null, 2)
const G = { const G = {
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [ articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
A('art-list', 'GET', '/v1/article/', 'List articles. Anonymous callers get online articles only.', 'Public', 1, [e401], [], { pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }), A('art-list', 'GET', '/v1/article/', 'List articles. Anonymous callers get online articles only.', 'Public', 1, [e401], [], { pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: '<p>…</p>' }) }), A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, 'Title shorter than 3 or longer than 200 characters.'], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('content', 'string', 'Yes', 'HTML, up to 10 000 characters.')], { body: { title: 'Choosing tiles for a small bathroom', content: '<p>Small rooms show every grout line.</p>' }, r: j({ id: 13, status: 'draft' }) }), A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'Yes', 'Up to 10 000 characters.')], { body: { title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line.' }, r: j({ id: 13, status: 'draft' }) }),
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, 'Content over 10 000 characters.'], e401, e404], [S('content', 'string', 'Yes', 'HTML, up to 10 000 characters.')], { body: { content: '<p>Updated text.</p>' }, r: j({ id: 12, status: 'draft' }) }), A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Up to 10 000 characters.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }),
A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }), A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }),
A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }), A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }),
A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' }) A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' })
] }, ] },
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [ media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }), A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, 'The server couldn\'t read the image.'], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.'), S('name', 'string', 'No', 'Filename without extension. Defaults to the file\'s own name.')], { multi: true, form: [['file', '@north-wall.jpg'], ['name', 'north-wall']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }), A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, 'The name is empty or only symbols.'], e401, m404], [S('name', 'string', 'Yes', 'Filename without extension.')], { body: { name: 'north-wall-01' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }), A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, mm], e401, m404], [S('filename', 'string', 'Yes', 'The new filename.')], { body: { filename: 'north-wall-01.jpg' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }),
A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }), A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }),
A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [e401, [409, 'A copy was deleted or added since you loaded the list.']], [S('keepId', 'number', 'Yes', 'The image to keep.'), S('mergeIds', 'number[]', 'Yes', 'Identical copies to delete. Articles switch to the kept image.')], { body: { keepId: 4, mergeIds: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }), A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }),
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }) A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) })
] }, ] },
users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [ users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }), A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
A('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', 1, [[400, 'Display name over 60 characters.'], e401], [S('displayName', 'string', 'No', 'Up to 60 characters. Empty goes back to the username.')], { body: { displayName: 'Marta Alves' }, r: j({ displayName: 'Marta Alves' }) }), A('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', 1, [[400, mm], e401], [S('display_name', 'string', 'Yes', 'Up to 60 characters.')], { body: { display_name: 'Marta Alves' }, r: j({ display_name: 'Marta Alves' }) }),
A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, 'Not a full email address.'], e401, [409, 'The address already has an account or a pending invite.']], [S('email', 'string', 'Yes', 'A full email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }), A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, mm], e401, [409, 'This address already has an account.']], [S('email', 'string', 'Yes', 'A valid email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }),
A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, [404, 'No pending invite with this id.']], [], { r: '204 No Content' }), A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, i404], [], { r: '204 No Content' }),
A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, [404, 'No invite with this id.']], [], { r: j({ id: 7, status: 'pending' }) }), A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, i404], [], { r: j({ id: 7, status: 'pending' }) }),
A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) }) A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) })
] }, ] },
settings: { title: 'Settings', desc: 'Read the site settings.', eps: [ settings: { title: 'Settings', desc: 'Read the site settings.', eps: [
@@ -237,7 +240,7 @@ class Component extends DCLogic {
const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })] const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips, return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips,
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })), toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
bodyTitle: e.multi ? 'Request body · multipart/form-data' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields, bodyTitle: e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }), tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
lang, code: this.hl(code), copyLabel: 'Copy ' + lang + ' example', cT: cp ? 'Copied' : 'Copy', cIcon: cp ? 'check' : 'copy', cFg: cp ? 'var(--ui-success)' : 'var(--ui-text-toned)', lang, code: this.hl(code), copyLabel: 'Copy ' + lang + ' example', cT: cp ? 'Copied' : 'Copy', cIcon: cp ? 'check' : 'copy', cFg: cp ? 'var(--ui-success)' : 'var(--ui-text-toned)',
copy: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash(e.id) }, copy: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash(e.id) },
@@ -267,20 +270,18 @@ class Component extends DCLogic {
groups: this.eps(), groups: this.eps(),
rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap', rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap',
fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto', fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto',
sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/auth/api-key', 'List keys'], ['POST', '/v1/auth/api-key', 'Create a key'], ['DELETE', '/v1/auth/api-key/{id}', 'Revoke a key']])], sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']])],
sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center', sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center',
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto',
errRows: [ errRows: [
{ c: 400, m: 'Bad request', w: 'A field breaks its rule, such as a title under 3 characters.' }, { c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future."' },
{ c: 401, m: 'Unauthorized', w: 'An invalid, unknown, expired or revoked key, even on public routes. Also a key whose user was deleted or unverified.' }, { c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
{ c: 403, m: 'Forbidden', w: 'The key\'s role is too low, or the route is session only: "This action needs a real session."' }, { c: 403, m: 'Forbidden', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." or "You must verify your account to access this resource."' },
{ c: 404, m: 'Not found', w: 'The id doesn\'t exist. Drafts answer 404 to anonymous callers.' }, { c: 404, m: 'Not found', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' },
{ c: 409, m: 'Conflict', w: 'The data changed since you read it, or the address already has an account.' }, { c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' },
{ c: 422, m: 'Unprocessable', w: 'The request is well formed but can\'t be applied, such as an update file that fails its check.' }, { c: 500, m: 'Server error', w: '"Internal server error"' }
{ c: 429, m: 'Too many requests', w: 'More than 120 requests per minute for one key. Wait for the number of seconds in the Retry-After header.' },
{ c: 500, m: 'Server error', w: 'Something failed on the server. Nothing was changed.' }
], ],
errBody: this.hl('{\n "statusCode": 403,\n "message": "This action needs a real session."\n}') errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.'
} }
} }
} }
@@ -76,7 +76,7 @@ class Component extends DCLogic {
groups() { groups() {
const withM = (st, mo = {}) => ({ ...st, m: { ...st, ...mo } }) const withM = (st, mo = {}) => ({ ...st, m: { ...st, ...mo } })
return this.finish([ return this.finish([
{ label: 'API keys', title: 'API keys', route: '/integration/api-keys', api: 'GET /v1/auth/api-key · POST /v1/auth/api-key · DELETE /v1/auth/api-key/{id} (session only)', note: 'Recommendation: place it under Integration, as a sub-page linked from the Authentication section and from the sidebar item. Keys exist to call the API, so they belong next to the reference. Settings is owner-only today, but admins can also create keys. The screen is session only: an API key cannot manage keys. Key name: required, up to 60 characters. Expiry: optional, in the future. A key is shown in full once; afterwards only its prefix is visible.', states: [ { label: 'API keys', title: 'API keys', route: '/integration/api-keys', api: 'GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} (session only)', note: 'Recommendation: place it under Integration, as a sub-page linked from the Authentication section and from the sidebar item. Keys exist to call the API, so they belong next to the reference. Settings is owner-only today, but admins can also create keys. The screen is session only: an API key cannot manage keys. Key name: required, up to 60 characters. Expiry: optional, in the future. A key is shown in full once; afterwards only its prefix is visible.', states: [
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Owner: list", trigger: "Owners see every user's keys and a \"Created by\" column. Expired keys keep their Revoke action.", ak: {"view":"owner"} }, { ak: {"view":"owner"} }), withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Owner: list", trigger: "Owners see every user's keys and a \"Created by\" column. Expired keys keep their Revoke action.", ak: {"view":"owner"} }, { ak: {"view":"owner"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Admin: own keys only", trigger: "Admins see only the keys they created, with no \"Created by\" column.", ak: {"view":"admin"} }, { ak: {"view":"admin"} }), withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Admin: own keys only", trigger: "Admins see only the keys they created, with no \"Created by\" column.", ak: {"view":"admin"} }, { ak: {"view":"admin"} }),
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Loading", trigger: "The list request is in flight. Skeleton rows appear after 150 ms.", ak: {"state":"loading"} }, { ak: {"state":"loading"} }), withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Loading", trigger: "The list request is in flight. Skeleton rows appear after 150 ms.", ak: {"state":"loading"} }, { ak: {"state":"loading"} }),
@@ -267,12 +267,12 @@ class Component extends DCLogic {
] }, ] },
{ label: 'Integration', title: 'Integration: API reference', route: '/integration', api: 'none (static). Replaces "Connect your website".', note: 'Navigation: a sticky section index on the right on desktop, and a "Jump to" select on mobile. A long reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page; the index keeps every group on one page, links to anchors and shows where you are. Written for a developer. The base URL is window.location.origin. The API keys screen has its own board.', states: [ { label: 'Integration', title: 'Integration: API reference', route: '/integration', api: 'none (static). Replaces "Connect your website".', note: 'Navigation: a sticky section index on the right on desktop, and a "Jump to" select on mobile. A long reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page; the index keeps every group on one page, links to anchors and shows where you are. Written for a developer. The base URL is window.location.origin. The API keys screen has its own board.', states: [
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "1. Default", trigger: "Opened from the sidebar or the Home card. Every endpoint card is collapsed. Desktop shows the sticky index on the right; mobile shows the \"Jump to\" select above the content.", int: {} }, { int: {} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "1. Default", trigger: "Opened from the sidebar or the Home card. Every endpoint card is collapsed. Desktop shows the sticky index on the right; mobile shows the \"Jump to\" select above the content.", int: {} }, { int: {} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "2. Group scrolled, one card expanded", trigger: "POST /v1/media/ opened in the Media group: multipart body with 2 fields, curl tab shown. Only one example block is visible per card. Other cards stay collapsed.", int: {"scroll":"media","open":"med-up","tab":"curl"} }, { int: {"scroll":"ep-med-up","open":"med-up","tab":"curl"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "2. Group scrolled, one card expanded", trigger: "POST /v1/media/ opened in the Media group: multipart body with 1 file field, curl tab shown. Only one example block is visible per card. Other cards stay collapsed.", int: {"scroll":"media","open":"med-up","tab":"curl"} }, { int: {"scroll":"ep-med-up","open":"med-up","tab":"curl"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3a. Copied: base URL", trigger: "The base URL was copied. The button reads \"Copied\" for 2 s and this is announced.", int: {"copied":"base"} }, { int: {"copied":"base"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3a. Copied: base URL", trigger: "The base URL was copied. The button reads \"Copied\" for 2 s and this is announced.", int: {"copied":"base"} }, { int: {"copied":"base"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the JSON error shape. Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }), withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} }) withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
] }, ] },
{ label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending)', note: 'One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.', states: [ { label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending)', note: 'One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.', states: [
@@ -294,7 +294,7 @@ class Component extends DCLogic {
withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }), withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }),
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' }) withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' })
] }, ] },
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/restart (all pending)', note: 'New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [ { label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }), SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }), SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }),
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }), SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
+13 -13
View File
@@ -140,20 +140,20 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
| Integration | `/integration` | Copied: CodeBlock | Copy clicked in a code block (2 s) | [integration](screens/integration.md) | | Integration | `/integration` | Copied: CodeBlock | Copy clicked in a code block (2 s) | [integration](screens/integration.md) |
| Integration | `/integration` | Authentication | API key card, link and notes | [integration](screens/integration.md) | | Integration | `/integration` | Authentication | API key card, link and notes | [integration](screens/integration.md) |
| Integration | `/integration` | Session only | Routes API keys cannot call | [integration](screens/integration.md) | | Integration | `/integration` | Session only | Routes API keys cannot call | [integration](screens/integration.md) |
| Integration | `/integration` | Errors | Status table and JSON error shape | [integration](screens/integration.md) | | Integration | `/integration` | Errors | Status table and plain-text error shape | [integration](screens/integration.md) |
| Integration | `/integration` | Navigation | Section index active item; mobile Jump to open | [integration](screens/integration.md) | | Integration | `/integration` | Navigation | Section index active item; mobile Jump to open | [integration](screens/integration.md) |
| API keys | `/integration/api-keys` | Owner: list | Owner sees every key, with Created by | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Owner: list | Owner sees every key, with Created by | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Admin: own keys only | Admin sees only their own keys | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Admin: own keys only | Admin sees only their own keys | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Loading | First load or retry | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Loading | First load or retry | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Empty | No keys yet | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Empty | No keys yet | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Error | 500 or network | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Error | 500 or network | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Create key | Modal opened | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Create key | Modal opened | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Create key: validation | Name empty or expiry not in the future | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Create key: validation | Name empty or expiry not in the future | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Create key: name too long | More than 60 characters | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Create key: name too long | More than 60 characters | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | One-time reveal | Key created, shown once | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | One-time reveal | Key created, shown once | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | One-time reveal: copied | Copy clicked (2 s) | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | One-time reveal: copied | Copy clicked (2 s) | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Revoke: confirm | Revoke pressed on a key | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Revoke: confirm | Revoke pressed on a key | [integration-api-keys](screens/integration-api-keys.md) |
| API keys | `/integration/api-keys` | Revoked | Confirmed, key stays listed as revoked | [integration](screens/integration.md) | | API keys | `/integration/api-keys` | Revoked | Confirmed, key stays listed as revoked | [integration-api-keys](screens/integration-api-keys.md) |
| Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) | | Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) |
| Users | `/users` | Loading | First load or retry | [users](screens/users.md) | | Users | `/users` | Loading | First load or retry | [users](screens/users.md) |
| Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) | | Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) |
+352
View File
@@ -0,0 +1,352 @@
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 monospace box with a "Copy" button (icon copy)
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 to assistive
technology
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
+235
View File
@@ -0,0 +1,235 @@
# QA: API keys screen
Two parts: the Go API change (HTTP-level, in `test/`) and the SPA (Vitest and visual).
## What changes in the backend
Only `GET /v1/api-key` called by an owner changes: each entry gains `created_by_name`, the creator's
display name (trimmed, non-empty) or else username, and `""` when the creator no longer exists.
Admin entries keep exactly the nine existing fields and never carry `created_by_name`.
`features/api_keys.feature` is not edited. Its exact-fields scenario is made as an admin and stays
true. No domain or persistence change: `ListAPIKeysUseCase` already holds `user.Repository` and
resolves each distinct creator once through `Get`.
## Go: created_by_name on GET /v1/api-key
Extend `test/api_keys_http_test.go`. Build the handler from `NewHandler(...)` over use cases wired
to the existing in-memory fakes (apikey.Repository, user.Repository, KeyGenerator, fake clock).
Mount it on a fresh `chi.NewRouter()` that mirrors `api/router.go`, wrapped in `jwtauth.Verifier`
and `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`; call
`validator.InitValidator()`; exercise it with `httptest`. No live server, no real SQLite, no real
SMTP. Users: owner 1 "marta" (display name "Marta Alves"), admin 2 "dev" ("Dev Two"), admin 3
"pat" (empty display name), all verified.
1. **Owner sees names** - keys a (user 1), b (user 2), c (user 3); owner cookie GET /v1/api-key;
assert 200, every entry has `created_by_name`, a "Marta Alves", b "Dev Two", c "pat", and the
other nine fields as before (newest first).
2. **Whitespace display name** - user 3 display name " "; assert "pat".
3. **Admin unchanged** - admin 2 cookie; assert every entry has exactly the nine existing fields
(use `apiKeyAssertExactFields`) and no `created_by_name`. Check that the existing exact-fields
test at line 341 is made as an admin; if it is made as an owner, update it and report it.
4. **Deleted creator** - key whose user was removed from the fake; owner GET; assert 200 and
`created_by_name` "" for it.
5. **User lookup fails** - the fake user repository returns an error on Get for a creator; assert
500 and body "Internal server error".
6. **One lookup per creator** - the fake counts Get calls; owner with 3 + 2 keys over 2 creators;
assert the creator lookups equal the distinct creators (plus the requester's own Get).
7. **Use case level** - ListAPIKeys for an owner returns entries with the creator name; for an
admin returns empty creator names; the other existing ListAPIKeys tests pass unchanged.
8. **Regression** - the existing api_keys tests still pass; list order, statuses, 401 and 403
behaviour are unchanged.
Run with `task test`; one test with `task test -- -run 'TestName'`.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for GET /v1/api-key, POST /v1/api-key and DELETE /v1/api-key/{id} per
case. Mount ApiKeysPage inside DashboardShell with a resolved verified session (id 1, "marta",
display name "Marta Alves", role "owner") and a test router at /integration/api-keys; admin cases
use role "admin", username "jonas". Mocked owner entries carry `created_by_name`. Stub
`navigator.clipboard.writeText` per case. No test freezes the system date; specs assert on the
fixtures' absolute dates only, and the expiry cases build their dates from the real clock. Run
layout cases at desktop and below-md (mock `matchMedia`).
1. **Owner: list** - breadcrumb (with the Integration link), title, owner description, header
labels, rows in fixture order with every cell (prefix cut to 9 characters plus "…", Created by
from `created_by_name`), badge words, "Revoke" on rows 1 to 3 and not row 4, row 4 muted, the
note; the below-md cards. An entry with an empty `created_by_name` shows "—".
2. **Admin: own keys only** - no "Created by" header or cell, admin description, two rows, cards
without " by {name}"; a session with no role gets the same rendering.
3. **Loading** - unresolved GET; 4 skeleton rows, aria-busy "true", header "Create key"; resolve
and assert rows.
4. **Empty** - `[]`; the panel copy and its "Create key", no table.
5. **Error, Examples-driven** - ApiError(500), then NetworkError; alert copy as drawn, "Try again",
no rows; click "Try again" and assert the second request and the rows.
6. **401 and 403** - the app-wide redirects, no alert on the page.
7. **Create key: opened** - title, text, "0 / 60", helps, buttons, focus on Name.
8. **Create key: validation, Examples-driven** - the three Outline rows; error text, no request,
errors clear when fixed.
9. **Create key: name too long** - 75 characters; counter, error class and text, no request.
10. **Create key: success** - one POST with `{"name":"Nightly media import"}` and no expires_at
key; the reveal: title, text, warning alert, key equal to the mocked `key`, "Copy", note, only
"Done".
11. **Create key: expiry** - date D + 30 days; assert expires_at is 23:59:59 local on that day
with the local offset. Examples today (sent), yesterday (blocked), tomorrow (sent); build the
dates from `new Date()` in the test.
12. **Reveal: copied** - fake timers; click "Copy"; writeText with the full key, icon check,
"Copied", aria-live announcement; advance 2 s; "Copy" again.
13. **Reveal: Done and Escape discard** - assert the modal is gone, the key string appears nowhere
in `wrapper.html()`, the router URL, localStorage or sessionStorage; the new row shows the
prefix only, status "active", "Never", created by the session user; no second GET; a reopened
form is empty. A backdrop click leaves the reveal open.
14. **Create key: Cancel and Escape** - no request, empty on reopen, focus back on the opener;
a backdrop click does not close.
15. **Revoke: confirm and Keep key** - dialog copy with the 9-character prefix, both buttons;
"Keep key" closes with no request.
16. **Revoked** - confirm; one DELETE with the key's id; row "revoked", muted, no "Revoke"; toast
title and text; no second GET.
17. **Not drawn: Create sending** - pending POST; "Creating…", disabled fields and Cancel, Escape
ignored.
18. **Not drawn: Create failed, Examples-driven** - ApiError(500) "Couldn't create the key. Try
again in a moment.", NetworkError "Couldn't reach RenewCMS. Check your connection and try
again.", ApiError(429) "Too many requests. Wait a moment, then try again.", ApiError(400)
"Something in the form isn't valid. Check the highlighted fields."; values kept, form
re-enabled, no row added; submit again, resolve 201, assert the reveal.
19. **Not drawn: Revoke sending** - "Revoking…", both buttons disabled, Escape ignored.
20. **Not drawn: Revoke failed, Examples-driven** - ApiError(500) "Couldn't revoke the key. Try
again in a moment." and NetworkError "Couldn't reach RenewCMS. Check your connection and try
again."; the notice in the open dialog, row unchanged; retry succeeds.
21. **Not drawn: Revoke 404** - the dialog closes, the row is removed, the error toast "That key no
longer exists.".
22. **Not drawn: Last used "Never"** - null `last_used_at`, desktop cell and mobile card.
23. **Mobile pinned button** - below md the pinned 48 px "Create key" exists in list, loading and
error; hidden while a modal is open and in Empty; no header button below md.
24. **Integration link and navigation** - on /integration assert "Manage API keys" is a link to
/integration/api-keys below the three info notes; navigate; assert the sidebar and drawer
"Integration" item has aria-current="page" on /integration/api-keys. Update the existing
integration specs (features/integration.qa.md case 1) for the extra link.
25. **French** - `setLocale('fr')`; walk cases 1, 4, 7, 10, 15, 16, 17, 21; no visible English
source string remains; dates use the French format.
26. **Messages** - every new `t()` key has a French entry (extend the i18n spec pattern).
Un-run web tests are reported as NOT RUN, never as passing.
## Visual (`task visual:check -- integration-api-keys`, desktop and mobile, light and dark)
Reference: docs/design/screens/integration.md, board `docs/design/source/RenewCMS Screens API
keys.dc.html`, exact values in `docs/design/source/ApiKeysPage.dc.html`. Add each state's setup to
`web/visual/states.ts` with the board's own sample data (mocked GET /v1/api-key responses include
`created_by_name` and 12-character prefixes whose first 9 characters match the board). Hard gate:
every capture of every state within 10 %.
Sample data: the owner and admin fixtures from the feature's Background; Create modal name
"Nightly media import" (20 / 60) and expiry 31 Dec 2026; validation: empty name (0 / 60) and
expiry 12 Mar 2026; too long: "Nightly import of the new media library from the Brussels office
file server" (75 / 60); reveal key rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe for
"Nightly media import"; revoke and toast for "Nightly media import" (rcms_Mq7b…).
No state is clock-sensitive, since every date is fixed text. The Create sample expiry 31 Dec 2026
becomes a past date after that day, but the state only displays it, so it gets no skip.
States:
- [ ] Owner: list
- [ ] Admin: own keys only
- [ ] Loading (the setup holds the request)
- [ ] Empty
- [ ] Error
- [ ] Create key
- [ ] Create key: validation
- [ ] Create key: name too long
- [ ] One-time reveal
- [ ] One-time reveal: copied (timer-driven, so it is checked)
- [ ] Revoke: confirm
- [ ] Revoked (list with the revoked row and the toast)
- [ ] Layout from the page component, not components.md: 36 px header, 48 px rows, 20 px status
badge; desktop modal 480 px wide 120 px from the top; mobile modal full width, bottom-aligned,
buttons stacked with Cancel under the primary; mobile card Revoke 44 px; toast 320 px wide
bottom-right on desktop and 16 px inset on mobile.
- [ ] Not drawn on the board, no visual state, Vitest only: Create sending, Create failed (500,
network, 429, 400), Revoke sending, Revoke failed, Revoke 404, "Never", the dismissal rules,
and the Integration "Manage API keys" link.
Risk: the mobile pinned "Create key" button is not on the board, and the Integration link is not
on the board's frames for the current Integration page. Either may push a mobile or Integration
capture over 10 %. Re-run `task visual:check -- integration` and the API keys screen. If either
fails, the user must decide: an approved per-state exception needs `approvedBy`, or a design
update.
Manual checks screenshots cannot cover:
- Keyboard and focus order: Tab reaches "Create key", then each "Revoke" in row order; modal focus
starts on Name, is trapped, and returns to the opener on close; the reveal's initial focus lands
on "Copy".
- Hover and motion: outline and primary button hover; modal motion; skeleton pulse stopped under
reduced motion.
## Manual verification
- Real round trip against `task dev`: create a key, copy it, run
`curl -H "Authorization: Bearer <key>" <origin>/v1/auth/me` and get 200; revoke it from the
screen and get 401; confirm the key gets 403 "This action needs a real session." on
/v1/api-key.
- Owner and admin: as owner confirm the Created by names (display name, else username); as admin
confirm only your own keys and no Created by column.
- Reset-password revocation: reset a user's password and confirm their keys show "revoked" after
reload.
- Clipboard on a real HTTPS or localhost origin.
## French strings for web/src/i18n/messages.ts
Keyed by the English text. `{0}` and `{1}` are the `t()` placeholders.
- API keys = Clés d'API
- Integration › API keys = Intégration › Clés d'API
- Create key = Créer une clé
- Name = Nom
- Key = Clé
- Status = Statut
- Created by = Créée par
- Created = Créée le
- Last used = Dernière utilisation
- Expires = Expire le
- active = active
- expired = expirée
- revoked = révoquée
- Revoke = Révoquer
- None = Aucune
- Never = Jamais
- Keys act as the user who created them. As an owner you see everyone's keys. = Les clés agissent comme l'utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.
- Keys act as you, with your role. You only see your own keys. = Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.
- Resetting a user's password revokes all of their keys. = Réinitialiser le mot de passe d'un utilisateur révoque toutes ses clés.
- No API keys yet = Aucune clé d'API pour l'instant
- Create a key to call the API from a script or service. = Créez une clé pour appeler l'API depuis un script ou un service.
- Couldn't load your API keys = Impossible de charger vos clés d'API
- Check your connection and try again. = Vérifiez votre connexion et réessayez.
- Try again = Réessayer
- Create API key = Créer une clé d'API
- The key acts as you, with your role. It is shown in full once. = La clé agit en votre nom, avec votre rôle. Elle n'est affichée en entier qu'une seule fois.
- Required. Up to 60 characters. = Obligatoire. 60 caractères maximum.
- Expiry date (optional) = Date d'expiration (facultative)
- Optional. Must be in the future. Leave it empty for a key that does not expire. = Facultative. Doit être dans le futur. Laissez vide pour une clé qui n'expire pas.
- Enter a name. = Saisissez un nom.
- Use 60 characters or fewer. = Utilisez 60 caractères ou moins.
- Choose a date in the future. = Choisissez une date dans le futur.
- Cancel = Annuler
- Your new API key = Votre nouvelle clé d'API
- "{0}" is ready. Copy the key and store it somewhere safe. = « {0} » est prête. Copiez la clé et conservez-la en lieu sûr.
- You won't be able to see this key again. = Vous ne pourrez plus revoir cette clé.
- Copy = Copier
- Copied = Copié
- Closing this window discards the key from the screen. Copy it first. = Fermer cette fenêtre supprime la clé de l'écran. Copiez-la d'abord.
- Done = Terminé
- Revoke this key? = Révoquer cette clé ?
- "{0}" ({1}) will stop working right away. Requests that use it answer 401. This can't be undone. = « {0} » ({1}) cessera de fonctionner immédiatement. Les requêtes qui l'utilisent recevront une réponse 401. Cette action est irréversible.
- Keep key = Garder la clé
- Revoke key = Révoquer la clé
- Key revoked = Clé révoquée
- "{0}" can no longer be used. = « {0} » ne peut plus être utilisée.
- Creating… = Création…
- Revoking… = Révocation…
- Couldn't create the key. Try again in a moment. = Impossible de créer la clé. Réessayez dans un instant.
- Couldn't reach RenewCMS. Check your connection and try again. = Impossible de joindre RenewCMS. Vérifiez votre connexion et réessayez.
- Too many requests. Wait a moment, then try again. = Trop de requêtes. Patientez un instant, puis réessayez.
- Something in the form isn't valid. Check the highlighted fields. = Un élément du formulaire n'est pas valide. Vérifiez les champs signalés.
- Couldn't revoke the key. Try again in a moment. = Impossible de révoquer la clé. Réessayez dans un instant.
- That key no longer exists. = Cette clé n'existe plus.
- Manage API keys = Gérer les clés d'API
@@ -3,7 +3,9 @@ package useCases
import ( import (
"RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"errors"
"sort" "sort"
"strings"
"time" "time"
) )
@@ -14,8 +16,10 @@ type ListAPIKeysUseCase struct {
} }
type APIKeyEntry struct { type APIKeyEntry struct {
Key apikey.APIKey Key apikey.APIKey
Status apikey.Status Status apikey.Status
CreatorName string
ListsCreator bool
} }
func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *ListAPIKeysUseCase { func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *ListAPIKeysUseCase {
@@ -40,14 +44,49 @@ func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, err
return keys[i].CreatedAt.After(keys[j].CreatedAt) return keys[i].CreatedAt.After(keys[j].CreatedAt)
}) })
creatorNames, err := l.creatorNamesFor(requester, keys)
if err != nil {
return nil, err
}
now := l.now() now := l.now()
entries := make([]APIKeyEntry, 0, len(keys)) entries := make([]APIKeyEntry, 0, len(keys))
for _, key := range keys { for _, key := range keys {
entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now)}) entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now), CreatorName: creatorNames[key.UserID], ListsCreator: requester.Role == user.RoleOwner})
} }
return entries, nil return entries, nil
} }
func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.APIKey) (map[uint32]string, error) {
names := map[uint32]string{}
if requester.Role != user.RoleOwner {
return names, nil
}
names[requester.ID] = displayNameOrUsername(requester)
for _, key := range keys {
if _, resolved := names[key.UserID]; resolved {
continue
}
creator, err := l.userRepository.Get(key.UserID)
if errors.Is(err, user.ErrUserNotFound) {
names[key.UserID] = ""
continue
}
if err != nil {
return nil, err
}
names[key.UserID] = displayNameOrUsername(creator)
}
return names, nil
}
func displayNameOrUsername(account user.User) string {
if trimmed := strings.TrimSpace(account.DisplayName); trimmed != "" {
return trimmed
}
return account.Username
}
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) { func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
if requester.Role == user.RoleOwner { if requester.Role == user.RoleOwner {
return l.apiKeyRepository.ListAll() return l.apiKeyRepository.ListAll()
+228
View File
@@ -0,0 +1,228 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
type apiKeyCreatorLookup struct {
user.Repository
gets map[uint32]int
failOn uint32
}
func newAPIKeyCreatorLookup(inner user.Repository) *apiKeyCreatorLookup {
return &apiKeyCreatorLookup{Repository: inner, gets: map[uint32]int{}}
}
func (a *apiKeyCreatorLookup) Get(id uint32) (user.User, error) {
a.gets[id]++
if id == a.failOn {
return user.User{}, errRepositoryFailure
}
return a.Repository.Get(id)
}
var _ user.Repository = &apiKeyCreatorLookup{}
func (e *apiKeyEnv) useCreatorLookup(lookup *apiKeyCreatorLookup) {
clock := func() time.Time { return e.now }
e.container.ListAPIKeysUseCase = useCases.NewListAPIKeysUseCase(e.keys, lookup, clock)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(e.container, e.settings))
e.router = router
}
func (e *apiKeyEnv) nameUsers() {
owner := e.users.users[apiKeyOwnerId]
owner.DisplayName = "Marta Alves"
e.users.users[apiKeyOwnerId] = owner
dev := e.users.users[apiKeyDevId]
dev.DisplayName = "Dev Two"
e.users.users[apiKeyDevId] = dev
}
func TestAPIKeysCreatorNameHttp(t *testing.T) {
allFields := []string{"id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at"}
t.Run("API: an owner's list carries the creator's name", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.nameUsers()
env.issueKey(apiKeyOwnerId, "a")
env.issueKey(apiKeyDevId, "b")
env.issueKey(apiKeyPatId, "c")
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
assertAPIKeyStatus(t, rec, http.StatusOK)
entries := apiKeyListedByName(t, rec)
want := map[string]string{"a": "Marta Alves", "b": "Dev Two", "c": "pat"}
for name, creator := range want {
if entries[name]["created_by_name"] != creator {
t.Errorf("entry %s: expected created_by_name %q, got %v", name, creator, entries[name]["created_by_name"])
}
apiKeyAssertExactFields(t, entries[name], append([]string{"created_by_name"}, allFields...)...)
}
})
t.Run("API: a whitespace-only display name falls back to the username", func(t *testing.T) {
env := newAPIKeyEnv(t)
pat := env.users.users[apiKeyPatId]
pat.DisplayName = " "
env.users.users[apiKeyPatId] = pat
env.issueKey(apiKeyPatId, "c")
entries := apiKeyListedByName(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId))
if entries["c"]["created_by_name"] != "pat" {
t.Errorf("expected pat, got %v", entries["c"]["created_by_name"])
}
})
t.Run("API: an admin's list is unchanged", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.nameUsers()
env.issueKey(apiKeyDevId, "a")
env.issueKey(apiKeyDevId, "b")
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)
assertAPIKeyStatus(t, rec, http.StatusOK)
list := apiKeyDecodeList(t, rec)
if len(list) != 2 {
t.Fatalf("expected two entries, got %d", len(list))
}
for _, entry := range list {
apiKeyAssertExactFields(t, entry, allFields...)
}
})
t.Run("API: a key whose creator no longer exists", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.issueKey(apiKeyPatId, "gone")
env.issueKey(apiKeyDevId, "kept")
delete(env.users.users, apiKeyPatId)
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
assertAPIKeyStatus(t, rec, http.StatusOK)
entries := apiKeyListedByName(t, rec)
if name, ok := entries["gone"]["created_by_name"]; !ok || name != "" {
t.Errorf("expected an empty created_by_name, got %v", name)
}
if entries["kept"]["created_by_name"] != "dev" {
t.Errorf("expected dev, got %v", entries["kept"]["created_by_name"])
}
})
t.Run("API: a failing user lookup fails the list", func(t *testing.T) {
env := newAPIKeyEnv(t)
env.issueKey(apiKeyDevId, "a")
lookup := newAPIKeyCreatorLookup(env.users)
lookup.failOn = apiKeyDevId
env.useCreatorLookup(lookup)
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error")
})
t.Run("API: the creator name is resolved once per user", func(t *testing.T) {
env := newAPIKeyEnv(t)
baseline := newAPIKeyCreatorLookup(env.users)
env.useCreatorLookup(baseline)
assertAPIKeyStatus(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusOK)
env.issueKey(apiKeyOwnerId, "o1")
env.issueKey(apiKeyOwnerId, "o2")
env.issueKey(apiKeyOwnerId, "o3")
env.issueKey(apiKeyDevId, "d1")
env.issueKey(apiKeyDevId, "d2")
lookup := newAPIKeyCreatorLookup(env.users)
env.useCreatorLookup(lookup)
assertAPIKeyStatus(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusOK)
if lookup.gets[apiKeyDevId] != 1 {
t.Errorf("expected one Get for the admin creator, got %d", lookup.gets[apiKeyDevId])
}
if lookup.gets[apiKeyOwnerId] != baseline.gets[apiKeyOwnerId] {
t.Errorf("expected no extra Get for the owner, got %d against %d", lookup.gets[apiKeyOwnerId], baseline.gets[apiKeyOwnerId])
}
})
}
func TestListAPIKeysCreatorName(t *testing.T) {
seed := func() *mockAPIKeyRepo {
repo := newMockAPIKeyRepo()
seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 1 })
seedStubKey(repo, "rcms_b", func(k *apikey.APIKey) { k.UserID = 2 })
return repo
}
users := func() *mockUserRepo {
repo := apiKeyUnitUsers()
marta := repo.users[1]
marta.DisplayName = "Marta Alves"
repo.users[1] = marta
return repo
}
t.Run("Use case: ListAPIKeys returns the creator name only to an owner", func(t *testing.T) {
uc := useCases.NewListAPIKeysUseCase(seed(), users(), apiKeyUnitClock)
ownerEntries, err := uc.ListAPIKeys(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
names := map[string]string{}
for _, entry := range ownerEntries {
names[entry.Key.Prefix] = entry.CreatorName
}
if names["rcms_a"] != "Marta Alves" || names["rcms_b"] != "dev" {
t.Errorf("unexpected creator names %v", names)
}
adminEntries, err := uc.ListAPIKeys(2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(adminEntries) != 1 || adminEntries[0].CreatorName != "" {
t.Errorf("expected one entry with an empty creator name, got %+v", adminEntries)
}
})
}
type apiKeyDuplicateIdRepo struct {
apikey.Repository
keys []apikey.APIKey
}
func (a *apiKeyDuplicateIdRepo) ListAll() ([]apikey.APIKey, error) {
return append([]apikey.APIKey(nil), a.keys...), nil
}
var _ apikey.Repository = &apiKeyDuplicateIdRepo{}
func TestListAPIKeysTieKeepsRepositoryOrder(t *testing.T) {
createdAt := apiKeyUnitNow.Add(-time.Hour)
repo := &apiKeyDuplicateIdRepo{keys: []apikey.APIKey{
{ID: 7, UserID: 1, Name: "first", CreatedAt: createdAt},
{ID: 7, UserID: 1, Name: "second", CreatedAt: createdAt},
}}
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
entries, err := uc.ListAPIKeys(1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 2 || entries[0].Key.Name != "first" || entries[1].Key.Name != "second" {
t.Errorf("expected a stable order for identical keys, got %+v", entries)
}
}
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { t } from '@/i18n'
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
import {
creatorLabel,
displayPrefix,
expiresLabel,
formatKeyDate,
lastUsedLabel,
} from '@/lib/apiKeys'
defineProps<{
keys: ApiKey[]
loading: boolean
showCreatedBy: boolean
}>()
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
function createdText(key: ApiKey, showCreatedBy: boolean) {
const date = formatKeyDate(key.created_at, '')
return showCreatedBy
? t('Created {0} by {1}', [date, creatorLabel(key)])
: t('Created {0}', [date])
}
function detailsText(key: ApiKey, showCreatedBy: boolean) {
return [
createdText(key, showCreatedBy),
t('Last used {0}', [lastUsedLabel(key)]),
t('Expires {0}', [expiresLabel(key)]),
].join(' · ')
}
</script>
<template>
<ul class="-mx-4 border-y border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<li
v-for="n in API_KEY_SKELETON_ROWS"
:key="n"
class="flex flex-col gap-1.5 border-b border-default px-4 py-3"
>
<USkeleton class="h-3.5 w-[55%] rounded-none bg-accented" />
<USkeleton class="h-3 w-[40%] rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li
v-for="key in keys"
:key="key.id"
data-testid="api-key-card"
class="flex flex-col gap-1.5 border-b border-default px-4 py-3"
:class="key.status === 'revoked' ? 'bg-muted' : 'bg-default'"
>
<div class="flex items-center gap-2">
<span
class="min-w-0 flex-1 truncate text-[0.9375rem]/5 font-medium"
:class="key.status === 'revoked' ? 'text-muted' : 'text-highlighted'"
>{{ key.name }}</span
>
<ApiKeyStatusBadge :status="key.status" />
</div>
<span
class="font-mono text-xs leading-[normal]"
:class="key.status === 'revoked' ? 'text-muted' : 'text-toned'"
>{{ displayPrefix(key.prefix) }}</span
>
<span :class="key.status === 'revoked' ? 'text-muted' : 'text-toned'">{{
detailsText(key, showCreatedBy)
}}</span>
<UButton
v-if="key.status !== 'revoked'"
color="neutral"
variant="outline"
class="h-11 self-start border border-accented bg-default px-3.5 text-sm font-medium text-highlighted ring-0"
@click="emit('revoke', key)"
>
{{ t('Revoke') }}
</UButton>
</li>
</template>
</ul>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const props = defineProps<{
name: string
apiKey: string
}>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ done: [] }>()
const { copied, copy } = useCopyToClipboard()
const copyButton = ref<{ $el: HTMLElement } | null>(null)
const description = computed(() =>
t('"{0}" is ready. Copy the key and store it somewhere safe.', [props.name]),
)
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
watch(
open,
async (isOpen) => {
if (!isOpen) {
return
}
await nextTick()
await nextTick()
copyButton.value?.$el?.focus()
},
{ immediate: true },
)
function onOpenChange(isOpen: boolean) {
if (!isOpen) {
emit('done')
}
}
function keepOpenOnOutsideInteraction(event: Event) {
event.preventDefault()
}
</script>
<template>
<UModal
:open="open"
:title="t('Your new API key')"
:description="description"
:close="false"
:content="{
onPointerDownOutside: keepOpenOnOutsideInteraction,
onInteractOutside: keepOpenOnOutsideInteraction,
}"
:ui="{
content:
'divide-y-0 border border-default shadow-(--shadow-modal) ring-0 sm:top-30 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-5 pt-5 pb-0 sm:px-5',
title: 'text-[1.125rem]/6.5 font-semibold',
description: 'mt-1 text-base/5.5 text-toned text-pretty',
body: 'px-5 pt-3.5 pb-0 sm:px-5 sm:pt-3.5 sm:pb-0',
footer: 'px-5 pt-3.5 pb-5 sm:px-5',
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-3.5">
<UAlert
role="alert"
color="warning"
variant="soft"
:close="false"
icon="i-lucide-triangle-alert"
:title="t('You won\'t be able to see this key again.')"
:ui="{ root: 'gap-2.5 px-3 py-2.5', title: 'text-sm font-medium' }"
/>
<div class="flex flex-col border border-accented bg-elevated md:flex-row">
<span
class="min-w-0 flex-1 px-3 py-2.5 font-mono text-code/5 break-all text-highlighted"
>{{ apiKey }}</span
>
<UButton
ref="copyButton"
:icon="copyIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-11 justify-center gap-1.5 rounded-none border-t border-accented px-3.5 font-medium md:border-t-0 md:border-l"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copy(apiKey)"
>
{{ copyLabel }}
</UButton>
</div>
<span class="text-muted">{{
t('Closing this window discards the key from the screen. Copy it first.')
}}</span>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col sm:flex-row sm:justify-end">
<UButton
block
class="h-11 justify-center sm:w-auto sm:px-3.5"
@click="emit('done')"
>
{{ t('Done') }}
</UButton>
</div>
</template>
</UModal>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { ApiKeyStatus } from '@/composables/useApiKeys'
const props = defineProps<{ status: ApiKeyStatus }>()
const badges = {
active: {
get label() {
return t('active')
},
color: 'success',
class: 'bg-(--rc-success-soft) text-success',
},
expired: {
get label() {
return t('expired')
},
color: 'warning',
class: 'bg-(--rc-warning-soft) text-warning',
},
revoked: {
get label() {
return t('revoked')
},
color: 'neutral',
class: 'bg-elevated text-muted',
},
} as const
const badge = computed(() => badges[props.status])
</script>
<template>
<UBadge
:color="badge.color"
variant="soft"
:class="badge.class"
:ui="{ base: 'h-5 px-1.5 py-0 text-xs' }"
>
{{ badge.label }}
</UBadge>
</template>
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ApiKeyStatusBadge from '@/components/ApiKeyStatusBadge.vue'
import { API_KEY_SKELETON_ROWS, type ApiKey } from '@/composables/useApiKeys'
import {
creatorLabel,
displayPrefix,
expiresLabel,
formatKeyDate,
lastUsedLabel,
} from '@/lib/apiKeys'
const props = defineProps<{
keys: ApiKey[]
loading: boolean
showCreatedBy: boolean
}>()
const emit = defineEmits<{ revoke: [key: ApiKey] }>()
type Row = ApiKey | { skeleton: true; id: number }
const skeletonRows: Row[] = Array.from({ length: API_KEY_SKELETON_ROWS }, (_, index) => ({
skeleton: true,
id: index,
}))
const data = computed<Row[]>(() => (props.loading ? skeletonRows : props.keys))
function fixed(width: string) {
return { meta: { class: { th: width, td: width } } }
}
const columns = computed(() => [
{
id: 'name',
get header() {
return t('Name')
},
},
{
id: 'prefix',
get header() {
return t('Key')
},
...fixed('w-30.5'),
},
{
id: 'status',
get header() {
return t('Status')
},
...fixed('w-22'),
},
...(props.showCreatedBy
? [
{
id: 'created_by',
get header() {
return t('Created by')
},
...fixed('w-54.75'),
},
]
: []),
{
id: 'created_at',
get header() {
return t('Created')
},
...fixed('w-28'),
},
{
id: 'last_used_at',
get header() {
return t('Last used')
},
...fixed('w-28'),
},
{
id: 'expires_at',
get header() {
return t('Expires')
},
...fixed('w-28'),
},
{ id: 'actions', header: '', ...fixed('w-24.5') },
])
const meta = {
class: {
tr: (row: { original: Row }) => {
const original = row.original
return 'skeleton' in original || original.status !== 'revoked' ? '' : 'bg-muted'
},
},
}
function key(row: { original: Row }) {
return row.original as ApiKey
}
function isSkeleton(row: { original: Row }) {
return 'skeleton' in row.original
}
function isRevoked(row: { original: Row }) {
return !isSkeleton(row) && key(row).status === 'revoked'
}
function mutedClass(row: { original: Row }) {
return isRevoked(row) ? 'text-muted' : 'text-toned'
}
</script>
<template>
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<span v-if="loading" role="status" class="sr-only">{{ t('Loading API keys') }}</span>
<UTable
:data="data"
:columns="columns"
:meta="meta"
:ui="{
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'h-9 px-1.5 py-0 first:pl-3 last:pr-3',
td: 'h-12 px-1.5 py-0 first:pl-3 last:pr-3',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #name-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[60%] rounded-none bg-accented" />
<span
v-else
class="block truncate font-medium"
:class="isRevoked(row) ? 'text-muted' : 'text-highlighted'"
>{{ key(row).name }}</span
>
</template>
<template #prefix-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[70%] rounded-none bg-accented" />
<span v-else class="font-mono text-xs" :class="mutedClass(row)">{{
displayPrefix(key(row).prefix)
}}</span>
</template>
<template #status-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-5 w-14 rounded-none bg-accented" />
<ApiKeyStatusBadge v-else :status="key(row).status" />
</template>
<template #created_by-cell="{ row }">
<USkeleton v-if="isSkeleton(row)" class="h-3 w-[60%] rounded-none bg-accented" />
<span v-else class="block truncate" :class="mutedClass(row)">{{
creatorLabel(key(row))
}}</span>
</template>
<template #created_at-cell="{ row }">
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
formatKeyDate(key(row).created_at, '')
}}</span>
</template>
<template #last_used_at-cell="{ row }">
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
lastUsedLabel(key(row))
}}</span>
</template>
<template #expires_at-cell="{ row }">
<span v-if="!isSkeleton(row)" :class="mutedClass(row)">{{
expiresLabel(key(row))
}}</span>
</template>
<template #actions-cell="{ row }">
<span v-if="!isSkeleton(row) && !isRevoked(row)" class="flex justify-end">
<UButton
color="neutral"
variant="outline"
size="xs"
class="h-7 border border-accented bg-default px-2.5 text-xs font-medium text-highlighted ring-0"
@click="emit('revoke', key(row))"
>
{{ t('Revoke') }}
</UButton>
</span>
</template>
</UTable>
</div>
</template>
+9 -1
View File
@@ -80,6 +80,13 @@ async function onConfirm() {
} }
} }
function onOpenChange(value: boolean) {
if (!value && pending.value) {
return
}
open.value = value
}
function onCancel() { function onCancel() {
if (pending.value) { if (pending.value) {
return return
@@ -90,10 +97,11 @@ function onCancel() {
<template> <template>
<UModal <UModal
v-model:open="open" :open="open"
:title="title" :title="title"
:description="description" :description="description"
:dismissible="!pending" :dismissible="!pending"
@update:open="onOpenChange"
:close="false" :close="false"
:content="{ :content="{
role: 'alertdialog', role: 'alertdialog',
+272
View File
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useApiKeys, type CreateApiKeyInput, type CreatedApiKey } from '@/composables/useApiKeys'
import { expiryInstant, isFutureExpiry } from '@/lib/apiKeys'
const NAME_MAX_LENGTH = 60
const fieldUi = { help: 'mt-1.5 text-sm/5 text-muted', error: 'mt-1.5 text-sm/5' }
const OPTIONAL_SUFFIX = /\s*\([^)]*\)$/
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [created: CreatedApiKey] }>()
const { createApiKey } = useApiKeys()
const name = ref('')
const expiry = ref('')
const submitted = ref(false)
const focused = ref(false)
const submitting = ref(false)
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed' | 'network'>('')
const nameLength = computed(() => [...name.value].length)
const trimmedName = computed(() => name.value.trim())
const nameError = computed(() => {
if (nameLength.value > NAME_MAX_LENGTH) {
return t('Use 60 characters or fewer.')
}
if (submitted.value && !trimmedName.value) {
return t('Enter a name.')
}
return ''
})
const expiryError = computed(() =>
submitted.value && expiry.value && !isFutureExpiry(expiry.value)
? t('Choose a date in the future.')
: '',
)
const expiryLabel = computed(() => t('Expiry date (optional)').replace(OPTIONAL_SUFFIX, ''))
const expiryOptional = computed(
() => OPTIONAL_SUFFIX.exec(t('Expiry date (optional)'))?.[0].trim() ?? '',
)
const alertMessage = computed(() => {
switch (formAlert.value) {
case 'invalid':
return t("Something in the form isn't valid. Check the highlighted fields.")
case 'rateLimited':
return t('Too many requests. Wait a moment, then try again.')
case 'network':
return t("Couldn't reach RenewCMS. Check your connection and try again.")
case 'failed':
return t("Couldn't create the key. Try again in a moment.")
default:
return ''
}
})
function resetForm() {
name.value = ''
expiry.value = ''
submitted.value = false
focused.value = false
submitting.value = false
formAlert.value = ''
}
async function focusNameField() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[name="name"]')?.focus()
focused.value = true
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
resetForm()
focusNameField()
},
{ immediate: true },
)
function showFailure(error: unknown) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return
}
if (error instanceof ApiError && error.status === 400) {
formAlert.value = 'invalid'
} else if (error instanceof ApiError && error.status === 429) {
formAlert.value = 'rateLimited'
} else if (error instanceof NetworkError) {
formAlert.value = 'network'
} else {
formAlert.value = 'failed'
}
}
async function onSubmit() {
if (submitting.value) {
return
}
submitted.value = true
focused.value = false
if (!trimmedName.value || nameLength.value > NAME_MAX_LENGTH || expiryError.value) {
return
}
const input: CreateApiKeyInput = { name: trimmedName.value }
if (expiry.value) {
input.expires_at = expiryInstant(expiry.value)
}
formAlert.value = ''
submitting.value = true
try {
const created = await createApiKey(input)
emit('created', created)
open.value = false
resetForm()
} catch (error) {
showFailure(error)
} finally {
submitting.value = false
}
}
function onCancel() {
if (submitting.value) {
return
}
open.value = false
}
function onOpenChange(isOpen: boolean) {
if (!isOpen && submitting.value) {
return
}
open.value = isOpen
}
function keepOpenOnOutsideInteraction(event: Event) {
event.preventDefault()
}
</script>
<template>
<UModal
:open="open"
:title="t('Create API key')"
:description="t('The key acts as you, with your role. It is shown in full once.')"
:dismissible="!submitting"
:close="false"
:content="{
onPointerDownOutside: keepOpenOnOutsideInteraction,
onInteractOutside: keepOpenOnOutsideInteraction,
}"
:ui="{
content:
'divide-y-0 border border-default shadow-(--shadow-modal) ring-0 sm:top-30 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-5 pt-5 pb-0 sm:px-5',
title: 'text-[1.125rem]/6.5 font-semibold',
description: 'mt-1 text-base/5.5 text-toned text-pretty',
body: 'px-5 pt-3.5 pb-0 sm:px-5 sm:pt-3.5 sm:pb-0',
footer: 'px-5 pt-3.5 pb-5 sm:px-5',
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-3.5">
<UAlert
v-if="alertMessage"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ name, expiry }" class="flex flex-col gap-3.5" @submit="onSubmit">
<UFormField
:label="t('Name')"
name="name"
:error="nameError || undefined"
:help="t('Required. Up to 60 characters.')"
:ui="fieldUi"
>
<template #hint>
<CharacterCounter
:value="nameLength"
:max="NAME_MAX_LENGTH"
variant="field"
/>
</template>
<UInput
v-model="name"
name="name"
autocomplete="off"
:disabled="submitting"
color="primary"
:highlight="focused || !!nameError"
:ui="{ base: 'h-10' }"
class="w-full"
@focus="focused = true"
@blur="focused = false"
/>
</UFormField>
<UFormField
name="expires_at"
:ui="fieldUi"
:error="expiryError || undefined"
:help="
t(
'Optional. Must be in the future. Leave it empty for a key that does not expire.',
)
"
>
<template #label>
{{ expiryLabel }}
<span v-if="expiryOptional" class="font-normal text-muted">{{
expiryOptional
}}</span>
</template>
<UInput
v-model="expiry"
type="date"
name="expires_at"
:disabled="submitting"
color="primary"
:highlight="!!expiryError"
:ui="{ base: 'h-10' }"
class="w-full"
/>
</UFormField>
</UForm>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 sm:w-auto sm:px-3.5"
:disabled="submitting"
@click="onCancel"
>
{{ t('Cancel') }}
</UButton>
<UButton
block
class="h-11 justify-center sm:w-auto sm:px-3.5"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? t('Creating…') : t('Create key') }}
</UButton>
</div>
</template>
</UModal>
</template>
+21 -1
View File
@@ -18,6 +18,9 @@ import UserMenu from '@/components/UserMenu.vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
mobileTitle?: string mobileTitle?: string
backTo?: string
backLabel?: string
stickyFooter?: boolean
fullBleed?: boolean fullBleed?: boolean
compactPadding?: boolean compactPadding?: boolean
renderWhileChecking?: boolean renderWhileChecking?: boolean
@@ -26,6 +29,9 @@ const props = withDefaults(
}>(), }>(),
{ {
mobileTitle: undefined, mobileTitle: undefined,
backTo: undefined,
backLabel: undefined,
stickyFooter: false,
offlineMessage: undefined, offlineMessage: undefined,
offlineIcon: undefined, offlineIcon: undefined,
fullBleed: false, fullBleed: false,
@@ -36,11 +42,15 @@ const props = withDefaults(
const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6' const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6'
const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8' const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8'
const STICKY_FOOTER_BODY = 'gap-0 px-4 pt-5 pb-0 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-0 lg:p-8'
const bodyUi = computed(() => { const bodyUi = computed(() => {
if (props.fullBleed) { if (props.fullBleed) {
return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0' return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
} }
if (props.stickyFooter) {
return STICKY_FOOTER_BODY
}
return props.compactPadding ? COMPACT_BODY : DEFAULT_BODY return props.compactPadding ? COMPACT_BODY : DEFAULT_BODY
}) })
@@ -229,7 +239,17 @@ async function onLogout() {
}" }"
> >
<template #toggle> <template #toggle>
<span class="relative lg:hidden"> <UButton
v-if="backTo"
:to="backTo"
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-11 justify-center lg:hidden"
:ui="{ leadingIcon: 'size-5' }"
:aria-label="backLabel"
/>
<span v-else class="relative lg:hidden">
<UDashboardSidebarToggle <UDashboardSidebarToggle
data-slot="toggle" data-slot="toggle"
color="neutral" color="neutral"
+54
View File
@@ -0,0 +1,54 @@
import { useApi } from '@/composables/useApi'
export type ApiKeyStatus = 'active' | 'expired' | 'revoked'
export interface ApiKey {
id: number
user_id: number
name: string
prefix: string
status: ApiKeyStatus
created_at: string
last_used_at: string | null
expires_at: string | null
revoked_at: string | null
created_by_name?: string
}
export interface CreatedApiKey {
id: number
name: string
prefix: string
key: string
created_at: string
expires_at: string | null
}
export interface CreateApiKeyInput {
name: string
expires_at?: string
}
export const API_KEY_SKELETON_ROWS = 4
export function useApiKeys() {
const { request } = useApi()
function listApiKeys(): Promise<ApiKey[]> {
return request<ApiKey[]>('/v1/api-key')
}
function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
return request<CreatedApiKey>('/v1/api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
}
function revokeApiKey(id: number): Promise<void> {
return request<void>(`/v1/api-key/${id}`, { method: 'DELETE' })
}
return { listApiKeys, createApiKey, revokeApiKey }
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const API_KEYS_MESSAGES = [
'API keys',
'Create key',
'Key',
'Created by',
'Last used',
'Expires',
'active',
'expired',
'revoked',
'Revoke',
'None',
'Never',
'Created {0}',
'Created {0} by {1}',
'Last used {0}',
'Expires {0}',
"Keys act as the user who created them. As an owner you see everyone's keys.",
'Keys act as you, with your role. You only see your own keys.',
"Resetting a user's password revokes all of their keys.",
'No API keys yet',
'Create a key to call the API from a script or service.',
"Couldn't load your API keys",
'Create API key',
'The key acts as you, with your role. It is shown in full once.',
'Required. Up to 60 characters.',
'Expiry date (optional)',
'Optional. Must be in the future. Leave it empty for a key that does not expire.',
'Enter a name.',
'Use 60 characters or fewer.',
'Choose a date in the future.',
'Your new API key',
'"{0}" is ready. Copy the key and store it somewhere safe.',
"You won't be able to see this key again.",
'Closing this window discards the key from the screen. Copy it first.',
'Done',
'Revoke this key?',
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.',
'Keep key',
'Revoke key',
'Key revoked',
'"{0}" can no longer be used.',
'Revoking…',
"Couldn't create the key. Try again in a moment.",
"Couldn't reach RenewCMS. Check your connection and try again.",
'Too many requests. Wait a moment, then try again.',
"Something in the form isn't valid. Check the highlighted fields.",
"Couldn't revoke the key. Try again in a moment.",
'That key no longer exists.',
'Manage API keys',
'Loading API keys',
]
describe('API keys screen messages', () => {
it.each(API_KEYS_MESSAGES)('has a French entry for "%s"', (key) => {
const french = (messages as Record<string, string>)[key]
expect(french).toBeTruthy()
if (key !== 'active') {
expect(french).not.toBe(key)
}
})
})
+60
View File
@@ -293,6 +293,7 @@ export const messages = {
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.": "Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.":
'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.', 'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.',
'Back to Articles': 'Retour aux articles', 'Back to Articles': 'Retour aux articles',
'Back to Integration': 'Retour à l’intégration',
'Your changes to "{0}" haven\'t been saved.': 'Your changes to "{0}" haven\'t been saved.':
'Vos modifications de « {0} » n’ont pas été enregistrées.', 'Vos modifications de « {0} » n’ont pas été enregistrées.',
'Shorten the text or remove an image. Formatting counts too.': 'Shorten the text or remove an image. Formatting counts too.':
@@ -824,6 +825,65 @@ export const messages = {
'images, {0} selected': 'images, {0} sélectionnées', 'images, {0} selected': 'images, {0} sélectionnées',
'Used in 1 article': 'Utilisée dans 1 article', 'Used in 1 article': 'Utilisée dans 1 article',
'Used in {0} articles': 'Utilisée dans {0} articles', 'Used in {0} articles': 'Utilisée dans {0} articles',
'API keys': 'Clés d’API',
'Create key': 'Créer une clé',
Key: 'Clé',
'Created by': 'Créée par',
'Last used': 'Dernière utilisation',
Expires: 'Expire le',
active: 'active',
expired: 'expirée',
revoked: 'révoquée',
Revoke: 'Révoquer',
None: 'Aucune',
Never: 'Jamais',
'Created {0}': 'Créée le {0}',
'Created {0} by {1}': 'Créée le {0} par {1}',
'Last used {0}': 'Dernière utilisation : {0}',
'Expires {0}': 'Expire : {0}',
"Keys act as the user who created them. As an owner you see everyone's keys.":
'Les clés agissent comme l’utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.',
'Keys act as you, with your role. You only see your own keys.':
'Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.',
"Resetting a user's password revokes all of their keys.":
'Réinitialiser le mot de passe d’un utilisateur révoque toutes ses clés.',
'No API keys yet': 'Aucune clé d’API pour l’instant',
'Create a key to call the API from a script or service.':
'Créez une clé pour appeler l’API depuis un script ou un service.',
"Couldn't load your API keys": 'Impossible de charger vos clés d’API',
'Create API key': 'Créer une clé d’API',
'The key acts as you, with your role. It is shown in full once.':
'La clé agit en votre nom, avec votre rôle. Elle n’est affichée en entier qu’une seule fois.',
'Required. Up to 60 characters.': 'Obligatoire. 60 caractères maximum.',
'Expiry date (optional)': 'Date d’expiration (facultative)',
'Optional. Must be in the future. Leave it empty for a key that does not expire.':
'Facultative. Doit être dans le futur. Laissez vide pour une clé qui n’expire pas.',
'Enter a name.': 'Saisissez un nom.',
'Choose a date in the future.': 'Choisissez une date dans le futur.',
'Your new API key': 'Votre nouvelle clé d’API',
'"{0}" is ready. Copy the key and store it somewhere safe.':
'« {0} » est prête. Copiez la clé et conservez-la en lieu sûr.',
"You won't be able to see this key again.": 'Vous ne pourrez plus revoir cette clé.',
'Closing this window discards the key from the screen. Copy it first.':
'Fermer cette fenêtre supprime la clé de l’écran. Copiez-la d’abord.',
Done: 'Terminé',
'Revoke this key?': 'Révoquer cette clé ?',
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.':
'« {0} » ({1}) cessera de fonctionner immédiatement. Les requêtes qui l’utilisent recevront une réponse 401. Cette action est irréversible.',
'Keep key': 'Garder la clé',
'Revoke key': 'Révoquer la clé',
'Key revoked': 'Clé révoquée',
'"{0}" can no longer be used.': '« {0} » ne peut plus être utilisée.',
'Revoking…': 'Révocation…',
"Couldn't create the key. Try again in a moment.":
'Impossible de créer la clé. Réessayez dans un instant.',
'Too many requests. Wait a moment, then try again.':
'Trop de requêtes. Patientez un instant, puis réessayez.',
"Couldn't revoke the key. Try again in a moment.":
'Impossible de révoquer la clé. Réessayez dans un instant.',
'That key no longer exists.': 'Cette clé n’existe plus.',
'Manage API keys': 'Gérer les clés d’API',
'Loading API keys': 'Chargement des clés d’API',
} as const } as const
export type MessageKey = keyof typeof messages export type MessageKey = keyof typeof messages
+60
View File
@@ -0,0 +1,60 @@
import { t } from '@/i18n'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import type { ApiKey } from '@/composables/useApiKeys'
const VISIBLE_PREFIX_LENGTH = 9
const END_OF_DAY = { hours: 23, minutes: 59, seconds: 59 }
const MINUTES_PER_HOUR = 60
export function displayPrefix(prefix: string) {
return `${prefix.slice(0, VISIBLE_PREFIX_LENGTH)}…`
}
function twoDigits(value: number) {
return String(value).padStart(2, '0')
}
function localEndOfDay(dateValue: string) {
const [year, month, day] = dateValue.split('-').map(Number)
return new Date(
year!,
month! - 1,
day,
END_OF_DAY.hours,
END_OF_DAY.minutes,
END_OF_DAY.seconds,
)
}
function localOffset(date: Date) {
const totalMinutes = -date.getTimezoneOffset()
const sign = totalMinutes < 0 ? '-' : '+'
const absolute = Math.abs(totalMinutes)
return `${sign}${twoDigits(Math.floor(absolute / MINUTES_PER_HOUR))}:${twoDigits(absolute % MINUTES_PER_HOUR)}`
}
export function expiryInstant(dateValue: string) {
const date = localEndOfDay(dateValue)
const day = `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
return `${day}T${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}:${twoDigits(date.getSeconds())}${localOffset(date)}`
}
export function isFutureExpiry(dateValue: string, now: Date = new Date()) {
return localEndOfDay(dateValue).getTime() > now.getTime()
}
export function formatKeyDate(iso: string | null, emptyLabel: string) {
return iso ? formatAbsoluteDateFromIso(iso) : emptyLabel
}
export function lastUsedLabel(key: ApiKey) {
return formatKeyDate(key.last_used_at, t('Never'))
}
export function expiresLabel(key: ApiKey) {
return formatKeyDate(key.expires_at, t('None'))
}
export function creatorLabel(key: ApiKey) {
return key.created_by_name || '—'
}
+5
View File
@@ -91,6 +91,11 @@ const router = createRouter({
component: () => import('@/views/IntegrationView.vue'), component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' }, meta: { auth: 'verified', title: 'Integration' },
}, },
{
path: '/integration/api-keys',
component: () => import('@/views/ApiKeysView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{ {
path: '/settings', path: '/settings',
component: () => import('@/views/SettingsView.vue'), component: () => import('@/views/SettingsView.vue'),
+378
View File
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ApiKeyCardList from '@/components/ApiKeyCardList.vue'
import ApiKeyReveal from '@/components/ApiKeyReveal.vue'
import ApiKeysTable from '@/components/ApiKeysTable.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import CreateApiKeyModal from '@/components/CreateApiKeyModal.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useApiKeys, type ApiKey, type CreatedApiKey } from '@/composables/useApiKeys'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { displayPrefix } from '@/lib/apiKeys'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const AUTH_HANDLED_STATUSES = [401, 403]
const NOT_FOUND_STATUS = 404
const REVOKE_FAILED_COPY = "Couldn't revoke the key. Try again in a moment."
const CONNECTION_FAILED_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
type LoadState = 'loading' | 'ready' | 'error'
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const session = useSessionStore()
const { listApiKeys, revokeApiKey } = useApiKeys()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const loadState = ref<LoadState>('loading')
const keys = ref<ApiKey[]>([])
const createOpen = ref(false)
const revealed = ref<{ name: string; key: string } | null>(null)
const revokeDialogOpen = ref(false)
let opener: HTMLElement | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const isEmpty = computed(() => loadState.value === 'ready' && keys.value.length === 0)
const showList = computed(
() => loadState.value === 'loading' || (loadState.value === 'ready' && keys.value.length > 0),
)
const showPinnedButton = computed(() => !isDesktop.value && !isEmpty.value)
const anyDialogOpen = computed(
() => createOpen.value || revealed.value !== null || revokeDialogOpen.value,
)
const description = computed(() =>
isOwner.value
? t("Keys act as the user who created them. As an owner you see everyone's keys.")
: t('Keys act as you, with your role. You only see your own keys.'),
)
const sessionName = computed(
() => session.session?.displayName?.trim() || session.session?.username || '',
)
function isHandledByApiClient(err: unknown) {
return err instanceof ApiError && AUTH_HANDLED_STATUSES.includes(err.status)
}
async function loadKeys() {
loadState.value = 'loading'
try {
keys.value = await listApiKeys()
loadState.value = 'ready'
} catch (err) {
if (!isHandledByApiClient(err)) {
loadState.value = 'error'
}
}
}
function rememberOpener() {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
}
async function restoreFocus() {
await nextTick()
opener?.focus()
opener = null
}
function openCreateModal() {
rememberOpener()
createOpen.value = true
}
function onCreateOpenChange(isOpen: boolean) {
createOpen.value = isOpen
if (!isOpen && revealed.value === null) {
restoreFocus()
}
}
function onCreated(created: CreatedApiKey) {
revealed.value = { name: created.name, key: created.key }
keys.value = [
{
id: created.id,
user_id: session.session?.id ?? 0,
name: created.name,
prefix: created.prefix,
status: 'active',
created_at: created.created_at,
last_used_at: null,
expires_at: created.expires_at,
revoked_at: null,
created_by_name: sessionName.value,
},
...keys.value,
]
}
function onRevealDone() {
revealed.value = null
restoreFocus()
}
function markRevoked(id: number) {
const revokedAt = new Date().toISOString()
keys.value = keys.value.map((key) =>
key.id === id ? { ...key, status: 'revoked', revoked_at: revokedAt } : key,
)
}
function removeKey(id: number) {
keys.value = keys.value.filter((key) => key.id !== id)
}
function announceRevoked(key: ApiKey) {
toast.add(() => ({
id: `api-key-revoked-${key.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Key revoked')
},
get description() {
return t('"{0}" can no longer be used.', [key.name])
},
}))
}
function announceMissing(key: ApiKey) {
toast.add(() => ({
id: `api-key-missing-${key.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t('That key no longer exists.')
},
}))
}
async function revoke(key: ApiKey) {
try {
await revokeApiKey(key.id)
} catch (err) {
if (isHandledByApiClient(err)) {
return
}
if (err instanceof ApiError && err.status === NOT_FOUND_STATUS) {
removeKey(key.id)
announceMissing(key)
return
}
throw new Error(
err instanceof NetworkError ? t(CONNECTION_FAILED_COPY) : t(REVOKE_FAILED_COPY),
)
}
markRevoked(key.id)
announceRevoked(key)
}
function confirmRevoke(key: ApiKey) {
rememberOpener()
revokeDialogOpen.value = true
overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Revoke this key?')
},
get description() {
return t(
'"{0}" ({1}) will stop working right away. Requests that use it answer 401. This can\'t be undone.',
[key.name, displayPrefix(key.prefix)],
)
},
get confirmLabel() {
return t('Revoke key')
},
get cancelLabel() {
return t('Keep key')
},
get pendingLabel() {
return t('Revoking…')
},
tone: 'danger',
action: () => revoke(key),
},
})
.open()
.finally(() => {
revokeDialogOpen.value = false
restoreFocus()
})
}
watch(showPinnedButton, (visible) => (toastsAboveBulkBar.value = visible), {
immediate: true,
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
onMounted(loadKeys)
</script>
<template>
<DashboardShell
:mobile-title="t('API keys')"
back-to="/integration"
sticky-footer
:back-label="t('Back to Integration')"
>
<template #breadcrumb>
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
},
to: '/integration',
},
{
get label() {
return t('API keys')
},
},
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1">
<UBreadcrumb
data-testid="api-keys-breadcrumb"
:items="[
{
get label() {
return t('Integration')
},
to: '/integration',
},
{
get label() {
return t('API keys')
},
},
]"
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
separator: 'px-0',
}"
>
<template #separator>
<span aria-hidden="true" class="text-muted">›</span>
</template>
</UBreadcrumb>
<h1 class="text-xl font-semibold text-highlighted md:text-2xl">
{{ t('API keys') }}
</h1>
<p class="text-pretty text-muted">{{ description }}</p>
</div>
<UButton v-if="isDesktop" class="h-9 px-3.5" @click="openCreateModal">
{{ t('Create key') }}
</UButton>
</div>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="horizontal"
:close="false"
:title="t('Couldn\'t load your API keys')"
:description="t('Check your connection and try again.')"
:ui="{ title: 'font-bold', description: 'mt-0 text-error' }"
>
<template #actions>
<UButton
color="neutral"
variant="link"
class="p-0 font-medium text-highlighted"
@click="loadKeys"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UEmpty
v-if="isEmpty"
variant="naked"
:title="t('No API keys yet')"
:description="t('Create a key to call the API from a script or service.')"
:ui="{
root: 'gap-2.5 border border-default bg-default px-6 py-12',
title: 'text-lg font-semibold',
description: 'max-w-90 text-sm text-toned text-pretty',
}"
>
<template #actions>
<UButton class="h-9 px-3.5" @click="openCreateModal">
{{ t('Create key') }}
</UButton>
</template>
</UEmpty>
<template v-if="showList">
<ApiKeysTable
v-if="isDesktop"
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="isOwner"
@revoke="confirmRevoke"
/>
<ApiKeyCardList
v-else
:keys="keys"
:loading="loadState === 'loading'"
:show-created-by="isOwner"
@revoke="confirmRevoke"
/>
</template>
<div class="flex gap-2.5">
<UIcon name="i-lucide-info" class="mt-0.75 size-4 shrink-0 text-info" />
<span class="text-base text-pretty text-toned">{{
t("Resetting a user's password revokes all of their keys.")
}}</span>
</div>
</div>
<div
v-if="showPinnedButton"
v-show="!anyDialogOpen"
class="sticky bottom-0 z-10 -mx-4 mt-4 border-t border-default bg-default p-4"
>
<UButton
class="h-12 w-full justify-center text-[0.9375rem]"
@click="openCreateModal"
>
{{ t('Create key') }}
</UButton>
</div>
<CreateApiKeyModal
:open="createOpen"
@update:open="onCreateOpenChange"
@created="onCreated"
/>
<ApiKeyReveal
v-if="revealed"
:open="true"
:name="revealed.name"
:api-key="revealed.key"
@done="onRevealDone"
/>
</DashboardShell>
</template>
+9
View File
@@ -150,6 +150,15 @@ const notes = computed(() => [
}}</span> }}</span>
</div> </div>
</div> </div>
<UButton
to="/integration/api-keys"
variant="link"
trailing-icon="i-lucide-arrow-right"
class="self-start px-0"
>
{{ t('Manage API keys') }}
</UButton>
</div> </div>
</DashboardShell> </DashboardShell>
</template> </template>
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin' import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router' import router from '@/router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import UButton from '@nuxt/ui/components/Button.vue'
import EndpointCard from '@/components/EndpointCard.vue' import EndpointCard from '@/components/EndpointCard.vue'
import IntegrationView from '../IntegrationView.vue' import IntegrationView from '../IntegrationView.vue'
@@ -102,6 +103,31 @@ describe("Default — the page loads with the app's own origin", () => {
}) })
}) })
describe('Manage API keys link', () => {
it('sits below the three notes as a link-variant button with a trailing arrow and navigates to the keys screen', async () => {
stubOrigin(STUB_ORIGIN)
const wrapper = await mountIntegration()
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
expect(anchor.attributes('href')).toBe('/integration/api-keys')
const button = wrapper
.findAllComponents(UButton)
.find((component) => component.element === anchor.element)!
expect(button.props('variant')).toBe('link')
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
const lastNote = wrapper
.findAll('span')
.find((span) => span.text().startsWith('Browsers can only call the API'))!
expect(
lastNote.element.compareDocumentPosition(anchor.element) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
await anchor.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
})
})
describe('Copied — the Base URL copy button', () => { describe('Copied — the Base URL copy button', () => {
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => { it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
stubOrigin(STUB_ORIGIN) stubOrigin(STUB_ORIGIN)
+234
View File
@@ -1976,6 +1976,239 @@ const pickerStates: Record<string, VisualState> = {
), ),
} }
const apiKeysFullKey = 'rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe'
const apiKeysTooLongName =
'Nightly import of the new media library from the Brussels office file server'
const apiKeysOwnerList = [
{
id: 4,
user_id: 1,
name: 'Website build',
prefix: 'rcms_Kf3x9Qw',
status: 'active',
created_at: '2026-03-12T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: null,
revoked_at: null,
created_by_name: 'Marta Alves',
},
{
id: 2,
user_id: 2,
name: 'Nightly media import',
prefix: 'rcms_Mq7bT2d',
status: 'active',
created_at: '2026-04-02T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: '2026-12-31T12:00:00Z',
revoked_at: null,
created_by_name: 'Jonas',
},
{
id: 3,
user_id: 1,
name: 'Staging preview',
prefix: 'rcms_Zt2dL8a',
status: 'expired',
created_at: '2026-06-14T09:00:00Z',
last_used_at: '2026-08-20T09:00:00Z',
expires_at: '2026-08-30T12:00:00Z',
revoked_at: null,
created_by_name: 'Marta Alves',
},
{
id: 1,
user_id: 3,
name: 'Old migration script',
prefix: 'rcms_Hn5wR4c',
status: 'revoked',
created_at: '2026-04-03T09:00:00Z',
last_used_at: '2026-05-09T09:00:00Z',
expires_at: null,
revoked_at: '2026-05-10T09:00:00Z',
created_by_name: 'Inès Duarte',
},
]
const apiKeysAdminList = [
{
id: 2,
user_id: 2,
name: 'Nightly media import',
prefix: 'rcms_Mq7bT2d',
status: 'active',
created_at: '2026-04-02T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: '2026-12-31T12:00:00Z',
revoked_at: null,
},
{
id: 1,
user_id: 2,
name: 'Old CI token',
prefix: 'rcms_Lp9cE1x',
status: 'revoked',
created_at: '2026-01-18T09:00:00Z',
last_used_at: '2026-02-02T09:00:00Z',
expires_at: null,
revoked_at: '2026-02-03T09:00:00Z',
},
]
function apiKeysApi(
list: ApiMock,
overrides: Record<string, ApiMock> = {},
session: ApiMock = verifiedSession,
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/api-key': list,
...overrides,
}
}
const apiKeysOwnerApi = (overrides: Record<string, ApiMock> = {}) =>
apiKeysApi({ status: 200, body: apiKeysOwnerList }, overrides)
const apiKeysCreated: ApiMock = {
status: 201,
body: {
id: 5,
name: 'Nightly media import',
prefix: apiKeysFullKey.slice(0, 12),
key: apiKeysFullKey,
created_at: '2026-10-04T09:00:00Z',
expires_at: null,
},
}
async function openApiKeyForm(page: Page) {
await page.getByRole('button', { name: 'Create key', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
async function fillApiKeyForm(page: Page, name: string, expiry: string) {
await page.locator('input[name="name"]').fill(name)
if (expiry) {
await page.locator('input[name="expires_at"]').fill(expiry)
}
}
async function submitApiKeyForm(page: Page) {
await page.getByRole('dialog').getByRole('button', { name: 'Create key', exact: true }).click()
}
async function settleApiKeyDialog(page: Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.mouse.move(0, 0)
}
async function revealApiKey(page: Page) {
await openApiKeyForm(page)
await fillApiKeyForm(page, 'Nightly media import', '')
await submitApiKeyForm(page)
await page.getByText('Your new API key').waitFor()
await page.mouse.move(0, 0)
}
function nightlyRevokeButton(page: Page) {
return page
.locator('tbody tr, li')
.filter({ hasText: 'Nightly media import' })
.getByRole('button', { name: 'Revoke', exact: true })
}
async function openApiKeyRevoke(page: Page) {
await nightlyRevokeButton(page).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
async function confirmApiKeyRevoke(page: Page) {
await openApiKeyRevoke(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Revoke key', exact: true })
.click()
await page.getByText('Key revoked').waitFor()
await page.mouse.move(0, 0)
}
const apiKeysStates: Record<string, VisualState> = {
'Owner: list': { path: '/integration/api-keys', api: apiKeysOwnerApi() },
'Admin: own keys only': {
path: '/integration/api-keys',
api: apiKeysApi({ status: 200, body: apiKeysAdminList }, {}, adminSession),
},
Loading: {
path: '/integration/api-keys',
api: apiKeysApi({ status: 200, hang: true }),
},
Empty: { path: '/integration/api-keys', api: apiKeysApi({ status: 200, body: [] }) },
Error: { path: '/integration/api-keys', api: apiKeysApi({ status: 500 }) },
'Create key': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, 'Nightly media import', '2026-12-31')
await settleApiKeyDialog(page)
},
},
'Create key: validation': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, '', '2026-03-12')
await submitApiKeyForm(page)
await settleApiKeyDialog(page)
},
},
'Create key: name too long': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, apiKeysTooLongName, '')
await settleApiKeyDialog(page)
},
},
'One-time reveal': {
path: '/integration/api-keys',
api: apiKeysOwnerApi({ 'POST /v1/api-key': apiKeysCreated }),
steps: revealApiKey,
},
'One-time reveal: copied': {
path: '/integration/api-keys',
origin: integrationOrigin,
api: apiKeysOwnerApi({ 'POST /v1/api-key': apiKeysCreated }),
steps: async (page) => {
await revealApiKey(page)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copy', exact: true })
.click()
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copied', exact: true })
.waitFor()
await page.mouse.move(0, 0)
},
},
'Revoke: confirm': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: openApiKeyRevoke,
},
Revoked: {
path: '/integration/api-keys',
api: apiKeysOwnerApi({ 'DELETE /v1/api-key/2': { status: 204 } }),
steps: confirmApiKeyRevoke,
},
}
export const visualStates: Record<string, Record<string, VisualState>> = { export const visualStates: Record<string, Record<string, VisualState>> = {
profile: { profile: {
'Account menu': { 'Account menu': {
@@ -2237,6 +2470,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}, },
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' }, 'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
}, },
'integration-api-keys': apiKeysStates,
integration: { integration: {
Default: { Default: {
path: '/integration', path: '/integration',