feat: integration v2 and API key PART 1/3
This commit is contained in:
1 parent
7e9bd9ba30
commit
844842fdae
40 files changed
+3782
-64
No files matched your search
@@ -0,0 +1,23 @@
|
||||
# API keys
|
||||
|
||||
Developers can authenticate against `/v1` with a named API key sent as `Authorization: Bearer rcms_...` instead of the `jwt` cookie. A key acts with exactly the rights of the user who created it.
|
||||
|
||||
## Managing keys
|
||||
|
||||
Key management needs a real session (the `jwt` cookie from a verified user).
|
||||
|
||||
- `POST /v1/api-key` with `{"name": "ci", "expires_at": "<RFC3339, optional>"}` answers `201` with `id`, `name`, `prefix`, `key`, `created_at`, `expires_at`. The full `key` is shown once and never again.
|
||||
- `GET /v1/api-key` lists the caller's keys (all keys for the owner), newest first, with a `status` of `active`, `expired` or `revoked`.
|
||||
- `DELETE /v1/api-key/{id}` revokes a key and answers `204`. Revoking twice keeps the first `revoked_at`.
|
||||
|
||||
Only the SHA-256 hash and a 12 character prefix are stored.
|
||||
|
||||
## Where a key is accepted
|
||||
|
||||
`/v1/article` (public and protected routes), `/v1/user`, `/v1/media`, `/v1/settings` and `GET /v1/auth/me`. An invalid, expired or revoked `rcms_` key answers `401` and is never treated as anonymous. Each key is limited to 120 requests per minute (`429` with `Retry-After`). `last_used_at` is updated at most once per minute.
|
||||
|
||||
## Session-only routes
|
||||
|
||||
`/v1/api-key`, `POST /v1/auth/verify`, `DELETE /v1/auth/register` and every `/v1/system` route answer `403 This action needs a real session.` to any `rcms_` Bearer header. The public auth routes ignore the header.
|
||||
|
||||
A successful password reset revokes every key of that user.
|
||||
@@ -31,7 +31,8 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Integration](screens/integration.md) | `/integration` | 2 | `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` |
|
||||
| [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` |
|
||||
@@ -41,6 +42,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
## Other boards
|
||||
|
||||
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
|
||||
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
|
||||
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
|
||||
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
|
||||
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
|
||||
|
||||
@@ -30,7 +30,12 @@
|
||||
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
|
||||
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
|
||||
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
|
||||
| EndpointCard | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration |
|
||||
| EndpointCard | UCollapsible, UBadge, UTable, UTabs | method: 'GET'\|'POST'\|'PATCH'\|'DELETE', path, description, access, keyAccess, body?, examples: { fetch, curl, response }, errors, sessionOnly? | none | none | Integration |
|
||||
| SectionIndex | custom nav (desktop), USelectMenu "Jump to" (mobile) | sections: { id, label }[], active: string | jump | none | Integration |
|
||||
| SessionOnlyGroup | UAlert-style note + list rows | routes: { method, path }[] | none | none | Integration |
|
||||
| ApiKeysTable | UTable, UBadge | keys: ApiKey[], showCreatedBy: boolean | revoke | none | API keys |
|
||||
| CreateApiKeyModal | UModal, UForm, UFormField, UInput | open: boolean | create | none | API keys |
|
||||
| ApiKeyReveal | UModal, UAlert, UButton | key: string | done | none | API keys |
|
||||
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
|
||||
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
|
||||
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
|
||||
|
||||
@@ -17,6 +17,7 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
|
||||
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
|
||||
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
|
||||
| `/integration` | DashboardShell | verified | IntegrationPage | none |
|
||||
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
|
||||
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
|
||||
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart |
|
||||
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
# Connect your website
|
||||
# Integration (API reference) and API keys
|
||||
|
||||
- **Route:** `/integration`
|
||||
- **API:** none (static)
|
||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
||||
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`.
|
||||
|
||||
Written for a developer. The base URL is window.location.origin.
|
||||
## 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.
|
||||
|
||||
## States
|
||||
## Page copy
|
||||
- Header: breadcrumb "Integration", title "API reference", description "Everything a developer needs to build on RenewCMS."
|
||||
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
|
||||
- Base URL: label "Base URL", button "Copy" / "Copied" (2 s, announced).
|
||||
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
|
||||
- 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."
|
||||
|
||||
### 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).
|
||||
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response with one CodeBlock and Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
|
||||
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
|
||||
- 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.
|
||||
|
||||
**Trigger:** Opened from the sidebar or the Home card.
|
||||
## 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).
|
||||
|
||||
- Breadcrumbs: Integration (mobile title "Integration")
|
||||
## 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."
|
||||
|
||||
### Copied
|
||||
## 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`.
|
||||
|
||||
**Trigger:** The base URL was copied. The button reads "Copied" for 2 s and this is announced.
|
||||
## 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.
|
||||
|
||||
- Breadcrumbs: Integration (mobile title "Integration")
|
||||
- copied: true
|
||||
## 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.
|
||||
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</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="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="{{ 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>
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}">
|
||||
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
|
||||
<sc-if value="{{ r.name }}"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.name }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span><span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></span>
|
||||
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
|
||||
<span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.canRevoke }}"><span style="height:28px;padding:0 10px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></span></sc-if>
|
||||
</div></sc-for></div></sc-if>
|
||||
<sc-if value="{{ mob }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:flex;flex-direction:column;gap:6px;padding:12px 16px;border-bottom:1px solid var(--ui-border);background:{{ r.bg }}">
|
||||
<sc-if value="{{ r.skel }}"><span style="height:14px;width:55%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:40%;background:var(--ui-bg-accented)"></span></sc-if>
|
||||
<sc-if value="{{ r.name }}"><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:15px;color:{{ r.fg }}">{{ r.name }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></div>
|
||||
<span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span>
|
||||
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Last used {{ r.used }} · Expires {{ r.exp }}</span>
|
||||
<sc-if value="{{ r.canRevoke }}"><span style="align-self:flex-start;height:44px;padding:0 14px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></sc-if>
|
||||
</div></sc-for></div></sc-if>
|
||||
</sc-if>
|
||||
<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="{{ 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 }}">
|
||||
<div role="dialog" aria-modal="true" style="width:{{ mW }};max-width:100%;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-modal);padding:20px;display:flex;flex-direction:column;gap:14px;color:var(--ui-text)">
|
||||
<div style="display:flex;flex-direction:column;gap:4px"><span style="font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ m.title }}</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px;text-wrap:pretty">{{ m.desc }}</span></div>
|
||||
<sc-if value="{{ m.form }}">
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">Name</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ m.cntFg }}">{{ m.count }}</span></div><span style="height:40px;padding:0 12px;display:flex;align-items:center;border:1px solid {{ m.nameBd }};background:var(--ui-bg);color:var(--ui-text-highlighted)">{{ m.name }}</span><span style="color:{{ m.nameFg }}">{{ m.nameHelp }}</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;color:var(--ui-text-highlighted)">Expiry date <span style="font-weight:400;color:var(--ui-text-muted)">(optional)</span></span><span style="height:40px;padding:0 12px;display:flex;align-items:center;border:1px solid {{ m.expBd }};background:var(--ui-bg);color:{{ m.expTx }}">{{ m.exp }}</span><span style="color:{{ m.expFg }}">{{ m.expHelp }}</span></div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ m.reveal }}">
|
||||
<div style="display:flex;gap:10px;padding:10px 12px;background:var(--rc-warning-soft);color:var(--ui-warning)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat"></span></span><span style="font-weight:500">You won't be able to see this key again.</span></div>
|
||||
<div style="display:flex;flex-direction:{{ keyDir }};border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated)"><span style="flex:1;min-width:0;padding:10px 12px;font:400 12px/20px 'IBM Plex Mono',monospace;word-break:break-all;color:var(--ui-text-highlighted)">rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe</span><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ m.cpFg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ m.cpIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ m.cpIcon }}.svg') center / contain no-repeat"></span>{{ m.cpT }}</span></div>
|
||||
<span style="color:var(--ui-text-muted)">Closing this window discards the key from the screen. Copy it first.</span>
|
||||
</sc-if>
|
||||
<div style="display:flex;flex-direction:{{ btnDir }};justify-content:flex-end;gap:8px"><sc-if value="{{ m.cancel }}"><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">{{ m.cancel }}</span></sc-if><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;font-weight:500;background:{{ m.cbg }};color:{{ m.cfg }};opacity:{{ m.cop }}">{{ m.confirm }}</span></div>
|
||||
</div></div></sc-if>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const mob = !!this.props.mobile, k = (this.props.s && this.props.s.ak) || {}
|
||||
const owner = k.view !== 'admin'
|
||||
const R = (name, prefix, status, by, created, used, exp) => {
|
||||
const rv = status === 'revoked', tone = { active: ['var(--rc-success-soft)', 'var(--ui-success)'], expired: ['var(--rc-warning-soft)', 'var(--ui-warning)'], revoked: ['var(--ui-bg-elevated)', 'var(--ui-text-muted)'] }[status]
|
||||
return { name, prefix, status, by, created, used, exp, sbg: tone[0], sfg: tone[1], bg: rv ? 'var(--ui-bg-muted)' : 'var(--ui-bg)', fg: rv ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)', fg2: rv ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', canRevoke: !rv }
|
||||
}
|
||||
let all = owner
|
||||
? [R('Website build', 'rcms_Kf3x…', 'active', 'Marta Alves', '12 Mar 2026', '3 Oct 2026', 'None'), R('Nightly media import', 'rcms_Mq7b…', k.revoked ? 'revoked' : 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Staging preview', 'rcms_Zt2d…', 'expired', 'Marta Alves', '14 Jun 2026', '20 Aug 2026', '30 Aug 2026'), R('Old migration script', 'rcms_Hn5w…', 'revoked', 'Inès Duarte', '3 Apr 2026', '9 May 2026', 'None')]
|
||||
: [R('Nightly media import', 'rcms_Mq7b…', 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Old CI token', 'rcms_Lp9c…', 'revoked', 'Jonas', '18 Jan 2026', '2 Feb 2026', 'None')]
|
||||
const st = k.state || 'list'
|
||||
const rows = st === 'loading' ? Array.from({ length: 4 }, () => ({ skel: true, bg: 'var(--ui-bg)' })) : all
|
||||
const cp = { reveal: true }
|
||||
const b = (o) => ({ cancel: 'Cancel', cbg: 'var(--ui-primary)', cfg: 'var(--ui-bg)', cop: '1', ...o })
|
||||
const form = (o) => b({ form: true, title: 'Create API key', desc: 'The key acts as you, with your role. It is shown in full once.', confirm: 'Create key', name: 'Nightly media import', count: '20 / 60', cntFg: 'var(--ui-text-muted)', nameBd: 'var(--ui-border-accented)', nameFg: 'var(--ui-text-muted)', nameHelp: 'Required. Up to 60 characters.', exp: '31 Dec 2026', expTx: 'var(--ui-text-highlighted)', expBd: 'var(--ui-border-accented)', expFg: 'var(--ui-text-muted)', expHelp: 'Optional. Must be in the future. Leave it empty for a key that does not expire.', ...o })
|
||||
const M = {
|
||||
create: form({}),
|
||||
invalid: form({ name: '', count: '0 / 60', nameBd: 'var(--ui-error)', nameFg: 'var(--ui-error)', nameHelp: 'Enter a name.', exp: '12 Mar 2026', expBd: 'var(--ui-error)', expFg: 'var(--ui-error)', expHelp: 'Choose a date in the future.' }),
|
||||
toolong: form({ name: 'Nightly import of the new media library from the Brussels office file server', count: '75 / 60', cntFg: 'var(--ui-error)', nameBd: 'var(--ui-error)', nameFg: 'var(--ui-error)', nameHelp: 'Use 60 characters or fewer.' }),
|
||||
reveal: b({ reveal: true, title: 'Your new API key', desc: '"Nightly media import" is ready. Copy the key and store it somewhere safe.', cancel: '', confirm: 'Done', cpT: 'Copy', cpIcon: 'copy', cpFg: 'var(--ui-text-highlighted)' }),
|
||||
copied: b({ reveal: true, title: 'Your new API key', desc: '"Nightly media import" is ready. Copy the key and store it somewhere safe.', cancel: '', confirm: 'Done', cpT: 'Copied', cpIcon: 'check', cpFg: 'var(--ui-success)' }),
|
||||
revoke: b({ title: 'Revoke this key?', desc: '"Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can\'t be undone.', cancel: 'Keep key', confirm: 'Revoke key', cbg: 'var(--ui-error)', cfg: 'var(--ui-bg)' })
|
||||
}
|
||||
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',
|
||||
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',
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,25 +177,8 @@
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ isInteg }}">
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:24px;max-width:860px;overflow:hidden">
|
||||
<div style="display:flex;flex-direction:column;gap:4px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Connect your website</span><span style="color:var(--ui-text-muted)">A guide for the developer who builds your public site.</span></div>
|
||||
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.</p>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span><div style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"><span style="flex:1;min-width:0;height:40px;padding:0 12px;display:flex;align-items:center;font:400 13px 'IBM Plex Mono',monospace;overflow:hidden;white-space:nowrap">https://cms.renew.example</span><span data-act="copy" style="height:40px;padding:0 12px;display:flex;align-items:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ copyFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ copyIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ copyIcon }}.svg') center / contain no-repeat"></span>{{ copyText }}</span></div></div>
|
||||
<sc-for list="{{ endpoints }}" as="e" hint-placeholder-count="2">
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:500 12px 'IBM Plex Mono',monospace">GET</span><span style="font:500 14px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ e.path }}</span></div><span style="color:var(--ui-text-toned)">{{ e.desc }}</span></div>
|
||||
<div style="display:flex;gap:20px;padding:0 16px;border-bottom:1px solid var(--ui-border)"><span style="height:36px;display:flex;align-items:center;border-bottom:2px solid {{ e.l1 }};margin-bottom:-1px;font-weight:500;color:var(--ui-text-highlighted)">fetch</span><span style="height:36px;display:flex;align-items:center;color:var(--ui-text-toned)">curl</span><span style="height:36px;display:flex;align-items:center;color:var(--ui-text-toned)">Response</span></div>
|
||||
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 8px 0 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">JavaScript</span><span style="display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span>Copy</span></div><pre style="margin:0;padding:14px 16px;overflow:hidden;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text);white-space:pre">{{ e.code }}</pre></div>
|
||||
</div>
|
||||
</sc-for>
|
||||
<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">Anonymous callers only ever get online articles. Drafts stay private until you publish them.</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Images in <code>body</code> use paths like <code>/static/uploadedImages/<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>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ isInteg }}"><div style="position:relative;flex:1;min-height:0;overflow:hidden"><dc-import name="IntegrationPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
|
||||
<sc-if value="{{ isKeys }}"><div style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden"><dc-import name="ApiKeysPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
|
||||
|
||||
<sc-if value="{{ isUsers }}">
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
|
||||
@@ -442,7 +425,7 @@ class Component extends DCLogic {
|
||||
desk: !m, mob: m, notBoot: !s.boot,
|
||||
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
||||
mLead: s.mLead || 'menu',
|
||||
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isError: k === 'error', isUsers: k === 'users',
|
||||
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
|
||||
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
|
||||
pad: m ? '20px 16px 88px' : '32px', gap: m ? '20px' : '28px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
|
||||
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<div ref="{{ rootRef }}" onScroll="{{ onScroll }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">
|
||||
<sc-if value="{{ mob }}">
|
||||
<div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;flex-direction:column;gap:4px">
|
||||
<button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button>
|
||||
<sc-if value="{{ jumpOpen }}"><div role="listbox" aria-label="Sections" style="position:absolute;left:16px;right:16px;top:56px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px">
|
||||
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button role="option" aria-selected="{{ i.on }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;height:44px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;font-size:14px;background:{{ i.bg }};color:var(--ui-text-highlighted);font-weight:{{ i.fw }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ i.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></button></sc-for>
|
||||
</div></sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="position:relative;padding:{{ pad }};display:flex;gap:40px;align-items:flex-start">
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:{{ secGap }}">
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="color:var(--ui-text-muted)">Integration</span>
|
||||
<span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API reference</span>
|
||||
<span style="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span>
|
||||
</div>
|
||||
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.</p>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
|
||||
<div style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"><span style="flex:1;min-width:0;height:40px;padding:0 12px;display:flex;align-items:center;font:400 13px 'IBM Plex Mono',monospace;overflow:hidden;white-space:nowrap">https://cms.renew.example</span>
|
||||
<button aria-label="Copy base URL" onClick="{{ copyBase }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:40px;padding:0 12px;display:flex;align-items:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ baseCopy.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ baseCopy.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ baseCopy.icon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ baseCopy.t }}</span></button></div>
|
||||
</div>
|
||||
|
||||
<section data-anchor="auth" aria-labelledby="h-auth" style="display:flex;flex-direction:column;gap:14px">
|
||||
<h2 id="h-auth" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Authentication</h2>
|
||||
<div style="display:grid;grid-template-columns:{{ authCols }};gap:12px;align-items:start">
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Public</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px">No key</span></div>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">No key needed. Two read-only routes, and only online articles are returned.</span>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)"><span>GET /v1/article/</span><span>GET /v1/article/{id}</span></div>
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. 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.</span>
|
||||
<div style="border:1px solid var(--ui-border);min-width:0">
|
||||
<div role="tablist" aria-label="Example language" style="display:flex;gap:16px;padding:0 12px;border-bottom:1px solid var(--ui-border)"><sc-for list="{{ authTabs }}" as="t" hint-placeholder-count="2"><button role="tab" aria-selected="{{ t.on }}" onClick="{{ t.pick }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:36px;display:flex;align-items:center;border-bottom:2px solid {{ t.bd }};margin-bottom:-1px;font-weight:500;color:{{ t.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ t.t }}</button></sc-for></div>
|
||||
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 4px 0 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ authLang }}</span><button aria-label="Copy example" onClick="{{ copyAuth }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;font-weight:500;color:{{ authCopy.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ authCopy.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ authCopy.icon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ authCopy.t }}</span></button></div>
|
||||
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ authCode }}</pre></div>
|
||||
</div>
|
||||
<span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span>
|
||||
<a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<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">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. 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.</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">Each key can make 120 requests per minute. Beyond that the API answers 429 with a <code>Retry-After</code> header.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="4">
|
||||
<section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2><span style="color:var(--ui-text-muted)">{{ g.count }}</span></div>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">{{ g.desc }}</span>
|
||||
<div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)">
|
||||
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
|
||||
<div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)">
|
||||
<button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">
|
||||
<span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;height:22px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:{{ ep.mBg }};color:{{ ep.mFg }};border:1px solid {{ ep.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ ep.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ ep.p }}</span><sc-if value="{{ mob }}"><span style="flex:1"></span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if></span>
|
||||
<span style="min-width:0;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:{{ descWrap }}">{{ ep.d }}</span>
|
||||
<span style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-start"><sc-for list="{{ ep.chips }}" as="c" hint-placeholder-count="2"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ c.bg }};color:{{ c.fg }};border:1px {{ c.bs }} {{ c.bd }};font-size:12px;font-weight:{{ c.fw }}"><sc-if value="{{ c.icon }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span></sc-if>{{ c.t }}</span></sc-for></span>
|
||||
<sc-if value="{{ desk }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if>
|
||||
</button>
|
||||
<sc-if value="{{ ep.open }}">
|
||||
<div style="padding:4px {{ detX }} 16px;display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--ui-border-muted)">
|
||||
<div style="padding-top:10px;display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">{{ ep.bodyTitle }}</span>
|
||||
<sc-if value="{{ ep.hasFields }}"><div style="border:1px solid var(--ui-border)">
|
||||
<div style="display:grid;grid-template-columns:{{ fCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Type</span><span>Required</span><sc-if value="{{ desk }}"><span>Rule</span></sc-if></div>
|
||||
<sc-for list="{{ ep.fields }}" as="f" hint-placeholder-count="2"><div style="display:grid;grid-template-columns:{{ fCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ f.n }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ f.t }}</span><span style="color:var(--ui-text-toned)">{{ f.req }}</span><span style="grid-column:{{ ruleCol }};color:var(--ui-text-toned);text-wrap:pretty">{{ f.rule }}</span></div></sc-for></div></sc-if>
|
||||
<sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div>
|
||||
<div style="border:1px solid var(--ui-border);min-width:0">
|
||||
<div role="tablist" aria-label="Example" style="display:flex;gap:16px;padding:0 12px;border-bottom:1px solid var(--ui-border)"><sc-for list="{{ ep.tabs }}" as="t" hint-placeholder-count="3"><button role="tab" aria-selected="{{ t.on }}" onClick="{{ t.pick }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:{{ tabH }};display:flex;align-items:center;border-bottom:2px solid {{ t.bd }};margin-bottom:-1px;font-weight:500;color:{{ t.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ t.t }}</button></sc-for></div>
|
||||
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 4px 0 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ ep.lang }}</span><button aria-label="{{ ep.copyLabel }}" onClick="{{ ep.copy }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;font-weight:500;color:{{ ep.cFg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.cIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.cIcon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ ep.cT }}</span></button></div>
|
||||
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ ep.code }}</pre></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span>
|
||||
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></div></sc-for></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">12 routes, not available to API keys</span></div>
|
||||
<div style="background:var(--ui-bg-muted);border:1px dashed var(--ui-border-accented)">
|
||||
<div style="display:flex;gap:10px;padding:12px 14px;color:var(--ui-text-toned);text-wrap:pretty"><span style="display:flex;padding-top:2px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span><span>These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."</span></div>
|
||||
<sc-for list="{{ sessGroups }}" as="sg" hint-placeholder-count="3">
|
||||
<div style="border-top:1px dashed var(--ui-border-accented)">
|
||||
<div style="padding:8px 14px 4px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">{{ sg.t }}</div>
|
||||
<sc-for list="{{ sg.rows }}" as="r" hint-placeholder-count="4"><div style="display:{{ rowDisplay }};grid-template-columns:{{ sessCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ sessGap }};padding:{{ sessPad }};min-height:36px"><span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;flex-shrink:0;font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ r.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:400 13px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ r.p }}</span></span><span style="color:var(--ui-text-muted)">{{ r.d }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;align-self:{{ chipSelf }};border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;white-space:nowrap"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span>Session only</span></div></sc-for>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ errBody }}</pre></div></div>
|
||||
</section>
|
||||
|
||||
<section data-anchor="notes" aria-labelledby="h-notes" style="display:flex;flex-direction:column;gap:10px">
|
||||
<h2 id="h-notes" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Notes</h2>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<sc-if value="{{ desk }}">
|
||||
<nav aria-label="On this page" style="position:sticky;top:32px;width:168px;flex-shrink:0;display:flex;flex-direction:column;gap:2px;border-left:1px solid var(--ui-border)">
|
||||
<span style="padding:0 12px 6px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">On this page</span>
|
||||
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button aria-current="{{ i.cur }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 12px;margin-left:-1px;display:flex;align-items:center;border-left:2px solid {{ i.bd }};color:{{ i.fg }};font-weight:{{ i.fw }}" style-hover="color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}</button></sc-for>
|
||||
</nav>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: i.scroll === 'ep-' + i.open ? 'media' : (i.scroll || 'auth'), authTab: i.authTab || 'curl' } })()
|
||||
rootRef = React.createRef()
|
||||
BASE = 'https://cms.renew.example'
|
||||
KEY = 'rcms_Kf3x9QwL0aBc…'
|
||||
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['media', 'Media'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
|
||||
componentDidMount() {
|
||||
const i = (this.props.s && this.props.s.int) || {}
|
||||
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
|
||||
go(); setTimeout(go, 250); setTimeout(go, 900)
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(go)
|
||||
}
|
||||
scrollTo(a, mob) {
|
||||
const r = this.rootRef.current; if (!r) return
|
||||
const el = r.querySelector('[data-anchor="' + a + '"]'); if (!el) return
|
||||
const off = a.indexOf('ep-') === 0 ? 24 : 0
|
||||
r.scrollTop = Math.max(0, this.yOf(el, r) - (mob ? 64 : 32) - off)
|
||||
}
|
||||
yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) }
|
||||
onScroll = () => {
|
||||
const r = this.rootRef.current; if (!r) return
|
||||
let cur = this.IDX[0][0]
|
||||
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (el && this.yOf(el, r) <= r.scrollTop + 120) cur = k }
|
||||
if (cur !== this.state.active) this.setState({ active: cur })
|
||||
}
|
||||
flash(k) {
|
||||
this.setState({ copied: k }); clearTimeout(this._t); this._t = setTimeout(() => this.setState({ copied: null }), 2000)
|
||||
}
|
||||
hl(code) {
|
||||
const re = /("[^"\n]*"|'[^'\n]*')|\b(const|await|fetch|curl|new)\b|\b(\d+)\b/g
|
||||
const out = []; let last = 0, m, k = 0
|
||||
while ((m = re.exec(code))) {
|
||||
if (m.index > last) out.push(code.slice(last, m.index))
|
||||
const c = m[1] ? 'var(--ui-success)' : m[2] ? 'var(--ui-info)' : 'var(--ui-warning)'
|
||||
out.push(React.createElement('span', { key: k++, style: { color: c } }, m[0])); last = m.index + m[0].length
|
||||
}
|
||||
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 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 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-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-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-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('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: [
|
||||
A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) })
|
||||
] }
|
||||
}
|
||||
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
|
||||
const mob = !!this.props.mobile
|
||||
return Object.keys(G).map(key => {
|
||||
const g = G[key]
|
||||
return { key, title: g.title, desc: g.desc, count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => {
|
||||
const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{id}', '12')
|
||||
const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY]
|
||||
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
|
||||
hdr.forEach(h => curl += ' \\\n -H "' + h + '"')
|
||||
if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + JSON.stringify(e.body) + '\''
|
||||
if (e.form) e.form.forEach(f => curl += ' \\\n -F "' + f[0] + '=' + f[1] + '"')
|
||||
let fe = (e.form ? 'const form = new FormData()\n' + e.form.map(f => f[1][0] === '@' ? "form.append('file', file)" : "form.append('" + f[0] + "', '" + f[1] + "')").join('\n') + '\n\n' : '') + "const res = await fetch('" + url + "'"
|
||||
const opts = []
|
||||
if (e.m !== 'GET') opts.push("method: '" + e.m + "'")
|
||||
if (hdr.length || e.body) opts.push('headers: { ' + [hdr.length ? "Authorization: 'Bearer " + this.KEY + "'" : null, e.body ? "'Content-Type': 'application/json'" : null].filter(Boolean).join(', ') + ' }')
|
||||
if (e.body) opts.push('body: JSON.stringify(' + JSON.stringify(e.body) + ')')
|
||||
if (e.form) opts.push('body: form')
|
||||
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
|
||||
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
|
||||
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
|
||||
const cp = this.state.copied === e.id
|
||||
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
|
||||
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,
|
||||
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) },
|
||||
errs: e.errs.map(([c, cap]) => ({ c, cap })) }
|
||||
}) }
|
||||
})
|
||||
}
|
||||
renderVals() {
|
||||
const mob = !!this.props.mobile, st = this.state
|
||||
const code = {
|
||||
curl: 'curl "' + this.BASE + '/v1/auth/me" \\\n -H "Authorization: Bearer ' + this.KEY + '"',
|
||||
fetch: "const res = await fetch('" + this.BASE + "/v1/auth/me', {\n headers: { Authorization: 'Bearer " + this.KEY + "' }\n})\nconst me = await res.json()"
|
||||
}[st.authTab]
|
||||
const cp = k => ({ t: st.copied === k ? 'Copied' : 'Copy', icon: st.copied === k ? 'check' : 'copy', fg: st.copied === k ? 'var(--ui-success)' : 'var(--ui-text-toned)' })
|
||||
const bc = cp('base'); if (st.copied !== 'base') bc.fg = 'var(--ui-text-highlighted)'
|
||||
const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } })
|
||||
const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
|
||||
const D = (a) => 'rowDisplay'
|
||||
return {
|
||||
rootRef: this.rootRef, onScroll: this.onScroll, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
|
||||
activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index,
|
||||
pad: mob ? '20px 16px 96px' : '32px', secGap: mob ? '28px' : '32px', h1: mob ? '20px/28px' : '28px/36px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px',
|
||||
authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)',
|
||||
baseCopy: bc, copyBase: () => { try { navigator.clipboard && navigator.clipboard.writeText(this.BASE) } catch (x) { } this.flash('base') },
|
||||
authTabs: ['curl', 'fetch'].map(t => { const on = st.authTab === t; return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState({ authTab: t }) } }),
|
||||
authLang: st.authTab === 'curl' ? 'Shell' : 'JavaScript', authCode: this.hl(code), authCopy: cp('auth'), copyAuth: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash('auth') },
|
||||
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']])],
|
||||
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.' }
|
||||
],
|
||||
errBody: this.hl('{\n "statusCode": 403,\n "message": "This action needs a real session."\n}')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<link rel="icon" href="brand/favicon.svg">
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet">
|
||||
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
||||
</helmet>
|
||||
<div ref="{{ rootRef }}" data-screen-label="Back-office screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||
|
||||
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: API keys</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">The screen the Authentication section of <a href="RenewCMS Screens Back-office.dc.html">the Integration screen</a> links to. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode.</p>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="7">
|
||||
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
|
||||
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"light","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light' }
|
||||
rootRef = React.createRef()
|
||||
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||
resolved() {
|
||||
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
return this.state.mode
|
||||
}
|
||||
apply() {
|
||||
const el = this.rootRef.current
|
||||
if (!el) return
|
||||
const vars = this.resolved() === 'dark' ? this.dark : this.light
|
||||
for (const k in vars) el.style.setProperty(k, vars[k])
|
||||
document.body.style.background = vars['--rc-canvas']
|
||||
}
|
||||
componentDidMount() { this.apply() }
|
||||
componentDidUpdate(p) {
|
||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||
this.apply()
|
||||
}
|
||||
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: [
|
||||
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"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Empty", trigger: "No keys yet.", ak: {"state":"empty"} }, { ak: {"state":"empty"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Error", trigger: "A 500 or network failure on the list request.", ak: {"state":"error"} }, { ak: {"state":"error"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key", trigger: "\"Create key\" in the page header, or the bottom action on mobile. Name is required and limited to 60 characters; expiry is optional.", ak: {"modal":"create"} }, { ak: {"modal":"create"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key: validation", trigger: "On submit or blur. Nothing is sent. The name is empty and the expiry is in the past.", ak: {"modal":"invalid"} }, { ak: {"modal":"invalid"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key: name too long", trigger: "Over 60 characters. Checked as you type.", ak: {"modal":"toolong"} }, { ak: {"modal":"toolong"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "One-time reveal", trigger: "201: the full key is shown once. Closing the dialog discards it from the screen.", ak: {"modal":"reveal"} }, { ak: {"modal":"reveal"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "One-time reveal: copied", trigger: "Copy was pressed. The button reads \"Copied\" for 2 s and this is announced.", ak: {"modal":"copied"} }, { ak: {"modal":"copied"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Revoke: confirm", trigger: "Revoke on an active or expired row.", ak: {"modal":"revoke"} }, { ak: {"modal":"revoke"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Revoked", trigger: "204: the key stays in the list, greyed, with no Revoke action. A toast confirms.", ak: {"revoked":1,"toast":1} }, { ak: {"revoked":1,"toast":1} })
|
||||
] }
|
||||
])
|
||||
}
|
||||
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
|
||||
renderVals() {
|
||||
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
||||
const on = this.state.mode === k
|
||||
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
||||
})
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -265,9 +265,15 @@ class Component extends DCLogic {
|
||||
MS({ kind: 'picker', inArticle: true, picker: false, modal: { k: 'remove' }, name: 'Remove image: confirm', trigger: 'Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?"' }),
|
||||
MS({ kind: 'picker', name: 'Removed', trigger: 'Confirmed. The image leaves the article body, which now has unsaved changes.', toasts: [TT('success', 'circle-check', 'Image removed', 'It\'s still in your library.')] })
|
||||
] },
|
||||
{ label: 'Integration', title: 'Connect your website', route: '/integration', api: 'none (static)', note: 'Written for a developer. The base URL is window.location.origin.', states: [
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: 'Default', trigger: 'Opened from the sidebar or the Home card.' }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' })
|
||||
{ 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: "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: "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: [
|
||||
withM({ ...U(), name: 'Default', trigger: 'The list loaded, newest first.' }),
|
||||
|
||||
+20
-2
@@ -134,8 +134,26 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
|
||||
| Media picker | `/article/:id/edit` | Inserted | Insert image from either tab | [media-picker](screens/media-picker.md) |
|
||||
| Media picker | `/article/:id/edit` | Remove image: confirm | Remove image on an image in the article | [media-picker](screens/media-picker.md) |
|
||||
| Media picker | `/article/:id/edit` | Removed | Confirmed | [media-picker](screens/media-picker.md) |
|
||||
| Integration | `/integration` | Default | Page load | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Copied | Copy clicked (2 s) | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Default | Page load, every endpoint card collapsed | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Group scrolled, one card expanded | A card opened, one example visible | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Copied: base URL | Copy clicked (2 s) | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Copied: CodeBlock | Copy clicked in a code block (2 s) | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Authentication | API key card, link and notes | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | 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` | 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) |
|
||||
| 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