Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md
2026-09-23 12:53:09 +02:00

24 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created, reviewed_at
phase slug status shadcn_initialized preset created reviewed_at
8 oauth2-1-authorization-server approved true shadcn-vue new-york; zinc base; CSS variables; Tailwind v4; Lucide 2026-09-23 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.


Entry and Authentication

  1. The server sends successful /oauth/mcp/authorize requests to <app URL>/connect?request=<opaque>; 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 <meta name="referrer" content="no-referrer">; 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:

{
  "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:

{
  "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