--- phase: 8 slug: oauth2-1-authorization-server status: approved shadcn_initialized: true preset: "shadcn-vue new-york; zinc base; CSS variables; Tailwind v4; Lucide" created: 2026-09-23 reviewed_at: 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 | The Phase 8 spacing scale is exactly the seven values above. The 44px minimum interactive target is an accessibility dimension, not a spacing token. The unchanged app shell's responsive horizontal padding and the connected-app scope badges' compact inherited gap are pre-existing, out-of-scope implementation observations; Phase 8 neither adopts them as tokens nor authorizes normalizing or editing them. The consent page uses only declared 48px vertical padding and 24px card rhythm. --- ## Typography Phase 8 owns exactly the four sizes and two weights declared below. Existing shared components remain unchanged; their inherited rendering is implementation observation, not an extension of this phase's typography scale. | 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 keeps the existing emphasized quotation treatment with a disclaimer, never as trusted branding; its inherited utility classes are out-of-scope observations, not Phase 8 size or weight tokens; - the shared connected-app empty state keeps its existing Instrument Serif title treatment; that immutable shared-component rendering does not add a Phase 8 size or weight; - headings use tight tracking; body, hint, date, and error copy use IBM Plex Sans. No additional font size or weight is adopted or requested, and this contract does not authorize a Nuxt source change to reconcile inherited component utilities with the phase-owned scale. --- ## 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ąć. | | Dialog cancel action | Cancel | Anuluj | | Destructive CTA | Revoke | Odwołaj | | 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. **Locked legacy CTA exception:** The exact unchanged labels **Allow**, **Deny**, **Revoke**, and **Cancel** (with their existing Polish translations above) are narrow exceptions to the UI-gate preference for verb-plus-noun action copy. **Allow** and **Deny** sit under the access-request title, explanatory lead, requested scopes, and destination context; **Revoke** sits in a destructive confirmation dialog named with the application and described by the irreversible consequence; **Cancel** is the non-destructive action in that same named dialog and closes it without an API call. Button styling, dialog semantics, focus management, visible surrounding copy, and the dynamic `Revoke {client_name}?` accessible name supply the action context. These are immutable legacy labels, not precedent for new copy, and Phase 8 does not authorize renaming them or making any Nuxt change. --- ## 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