From 514a082c966a437ee363e771e176ca1aa38d24ae Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Mon, 5 Oct 2026 00:05:12 +0200 Subject: [PATCH] feat(SPA): screen to manage API keys (PART 2/3) --- api/handlers/apikey/apiKeyHandler.go | 27 +- docs/design/README.md | 2 +- docs/design/screens/integration-api-keys.md | 58 + docs/design/screens/integration.md | 66 +- docs/design/source/ApiKeysPage.dc.html | 9 +- docs/design/source/IntegrationPage.dc.html | 57 +- .../source/RenewCMS Screens API keys.dc.html | 2 +- .../RenewCMS Screens Back-office.dc.html | 6 +- docs/design/states.md | 26 +- features/api_keys_screen.feature | 352 +++++ features/api_keys_screen.qa.md | 235 ++++ .../infrastructure/useCases/ListAPIKeys.go | 45 +- test/api_keys_creator_name_http_test.go | 228 ++++ web/src/components/ApiKeyCardList.vue | 86 ++ web/src/components/ApiKeyReveal.vue | 117 ++ web/src/components/ApiKeyStatusBadge.vue | 45 + web/src/components/ApiKeysTable.vue | 190 +++ web/src/components/ConfirmDialog.vue | 10 +- web/src/components/CreateApiKeyModal.vue | 272 ++++ web/src/components/DashboardShell.vue | 22 +- web/src/composables/useApiKeys.ts | 54 + web/src/i18n/apiKeys.spec.ts | 65 + web/src/i18n/messages.ts | 60 + web/src/lib/apiKeys.ts | 60 + web/src/router/index.ts | 5 + web/src/views/ApiKeysView.vue | 378 ++++++ web/src/views/IntegrationView.vue | 9 + web/src/views/__tests__/ApiKeysView.spec.ts | 1181 +++++++++++++++++ .../views/__tests__/IntegrationView.spec.ts | 26 + web/visual/states.ts | 234 ++++ 30 files changed, 3844 insertions(+), 83 deletions(-) create mode 100644 docs/design/screens/integration-api-keys.md create mode 100644 features/api_keys_screen.feature create mode 100644 features/api_keys_screen.qa.md create mode 100644 test/api_keys_creator_name_http_test.go create mode 100644 web/src/components/ApiKeyCardList.vue create mode 100644 web/src/components/ApiKeyReveal.vue create mode 100644 web/src/components/ApiKeyStatusBadge.vue create mode 100644 web/src/components/ApiKeysTable.vue create mode 100644 web/src/components/CreateApiKeyModal.vue create mode 100644 web/src/composables/useApiKeys.ts create mode 100644 web/src/i18n/apiKeys.spec.ts create mode 100644 web/src/lib/apiKeys.ts create mode 100644 web/src/views/ApiKeysView.vue create mode 100644 web/src/views/__tests__/ApiKeysView.spec.ts diff --git a/api/handlers/apikey/apiKeyHandler.go b/api/handlers/apikey/apiKeyHandler.go index 5b159d8..0046e27 100644 --- a/api/handlers/apikey/apiKeyHandler.go +++ b/api/handlers/apikey/apiKeyHandler.go @@ -37,15 +37,16 @@ type createdKeyResponse struct { } type keyListItem struct { - ID uint32 `json:"id"` - UserID uint32 `json:"user_id"` - Name string `json:"name"` - Prefix string `json:"prefix"` - Status domain.Status `json:"status"` - CreatedAt time.Time `json:"created_at"` - LastUsedAt *time.Time `json:"last_used_at"` - ExpiresAt *time.Time `json:"expires_at"` - RevokedAt *time.Time `json:"revoked_at"` + ID uint32 `json:"id"` + UserID uint32 `json:"user_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Status domain.Status `json:"status"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at"` + CreatedByName *string `json:"created_by_name,omitempty"` } func NewHandler(create *useCases.CreateAPIKeyUseCase, list *useCases.ListAPIKeysUseCase, revoke *useCases.RevokeAPIKeyUseCase) *Handler { @@ -121,7 +122,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { items := make([]keyListItem, 0, len(entries)) for _, entry := range entries { key := entry.Key - items = append(items, keyListItem{ + item := keyListItem{ ID: key.ID, UserID: key.UserID, Name: key.Name, @@ -131,7 +132,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { LastUsedAt: key.LastUsedAt, ExpiresAt: key.ExpiresAt, RevokedAt: key.RevokedAt, - }) + } + if entry.ListsCreator { + item.CreatedByName = &entry.CreatorName + } + items = append(items, item) } body, err := json.Marshal(items) diff --git a/docs/design/README.md b/docs/design/README.md index be3216f..7adad4e 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -32,7 +32,7 @@ The visual spec of record for the back-office SPA and the transactional emails, | [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` | | [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` | | [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` | -| [API keys](screens/integration.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` | +| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` | | [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` | | [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` | | [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` | diff --git a/docs/design/screens/integration-api-keys.md b/docs/design/screens/integration-api-keys.md new file mode 100644 index 0000000..d4760da --- /dev/null +++ b/docs/design/screens/integration-api-keys.md @@ -0,0 +1,58 @@ +# Integration: API keys + +Route: `/integration/api-keys`. Component: `ApiKeysPage.dc.html`, mounted by `AppFrame`. + +- **Visual reference:** `source/RenewCMS Screens API keys.dc.html`, group "API keys", drawn by `source/ApiKeysPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). + +## API +`POST /v1/api-key` · `GET /v1/api-key` · `DELETE /v1/api-key/{id}`. Access: Session only. An API key cannot manage keys; with a key these routes answer 403 "This action needs a real session." +- `POST /v1/api-key` body: `name` (string, required, max 60), `expires_at` (RFC3339 string, optional, in the future). A past date answers 400 "The expiry must be a date in the future." +- `DELETE /v1/api-key/{id}`: unknown id answers 404 "API key not found". + +## Placement +Under Integration, as a sub-page linked from the Authentication section ("Manage API keys") and the sidebar item. Keys exist to call the API and admins can create them; Settings is owner-only today. The mobile top bar has a back arrow that returns to Integration. + +## Content +- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own. +- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future." +- The Expiry field is a native date input. The displayed format follows the browser's locale, so there is no custom format. The board draws one example date only. +- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done". +- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked". +- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again." +- Note: "Resetting a user's password revokes all of their keys." + +## Mobile: pinned Create key +On mobile the "Create key" button is pinned to the bottom of the viewport (44 px high, full width, on a bar with a top border). It shows in Owner list, Admin own keys only, Loading, Empty, Error and Revoked, and is hidden behind every modal state. In Empty it replaces the inline button. The content has bottom padding so nothing sits under the bar, and the toast sits above it. Desktop keeps "Create key" in the page header (and in the Empty card). + +## States + +Names are matched exactly by the visual-check script. + +### Owner: list + +### Admin: own keys only + +### Loading +the list request is in flight. Skeleton rows appear only after 150 ms. + +### Empty + +### Error +a 500 or network failure on the list request. If a key is created while this state is showing, the list reloads, the new key appears and the error alert is cleared. + +### Create key + +### Create key: validation + +### Create key: name too long + +### One-time reveal + +### One-time reveal: copied + +### Revoke: confirm + +### Revoked + +## Component inventory (Nuxt UI v4) +`UTable`, `UBadge`, `UModal` with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, `useClipboard`. diff --git a/docs/design/screens/integration.md b/docs/design/screens/integration.md index 0d44ff0..2e92bc3 100644 --- a/docs/design/screens/integration.md +++ b/docs/design/screens/integration.md @@ -1,6 +1,8 @@ -# Integration (API reference) and API keys +# Integration (API reference) -Routes: `/integration` and `/integration/api-keys`. Boards: `RenewCMS Screens Back-office.dc.html` (group "Integration") and `RenewCMS Screens API keys.dc.html`. Components: `IntegrationPage.dc.html`, `ApiKeysPage.dc.html`, mounted by `AppFrame`. +Route: `/integration`. Component: `IntegrationPage.dc.html`, mounted by `AppFrame`. The API keys sub-page is in `screens/integration-api-keys.md`. + +- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration", drawn by `source/IntegrationPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). ## Navigation decision Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position. @@ -13,9 +15,9 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere - Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeBlock tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys". - Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified." - Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header." -- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."" -- Errors: table 400, 401, 403, 404, 409, 422, 429, 500 plus the JSON body. -- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=html` or `?format=markdown` to choose how `body` is returned." +- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."" +- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`). +- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default." ## EndpointCard states - Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips). @@ -24,27 +26,57 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere - Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted). - Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only. -## Board states (desktop 1440×900, mobile 390×844, light and dark) -1 Default · 2 Media group, POST /v1/media/ expanded with curl tab · 3a Base URL copied · 3b CodeBlock copied · 4 Authentication · 5 Session only · 6 Errors · 7 Navigation (index active item Media; mobile select open). +## States +Desktop 1440×900, mobile 390×844, light and dark. Names are matched exactly by the visual-check script. -## API keys screen -Placement: under Integration (sub-page), because keys exist to call the API and admins can create them; Settings is owner-only today. Session only. -- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own. -- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future." -- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done". -- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked". -- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again." -- Note: "Resetting a user's password revokes all of their keys." +### 1. Default + +### 2. Group scrolled, one card expanded + +### 3a. Copied: base URL + +### 3b. Copied: CodeBlock + +### 4. Authentication + +### 5. Session only + +### 6. Errors + +### 7. Navigation + +## Error messages +Plain text, one sentence. Used in the table and in the per-endpoint error captions. +- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future." +- 401: "You must be logged in to access this resource." +- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource." +- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found" +- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`. +- 500: "Internal server error" +- 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table. +- 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known. + +## Request bodies +- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, required, max 10000). +- `PATCH /v1/article/{id}`: `body` (string, required, max 10000). +- `PATCH /v1/media/{id}` (rename): `filename` (string, required). +- `POST /v1/media/merge`: `keep_id` (number, required, > 0), `merge_ids` (number[], required, at least 1, each > 0). +- `POST /v1/media/`: multipart/form-data, 1 file field (shown as "multipart/form-data, 1 file field"). +- `PATCH /v1/user/me`: `display_name` (string, required, max 60 characters). +- `POST /v1/user/invite`: `email` (string, required, valid email). +- `POST /v1/system/update/restart`: optional body `{ "kind": "update" | "restart" }`. ## Component inventory and Nuxt UI v4 - EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), `UTabs` (fetch, curl, Response), CodeBlock. - SectionIndex (new): custom `