feat(SPA): screen to manage API keys (PART 2/3)
This commit is contained in:
1 parent
844842fdae
commit
514a082c96
30 files changed
+3844
-83
No files matched your search
@@ -37,15 +37,16 @@ type createdKeyResponse struct {
|
||||
}
|
||||
|
||||
type keyListItem struct {
|
||||
ID uint32 `json:"id"`
|
||||
UserID uint32 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Status domain.Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
ID uint32 `json:"id"`
|
||||
UserID uint32 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Status domain.Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *time.Time `json:"expires_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 {
|
||||
@@ -121,7 +122,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
items := make([]keyListItem, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
key := entry.Key
|
||||
items = append(items, keyListItem{
|
||||
item := keyListItem{
|
||||
ID: key.ID,
|
||||
UserID: key.UserID,
|
||||
Name: key.Name,
|
||||
@@ -131,7 +132,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
LastUsedAt: key.LastUsedAt,
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
RevokedAt: key.RevokedAt,
|
||||
})
|
||||
}
|
||||
if entry.ListsCreator {
|
||||
item.CreatedByName = &entry.CreatorName
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(items)
|
||||
|
||||
@@ -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 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` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
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".
|
||||
- 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."
|
||||
- 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.""
|
||||
- Errors: table 400, 401, 403, 404, 409, 422, 429, 500 plus the JSON body.
|
||||
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=html` or `?format=markdown` to choose how `body` is returned."
|
||||
- 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: "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=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
|
||||
- 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).
|
||||
- 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)
|
||||
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).
|
||||
## States
|
||||
Desktop 1440×900, mobile 390×844, light and dark. Names are matched exactly by the visual-check script.
|
||||
|
||||
## API keys screen
|
||||
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.
|
||||
- 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".
|
||||
- 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."
|
||||
### 1. Default
|
||||
|
||||
### 2. Group scrolled, one card expanded
|
||||
|
||||
### 3a. Copied: base URL
|
||||
|
||||
### 3b. Copied: CodeBlock
|
||||
|
||||
### 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
|
||||
- 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".
|
||||
- SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour.
|
||||
- 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
|
||||
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
|
||||
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.
|
||||
@@ -7,13 +7,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<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="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>
|
||||
</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="{{ 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="{{ 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>
|
||||
@@ -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;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>
|
||||
<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>
|
||||
</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 }}">
|
||||
@@ -79,11 +80,11 @@ class Component extends DCLogic {
|
||||
}
|
||||
const m = k.modal ? M[k.modal] : null
|
||||
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.',
|
||||
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',
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +108,12 @@
|
||||
|
||||
<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>
|
||||
<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="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>
|
||||
</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>
|
||||
</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;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/<uuid>.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">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>
|
||||
</section>
|
||||
|
||||
@@ -176,36 +176,39 @@ class Component extends DCLogic {
|
||||
out.push(code.slice(last)); return out
|
||||
}
|
||||
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 e401 = [401, 'The key is invalid, unknown, expired or revoked, or its user was deleted or unverified.']
|
||||
const e404 = [404, 'No article with this id.']
|
||||
const m404 = [404, 'No image with this id.']
|
||||
const e401 = [401, 'You must be logged in to access this resource.']
|
||||
const e403 = [403, 'You must verify your account to access this resource.']
|
||||
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 G = {
|
||||
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-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-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-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-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, 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, 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-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' })
|
||||
] },
|
||||
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-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-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-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, 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-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' }]) })
|
||||
] },
|
||||
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-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-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-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-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-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, 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, i404], [], { r: '204 No Content' }),
|
||||
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' }) })
|
||||
] },
|
||||
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 })]
|
||||
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] } })),
|
||||
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 } })) } }),
|
||||
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) },
|
||||
@@ -267,20 +270,18 @@ class Component extends DCLogic {
|
||||
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',
|
||||
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',
|
||||
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto',
|
||||
errRows: [
|
||||
{ c: 400, m: 'Bad request', w: 'A field breaks its rule, such as a title under 3 characters.' },
|
||||
{ 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: 403, m: 'Forbidden', w: 'The key\'s role is too low, or the route is session only: "This action needs a real session."' },
|
||||
{ c: 404, m: 'Not found', w: 'The id doesn\'t exist. 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: 422, m: 'Unprocessable', w: 'The request is well formed but can\'t be applied, such as an update file that fails its check.' },
|
||||
{ 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.' }
|
||||
{ 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: '"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: '"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: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' },
|
||||
{ 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: 500, m: 'Server error', w: '"Internal server error"' }
|
||||
],
|
||||
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() {
|
||||
const withM = (st, mo = {}) => ({ ...st, m: { ...st, ...mo } })
|
||||
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: "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"} }),
|
||||
|
||||
@@ -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: [
|
||||
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: "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: "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} })
|
||||
] },
|
||||
{ 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({ 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: '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.' }),
|
||||
|
||||
+13
-13
@@ -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` | 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` | 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) |
|
||||
| API keys | `/integration/api-keys` | Owner: list | Owner sees every key, with Created by | [integration](screens/integration.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` | Loading | First load or retry | [integration](screens/integration.md) |
|
||||
| API keys | `/integration/api-keys` | Empty | No keys yet | [integration](screens/integration.md) |
|
||||
| API keys | `/integration/api-keys` | Error | 500 or network | [integration](screens/integration.md) |
|
||||
| API keys | `/integration/api-keys` | Create key | Modal opened | [integration](screens/integration.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: name too long | More than 60 characters | [integration](screens/integration.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: copied | Copy clicked (2 s) | [integration](screens/integration.md) |
|
||||
| API keys | `/integration/api-keys` | Revoke: confirm | Revoke pressed on a key | [integration](screens/integration.md) |
|
||||
| API keys | `/integration/api-keys` | Revoked | Confirmed, key stays listed as revoked | [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-api-keys](screens/integration-api-keys.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-api-keys](screens/integration-api-keys.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-api-keys](screens/integration-api-keys.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-api-keys](screens/integration-api-keys.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-api-keys](screens/integration-api-keys.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-api-keys](screens/integration-api-keys.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` | Only you | Nobody invited yet | [users](screens/users.md) |
|
||||
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -14,8 +16,10 @@ type ListAPIKeysUseCase struct {
|
||||
}
|
||||
|
||||
type APIKeyEntry struct {
|
||||
Key apikey.APIKey
|
||||
Status apikey.Status
|
||||
Key apikey.APIKey
|
||||
Status apikey.Status
|
||||
CreatorName string
|
||||
ListsCreator bool
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
creatorNames, err := l.creatorNamesFor(requester, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := l.now()
|
||||
entries := make([]APIKeyEntry, 0, len(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
|
||||
}
|
||||
|
||||
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) {
|
||||
if requester.Role == user.RoleOwner {
|
||||
return l.apiKeyRepository.ListAll()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -80,6 +80,13 @@ async function onConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && pending.value) {
|
||||
return
|
||||
}
|
||||
open.value = value
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (pending.value) {
|
||||
return
|
||||
@@ -90,10 +97,11 @@ function onCancel() {
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:dismissible="!pending"
|
||||
@update:open="onOpenChange"
|
||||
:close="false"
|
||||
:content="{
|
||||
role: 'alertdialog',
|
||||
|
||||
@@ -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>
|
||||
@@ -18,6 +18,9 @@ import UserMenu from '@/components/UserMenu.vue'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mobileTitle?: string
|
||||
backTo?: string
|
||||
backLabel?: string
|
||||
stickyFooter?: boolean
|
||||
fullBleed?: boolean
|
||||
compactPadding?: boolean
|
||||
renderWhileChecking?: boolean
|
||||
@@ -26,6 +29,9 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
mobileTitle: undefined,
|
||||
backTo: undefined,
|
||||
backLabel: undefined,
|
||||
stickyFooter: false,
|
||||
offlineMessage: undefined,
|
||||
offlineIcon: undefined,
|
||||
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 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(() => {
|
||||
if (props.fullBleed) {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -229,7 +239,17 @@ async function onLogout() {
|
||||
}"
|
||||
>
|
||||
<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
|
||||
data-slot="toggle"
|
||||
color="neutral"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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.":
|
||||
'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 Integration': 'Retour à l’intégration',
|
||||
'Your changes to "{0}" haven\'t been saved.':
|
||||
'Vos modifications de « {0} » n’ont pas été enregistrées.',
|
||||
'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',
|
||||
'Used in 1 article': 'Utilisée dans 1 article',
|
||||
'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
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -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 || '—'
|
||||
}
|
||||
@@ -91,6 +91,11 @@ const router = createRouter({
|
||||
component: () => import('@/views/IntegrationView.vue'),
|
||||
meta: { auth: 'verified', title: 'Integration' },
|
||||
},
|
||||
{
|
||||
path: '/integration/api-keys',
|
||||
component: () => import('@/views/ApiKeysView.vue'),
|
||||
meta: { auth: 'verified', title: 'Integration' },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
|
||||
@@ -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>
|
||||
@@ -150,6 +150,15 @@ const notes = computed(() => [
|
||||
}}</span>
|
||||
</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>
|
||||
</DashboardShell>
|
||||
</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 router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import UButton from '@nuxt/ui/components/Button.vue'
|
||||
import EndpointCard from '@/components/EndpointCard.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', () => {
|
||||
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
|
||||
@@ -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>> = {
|
||||
profile: {
|
||||
'Account menu': {
|
||||
@@ -2237,6 +2470,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
|
||||
},
|
||||
'integration-api-keys': apiKeysStates,
|
||||
integration: {
|
||||
Default: {
|
||||
path: '/integration',
|
||||
|
||||
Reference in new issue
Block a user