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
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user