diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md b/.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md new file mode 100644 index 0000000..9ee6586 --- /dev/null +++ b/.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md @@ -0,0 +1,354 @@ +--- +phase: 8 +slug: oauth2-1-authorization-server +status: draft +shadcn_initialized: true +preset: "shadcn-vue new-york; zinc base; CSS variables; Tailwind v4; Lucide" +created: 2026-09-23 +--- + +# Phase 8 — UI Design Contract + +> Visual and interaction contract for the existing Nuxt OAuth consent and connected-app surfaces. Generated by gsd-ui-researcher, verified by gsd-ui-checker. + +## Scope and Non-Redesign Rule + +Phase 8 changes the Go backend only. The existing `vue-fonoteka-app` remains byte-for-byte unchanged. This document therefore specifies the frontend states and payloads the backend must preserve; it does not authorize new components, restyling, copy changes, new routes, or changes to the Nuxt store/composable. + +In scope: + +- localized `/polacz?request=…` and `/en/connect?request=…` consent screen; +- Settings → Integrations connected-app list and revoke interaction; +- loading, invalid/expired, error, empty, populated, allow, deny, and revoke states; +- the API response/status behavior that selects those existing states. + +Out of scope: + +- redesigning either surface; +- adding a loading skeleton, countdown, retry control, collection selector, or app logo; +- displaying `offline_access`, raw redirect URIs, client IDs, token values, refresh tokens, or request handles; +- changing the Nuxt app, MCP server, social-login account screen, or personal-token manager. + +Sources of truth, in descending order: `08-CONTEXT.md` decisions D-08/D-12/D-16/D-20, the existing Nuxt files (`connect.vue`, `settings.vue`, `ConsentScopePicker.vue`, `ConnectedAppsManager.vue`, store/composable/types/locales), and the PHP consent/connected-app controllers. + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | shadcn-vue, already initialized in the unchanged Nuxt repository | +| Preset | `new-york`; `baseColor: zinc`; CSS variables; no prefix; square radius (`--radius: 0`) | +| Component library | Reka UI primitives through local shadcn-vue components | +| Icon library | Lucide; `Trash2` is the only phase-specific icon | +| Fonts | IBM Plex Sans for app copy, IBM Plex Mono for redirect host, Instrument Serif only where the existing shared `EmptyState`/brand uses it | +| Theme | forced dark only; no theme switcher or light palette | +| Styling | Tailwind CSS v4 CSS-first tokens in `app/assets/css/main.css` | + +The shadcn CLI binary is not installed in the Nuxt checkout; the checked-in `components.json` and vendored component source were inspected directly. Since D-08 locks the Nuxt client as unchanged, there is no preset confirmation/override decision to make. + +### Existing Component Inventory + +| Surface | Existing components | Contract | +|---------|---------------------|----------| +| Consent | public layout, `Button`, `ConsentScopePicker` | One centered decision card, no authenticated app chrome inside the page, no modal, all requested data scopes selected initially | +| Connected apps | Settings tab shell, `Card`, shared `EmptyState`, `Button`, Reka `Dialog` | Sibling section after `TokenManager`; card list when populated; confirmation dialog before revoke | +| Feedback | inline semantic text, disabled button states, `vue-sonner` toasts | Reads use inline states; mutations use existing generic failure toast and revoke-success toast; no new notification pattern | + +--- + +## Spacing Scale + +Declared values (all multiples of 4): + +| Token | Value | Usage | +|-------|-------|-------| +| xs | 4px | Tight inline separation and checkbox alignment | +| sm | 8px | Button/list gaps and compact card padding | +| md | 16px | Consent page horizontal padding, default element spacing | +| lg | 24px | Consent card/dialog padding and section rhythm | +| xl | 32px | Reserved layout gap; inherited app shell only | +| 2xl | 48px | Contextual empty-state vertical padding | +| 3xl | 64px | Reserved page-level separation; inherited app shell only | + +Exceptions that are already implemented and must be preserved rather than normalized: + +- interactive consent buttons, scope rows, settings tabs, and revoke icon button have a minimum 44px target; +- the app shell uses 20px mobile and 28px desktop horizontal padding; +- consent page uses 48px vertical padding and a 24px card rhythm; +- the connected-app scope badges retain their existing compact Tailwind `gap-1.5`; it is an inherited component detail, not a new phase spacing token. + +--- + +## Typography + +The phase declares four app roles and exactly two design weights. Existing shared components remain unchanged even where their vendored utilities use `font-medium` or the display font. + +| Role | Size | Weight | Line Height | +|------|------|--------|-------------| +| Meta | 12px | 400 | 1.5 | +| Body / control | 14px | 400 | 1.5 | +| Page/card heading | 16px | 600 | 1.2 | +| Section heading | 20px | 600 | 1.2 | + +Additional locked rendering details: + +- `redirect_host` uses IBM Plex Mono at the body size so the destination domain remains visually distinct; +- the untrusted application name is rendered as the existing quotation treatment (`text-lg font-medium`) with a disclaimer, never as trusted branding; +- the shared connected-app empty state keeps its existing Instrument Serif title treatment; +- headings use tight tracking; body, hint, date, and error copy use IBM Plex Sans. + +--- + +## Color + +The implementation uses exact OKLCH semantic tokens, not hard-coded hex colors. + +| Role | Value | Usage | +|------|-------|-------| +| Dominant (60%) | `--canvas: oklch(0.13 0.008 60)` and `--background: oklch(0.155 0.008 60)` | Public/app ground and primary page surfaces | +| Secondary (30%) | `--card: oklch(0.172 0.009 60)`, `--muted: oklch(0.19 0.009 60)`, `--border: oklch(0.21 0.010 60)` | Consent card, connected-app card, scope rows, hover surfaces, borders | +| Accent (10%) | `--primary: oklch(0.80 0.145 68)` | Allow button fill, keyboard focus ring, active Settings tab underline only | +| Destructive | `--destructive: oklch(0.72 0.10 30)` | Empty-scope/error text and connected-app revoke affordance/confirmation only | + +Accent is reserved for the consent **Allow** action, active tab indicator, and focus visibility. **Deny** remains outline-styled because it cancels an authorization request without deleting account data. **Revoke** alone uses destructive styling because it immediately kills the access token and refresh lineage. + +--- + +## Copywriting Contract + +All copy is pre-existing and locked. The backend returns data only; it must not send display-ready prose for these surfaces. + +### Required Surface Copy + +| Element | English | Polish | +|---------|---------|--------| +| Consent title | Allow access | Zezwól na dostęp | +| Consent lead | A chat application is asking for access to your Płytarium collection. | Aplikacja czatowa prosi o dostęp do Twojej kolekcji w Płytarium. | +| Untrusted-name disclaimer | Name supplied by the application — not verified by Płytarium | Nazwa podana przez aplikację — nie jest weryfikowana przez Płytarium | +| Primary CTA | Allow | Zezwól | +| Secondary CTA | Deny | Odmów | +| Empty scope | Select at least one permission. | Wybierz co najmniej jedno uprawnienie. | +| Missing request | There is no valid access request. Return to the chat application and connect Płytarium again. | Brak ważnego żądania dostępu. Wróć do aplikacji czatowej i połącz Płytarium ponownie. | +| Expired defensive state | This request expired or was already used. Return to the chat application and connect again. | To żądanie wygasło albo zostało już użyte. Wróć do aplikacji czatowej i połącz ponownie. | +| Consent load error | Could not load the access request. Try again. | Nie udało się wczytać prośby o dostęp. Spróbuj ponownie. | +| Connected-app heading | Connected applications | Połączone aplikacje | +| Empty state | You have no connected chat applications yet. | Nie masz jeszcze połączonych aplikacji czatowych. | +| Connected-app load error | Could not load connected applications. Try again. | Nie udało się wczytać połączonych aplikacji. Spróbuj ponownie. | +| Revoke title | Revoke {name}? | Odwołać {name}? | +| Revoke confirmation | This application will immediately lose access to the collection. This cannot be undone. | Ta aplikacja natychmiast straci dostęp do kolekcji. Tej operacji nie można cofnąć. | +| Revoke success | Access revoked | Dostęp odwołany | + +Scope labels and explanations remain the existing localized `Read`/`Write`/`AI` copy. `client_name`, collection names, email, dates, and counts are data substitutions and must be rendered through Vue interpolation, never as HTML. + +--- + +## Consent Screen Contract + +### Entry and Authentication + +1. The server sends successful `/oauth/mcp/authorize` requests to `/connect?request=`; Nuxt localizes this to `/polacz` (PL) or `/en/connect` (EN). +2. The query value is accepted only when it matches `^[A-Za-z0-9_-]{16,128}$`. Missing, repeated-first-invalid, malformed, or non-string values make `requestId` null and issue no consent API request. +3. The page uses the existing UX auth middleware. A logged-out visitor goes to localized login with the validated consent path as the return target. The backend JWT group remains the real authorization boundary. +4. The page sets `Referrer-Policy` via ``; the opaque handle must not leak to the registered redirect host, logs, or analytics. + +### Read Payload + +`GET /_fonoteka/api/v1/oauth/request/{request_id}` must return: + +```json +{ + "data": { + "client_name": "string, control characters removed, maximum 120 characters", + "redirect_host": "host only, never the full redirect URI", + "scopes_requested": ["read", "write", "ai"], + "collection_name": "server-resolved active collection name", + "expires_at": "ISO 8601 timestamp" + } +} +``` + +Contract details: + +- `scopes_requested` contains only the ordered, mintable intersection of `read`, `write`, and `ai`; `offline_access` is protocol state and is never displayed or submitted by this picker; +- the UI initially selects every returned scope and offers only those returned scopes, in canonical read → write → ai order; +- `collection_name` is display-only and carries no collection ID; consent pins collection IDs server-side using the active collection resolver; +- `expires_at` remains part of the exact payload although the current screen does not render a countdown; +- `client_name` is untrusted quotation text, HTML-escaped by Vue and paired with the explicit unverified-name disclaimer. + +### State Matrix + +| State | Trigger | Existing rendering | Backend obligation | +|-------|---------|--------------------|--------------------| +| Awaiting read | top-level awaited `useFetch` is unresolved | Nuxt suspense/layout owns the wait; no phase-specific skeleton or spinner | Respond within the existing client timeout; do not invent a partial payload | +| Missing/invalid handle | query absent or fails the closed pattern | centered title + `connect.missing`; no card and no API call | None; malformed handles must never be echoed into a request | +| Loaded | HTTP 200 with exact `data` object | centered max-width card showing app name, host, collection, scopes, acting email, and two actions | Return every required field with stable types | +| Empty selected scopes | user clears the last offered scope | destructive inline `scopeRequired`; Allow disabled; Deny remains enabled | Server independently rejects an empty/no-longer-grantable intersection with exact 422 `{"error":"No grantable scopes"}` | +| Missing/stale/used/expired/foreign | exact backend 404 `{"error":"Request not found"}` | current `useFetch` enters error status and shows `connect.loadError`; the separate `expired` branch is only a defensive success-with-null fallback | Preserve the parity 404; do not change it to 200/null merely to select different copy | +| Other read failure | network, timeout, 5xx, or malformed payload | centered title + `connect.loadError` | No house envelope or secret-bearing diagnostic text | +| Allow/deny pending | mutation begins | both actions disabled; no spinner or optimistic redirect | Single-use transition must be atomic; duplicate action cannot issue two outcomes | +| Mutation failure | 404/422/network/5xx | generic existing failure toast; buttons re-enable and screen stays in place | Return exact status/body and never expose code, verifier, request handle, or redirect internals in error text | + +### Scope Selection and Actions + +- Each scope is a native checkbox inside a full-width label with a 44px minimum target, label, and explanatory hint. +- Clearing every checkbox immediately shows the inline requirement and disables **Allow**. +- **Allow** sends JSON `{ "request_id": "…", "scopes": [/* selected subset */] }` to `POST /oauth/consent`. +- **Deny** sends JSON `{ "request_id": "…" }` to `POST /oauth/deny`; it does not require a selected scope. +- Both actions are guarded by the component-local `acting` flag and the store-wide `isLoading` flag. +- Successful actions return `{ "data": { "redirect_to": "…" } }`. The client performs `window.location.assign(redirect_to)`, not Vue-router navigation. The backend must construct this value exclusively from the exact registered redirect URI plus ordered RFC3986 parameters. +- Allow success appends `code`, `iss`, then optional `state`. Deny success appends `error=access_denied`, `iss`, then optional `state`. +- A missing/blank `redirect_to` leaves the user on the page; therefore every successful consent/deny response must include a non-empty value. + +### Responsive and Layout Behavior + +- Page container: centered, full width, max `32rem`, minimum 70vh, 16px horizontal and 48px vertical padding. +- Consent card: square corners, border, card surface, subtle shadow, 24px padding and vertical rhythm. +- Actions stack vertically on narrow viewports; at `sm` (640px) they become a right-aligned row with Deny first and Allow second. +- Long app names remain text, not a logo. Redirect host uses monospace and must contain only the host. The card must not gain horizontal scrolling from backend data. + +--- + +## Connected Applications Contract + +### Placement and Read Payload + +The existing Settings → Integrations tab renders `TokenManager` followed by `ConnectedAppsManager`. Phase 8 must not add a new settings tab or merge OAuth apps into the hand-minted token list. + +`GET /_fonoteka/api/v1/oauth/connected-apps` must return: + +```json +{ + "data": [ + { + "id": 123, + "name": "access-token name", + "client_name": "string", + "scopes": ["read", "write", "ai"], + "collection_ids": [7], + "collections": [{ "id": 7, "name": "Collection" }], + "last_used_at": null, + "last_used_ip": null, + "expires_at": "ISO 8601 timestamp or null", + "revoked_at": null, + "created_at": "ISO 8601 timestamp" + } + ], + "manual_tokens_count": 2 +} +``` + +Contract details: + +- include live, unrevoked OAuth access-token rows owned by the current user only, ordered newest first; +- exclude manual tokens from `data`; expose only their live count in `manual_tokens_count` for localized plural copy; +- each row is the existing positive allow-list from `serializeToken` plus sanitized `client_name`; the current component consumes `id`, `client_name`, `scopes`, `collections`, `last_used_at`, and `expires_at`, while parity retains the other listed serializer fields; +- never serialize raw access/refresh tokens, OAuth client IDs, client secrets, hashes, request IDs, redirect URIs, other users, or fields outside that positive allow-list; +- sanitize and truncate `client_name` exactly as on consent; +- collection names are comma-joined by the existing client; return `[]`, never `null`, if no collection is present; +- timestamps are ISO strings or null. The client uses locale `toLocaleDateString()` and renders null as localized “Never”; it does not display `created_at` today, but the type and parity payload retain it. + +### State Matrix + +| State | Existing rendering | Backend obligation | +|-------|--------------------|--------------------| +| Awaiting read | awaited settings data path; no phase-specific skeleton | Return one complete response; do not stream partial rows | +| Read failure | bordered destructive inline paragraph with localized retry copy | Fail with an appropriate status; no partial list, secrets, or ownership clues | +| Empty | shared centered `EmptyState`, 48px vertical padding, heading + body, no CTA | Return exact `data: []` and numeric `manual_tokens_count` | +| Populated | bordered card with divided rows | Preserve newest-first order and stable positive allow-list fields | +| Revoke dialog open | app name in title, irreversible-warning description, Cancel + destructive Revoke | The app name is untrusted text and must remain sanitized | +| Revoking | destructive action disabled; dialog remains open | Revoke token and complete refresh lineage atomically | +| Revoke success | dialog closes; “Access revoked” toast; connected-app and token reads refresh | Return 2xx with `{"data":{"revoked":true}}`; revoked app must disappear immediately from the next read | +| Revoke failure | existing generic error toast; dialog remains open; action re-enables | Missing, foreign, or manual-token IDs all return the same 404 `{"error":"Token not found"}` | + +### Row and Revoke Interaction + +- Each row is at least 44px high with muted hover feedback. +- `client_name` truncates visually in the row; its complete value remains available in the revoke dialog title and button accessible name. +- Scope badges wrap and use localized labels; unknown scopes must not be emitted because the client has no translation contract for them. +- Collection names, Last used, and Expires appear in one muted metadata line and may wrap naturally on narrow screens. +- The icon-only `Trash2` button is 44×44px and has the localized accessible name `Revoke {client_name}?`. +- The Reka dialog traps focus, supports Escape/close behavior, restores focus, uses an 80% black overlay, fits within viewport width minus 32px, and caps at `32rem`. +- Dialog actions stack in reverse order on narrow screens and become a right-aligned row at `sm`. +- Revocation is destructive and irreversible from the UI. Cancel never calls the API. + +--- + +## Accessibility Contract + +- Keep one `h1` on consent and the existing Settings page heading hierarchy (`h1` page, `h2` connected applications). +- Native checkbox inputs remain associated through wrapping labels; every scope row has a 44px minimum target. +- All keyboard-focusable controls retain the global 2px primary focus outline with 2px offset; disabled controls use native disabled semantics and cannot receive pointer actions. +- The settings tab list retains its `aria-label`; revoke remains a button with a dynamic localized `aria-label` rather than an unlabeled icon. +- Dialog title and description remain Reka primitives so the modal has an accessible name/description and managed focus. +- Error information is conveyed by localized text in addition to destructive color. Do not introduce color-only scope or revocation states. +- Respect the existing global reduced-motion rule. No new animation is part of this phase. +- Untrusted client/collection strings are rendered with Vue interpolation only. Never provide or request `v-html` content. + +--- + +## Security-Sensitive Interaction Rules + +1. Never show or log the request handle, authorization code, PKCE challenge/verifier, client secret, access token, or refresh token. +2. Validate the registered redirect URI before creating the consent request. The browser receives only a host for display and a server-built `redirect_to` after a valid terminal action. +3. A pending handle is single-use, expires after the configured TTL, and may be bound/consumed by only the correct authenticated user. +4. Submitted scopes are untrusted. Grant only `submitted ∩ originally requested ∩ client ceiling ∩ MINTABLE_SCOPES`; derive collection IDs server-side. +5. Client and collection names are untrusted plain text. Strip control characters server-side, cap client names at 120 characters, and rely on Vue escaping. +6. A foreign/missing consent request and a foreign/missing/manual connected-app ID use indistinguishable 404 responses. +7. Revoke kills the access token and the entire refresh lineage in one committed transaction before the UI reports success. +8. Do not add the MCP resource server's RFC 9728 challenge or metadata to these backend UI endpoints; D-12 keeps that concern in `fonoteka-mcp`. + +--- + +## Verification Contract + +The planner/executor must prove backend compatibility without editing Nuxt source: + +- invalid query handle causes no `oauth/request` request; +- logged-out consent entry returns through the closed safe-return-path allow-list after login; +- 200 request payload renders app name, host, active collection, all returned scopes selected, and acting email; +- zero selected scopes disables Allow while Deny still works; +- stale/used/expired/foreign request produces exact 404 and existing error state; +- Allow and Deny each produce one full-page redirect using the server response; +- connected-app reads cover error, empty, manual-count plural, and populated states; +- revoke covers cancel, pending disabled state, success refresh/toast, failure retention, and identical 404 for foreign/manual IDs; +- keyboard traversal, focus visibility, dialog focus management, 44px targets, and mobile button stacking remain unchanged; +- English and Polish copy keys resolve; no raw i18n key appears; +- the Nuxt repository has no Phase 8 diff after backend implementation. + +--- + +## Registry Safety + +| Registry | Blocks Used | Safety Gate | +|----------|-------------|-------------| +| shadcn-vue official/local | Existing `Button`, `Card`, `Dialog` only | checked-in source and `components.json` inspected — no new block — 2026-09-23 | +| Third-party registries | none (`registries: {}`) | no third-party registry declared — 2026-09-23 | + +No registry installation, network fetch, or component generation is authorized by this phase. + +--- + +## Provenance + +| Source | Decisions Used | +|--------|----------------| +| `08-CONTEXT.md` | Phase boundary; unchanged Nuxt; app-owned consent/connected-app shapes; exact redirects; security and parity rules | +| `08-RESEARCH.md` | Exact API matrix; scope/tenant rules; stale/foreign behavior; lifecycle and verification risks | +| `REQUIREMENTS.md` | AUTH-05, AUTH-06, AUTH-07 and unchanged-client acceptance | +| Nuxt source | All layout, state, typography, color, spacing, copy, accessibility, responsive, and store-refresh behavior | +| PHP source | Exact payloads, status codes, sanitization, ownership, and atomic revoke semantics | +| User input | No new visual choices; prior locked decision to keep the Nuxt app unchanged | + +--- + +## Checker Sign-Off + +- [ ] Dimension 1 Copywriting: PASS +- [ ] Dimension 2 Visuals: PASS +- [ ] Dimension 3 Color: PASS +- [ ] Dimension 4 Typography: PASS +- [ ] Dimension 5 Spacing: PASS +- [ ] Dimension 6 Registry Safety: PASS + +**Approval:** pending