From e17207a00dde866e4c3439fd04ff19ada457f12e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 02:06:43 +0200 Subject: [PATCH] feat(16.3-02): dialog rules smoke tests and My account API tokens docs Cover scope *, 422 focus, clipboard copy/fallback, close-without-copy guard, and document the SPA tokens card on My account. Co-authored-by: Cursor --- admin/tests/account/ApiTokensPanel.test.ts | 102 ++++++++++++++++++++- docs/backend/admin-spa.md | 2 +- docs/backend/users-and-permissions.md | 1 + 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/admin/tests/account/ApiTokensPanel.test.ts b/admin/tests/account/ApiTokensPanel.test.ts index cbfd1b0..33b2694 100644 --- a/admin/tests/account/ApiTokensPanel.test.ts +++ b/admin/tests/account/ApiTokensPanel.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises } from '@vue/test-utils' import { toasts } from '../../src/state/useToasts' -import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers' +import { API, keydown, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers' const TOKENS = `${API}/account/tokens` const SCOPES = `${API}/account/tokens/scopes` @@ -224,4 +224,104 @@ describe('ApiTokensPanel', () => { expect(panel()!.querySelector('[data-token-row="11"] [data-action="revoke-token"]')).toBeTruthy() }) }) + + describe('dialog rules', () => { + async function openCreateDialog(routes: Record = {}) { + const mounted = await openAccount(routes) + await click('[data-action="create-token"]', panel()!) + await flushPromises() + return mounted + } + + async function fillName(value: string): Promise { + const nameInput = dialog()!.querySelector('#api-token-name')! + nameInput.value = value + nameInput.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + } + + it('disables other scopes when * is checked and posts only ["*"]', async () => { + const { calls } = await openCreateDialog() + await fillName('Full') + const star = dialog()!.querySelector('[data-scope="*"] input')! + star.checked = true + star.dispatchEvent(new Event('change', { bubbles: true })) + await flushPromises() + expect(dialog()!.querySelector('[data-scope="admin:read"] input')!.disabled).toBe(true) + expect(dialog()!.textContent).toContain('Pełny dostęp obejmuje wszystkie pozostałe zakresy.') + await click('[data-action="create-token-submit"]', dialog()!) + const [post] = requestsTo(calls, 'POST', TOKENS) + expect(await post!.clone().json()).toEqual({ name: 'Full', expires: '90d', scopes: ['*'] }) + }) + + it('renders a 422 on scopes and focuses the first scope checkbox', async () => { + await openCreateDialog({ + [`POST ${TOKENS}`]: { + status: 422, + body: { + error: { + code: 'validation_failed', + message: 'Validation failed', + details: { scopes: ['Wybierz co najmniej jeden zakres.'] }, + }, + }, + }, + }) + await fillName('No scope') + await click('[data-action="create-token-submit"]', dialog()!) + expect(dialog()!.textContent).toContain('Wybierz co najmniej jeden zakres.') + expect(document.activeElement).toBe(dialog()!.querySelector('[data-scope="*"] input')) + }) + + it('shows Copied on clipboard success and copy_failed on rejection', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + await openCreateDialog() + await fillName('Copy me') + const scope = dialog()!.querySelector('[data-scope="admin:read"] input')! + scope.checked = true + scope.dispatchEvent(new Event('change', { bubbles: true })) + await flushPromises() + await click('[data-action="create-token-submit"]', dialog()!) + await click('[data-action="copy-token"]', dialog()!) + expect(writeText).toHaveBeenCalledWith(createdToken.token) + expect(dialog()!.querySelector('[data-action="copy-token"]')!.textContent).toContain('Skopiowano') + expect(dialog()!.querySelector('[aria-live="polite"]')!.textContent).toContain( + 'Skopiowano token do schowka.', + ) + + writeText.mockRejectedValueOnce(new Error('denied')) + await click('[data-action="copy-token"]', dialog()!) + expect(dialog()!.querySelector('[role="alert"]')!.textContent).toContain( + 'Nie udało się skopiować automatycznie', + ) + expect(document.activeElement).toBe(dialog()!.querySelector('[data-token-plaintext]')) + }) + + it('asks before closing step 2 without a copy and keeps the step on cancel', async () => { + await openCreateDialog() + await fillName('Guard') + const scope = dialog()!.querySelector('[data-scope="admin:read"] input')! + scope.checked = true + scope.dispatchEvent(new Event('change', { bubbles: true })) + await flushPromises() + await click('[data-action="create-token-submit"]', dialog()!) + expect(dialog()!.querySelector('[data-token-plaintext]')).toBeTruthy() + keydown(dialog()!, 'Escape') + await flushPromises() + // Reka Escape may be handled as escape-key-down; fall back to the close button. + if (!confirmDialog()) { + await click('[data-action="close"]', dialog()!) + } + expect(confirmDialog()).toBeTruthy() + expect(confirmDialog()!.textContent).toContain('Zamknąć bez kopiowania') + await click('[data-action="cancel"]', confirmDialog()!) + expect(dialog()!.querySelector('[data-token-plaintext]')!.value).toBe( + createdToken.token, + ) + }) + }) }) diff --git a/docs/backend/admin-spa.md b/docs/backend/admin-spa.md index 03aba6f..a3dd5dd 100644 --- a/docs/backend/admin-spa.md +++ b/docs/backend/admin-spa.md @@ -33,7 +33,7 @@ A list row that carries a state (deleted, negative or disabled) shows a text bad A form whose schema carries `preview` has a read-only record screen at `//preview`, next to the list (``), the create form (`/create`) and the update form (`/`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen). -The user menu in the top bar has a My account item that opens `/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, and changes the password when a new one is typed together with the current one. Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See [My account](users-and-permissions.md#my-account). +The user menu in the top bar has a My account item that opens `/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, changes the password when a new one is typed together with the current one, and manages personal API tokens (list, create with a show-once plaintext, revoke, show revoked). Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See [My account](users-and-permissions.md#my-account) and [API tokens](users-and-permissions.md#api-tokens). ## Types from OpenAPI diff --git a/docs/backend/users-and-permissions.md b/docs/backend/users-and-permissions.md index 85be03d..a7300c4 100644 --- a/docs/backend/users-and-permissions.md +++ b/docs/backend/users-and-permissions.md @@ -140,6 +140,7 @@ Every signed-in administrator can edit their own profile on the My account scree - **Password:** leave it blank to keep the current one. A new password needs `current_password`, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal `password_confirmation`. A wrong current password answers 422 on `current_password` and is written to the authentication log. A changed password is hashed at `admin.password.bcrypt_cost` and ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again. - **Subtitle:** a short line such as a role or position. It is stored where WinterCMS keeps it, under the `subtitle` key of the jsonable `backend_users.metadata` column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as no subtitle. `GET /api/v1/auth/me` returns it as `subtitle`. - **Avatar:** `POST /api/v1/account/avatar` takes one multipart part named `file_data`. Only jpg, jpeg, png, gif and webp images that pass the image check are accepted, up to `cabana.AvatarMaxBytes` (2 MiB) and within `http.body_limits.upload_bytes`; anything else answers 422 on `avatar`, and a body over the cap answers 413. The file is stored under a name the server generates, never the uploaded file name. An upload replaces the previous avatar: the old row is deleted with the new one's save and its files right after. `DELETE /api/v1/account/avatar` removes the avatar and answers 200 also when there is none. The avatar is a public attachOne in `system_files` with attachment type `Backend\Models\User` (`cabana.BackendUserMorphType`) and field `avatar` (`cabana.BackendUserAvatarField`), exactly as WinterCMS stores it, so avatar rows copied from WinterCMS work as they are. `GET .../auth/me` returns its `url` and a 250x250 crop `thumb_url`, and the top bar shows it in place of the initials. +- **API tokens:** a card below the profile form (never inside it — the page Save does not touch tokens) lists, creates and revokes only the signed-in administrator's own tokens. Creating asks for a name, an expiry (`30d`, `90d`, `365d` or `never`) and scopes from `GET /account/tokens/scopes`; the plaintext is shown once in the dialog for copying, then cleared. Revoke asks for confirmation. A "Show revoked tokens" toggle reloads the list with revoked rows. See [API tokens](#api-tokens) for the wire contract and scopes. Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.