From d53e7e2985c385a9f4781ec743816a1747718bc6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 15:40:07 -0400 Subject: [PATCH] docs(spec): add web UI auth design Covers the login flow, session store, fetch wrapper, persistent Shell, and route guarding for the first frontend feature on top of the scaffold. Commits to TanStack Query as the data layer going forward. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-04-22-web-ui-auth-design.md | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-22-web-ui-auth-design.md diff --git a/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md b/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md new file mode 100644 index 00000000..4b4a54dc --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md @@ -0,0 +1,256 @@ +# Web UI Auth — Design Spec + +**Date:** 2026-04-22 +**Status:** Design approved +**Follows:** [Web UI Scaffold](2026-04-20-web-ui-scaffold-design.md) — this spec is the first SPA feature on top of the scaffold. + +## Goal + +Let a browser visitor sign in with a username and password, reach a protected app shell, and sign out again. After this lands, every subsequent sub-plan (library views, search, player) builds on top of a known-authenticated `user` store, a persistent Shell component, and a typed HTTP client. + +## Non-goals + +Explicit YAGNI — kept out of this plan so the scope stays tight: + +- No self-service sign-up or password reset. Users are admin-provisioned; a forgotten password is a DBA problem for now. +- No "remember me" / persistent-session checkbox. The server cookie already has a 30-day `MaxAge`; every login is long-lived. +- No OAuth / OIDC / third-party sign-in. Deferred (see memory — OIDC is pushed to post-v1). +- No multi-tab logout propagation via `BroadcastChannel`. If you log out in one tab, the other tab will discover it on the next API call (401 → silent logout). Good enough. +- No rate-limiting UX on the login form. The backend already 401s on bad creds; brute-force mitigation is a server-side concern tracked elsewhere. + +## Architecture + +Three layers stacked under the SvelteKit app: + +1. **Transport (`lib/api/`).** `apiFetch(path, init)` hits the relative `/api/*` origin, sets `Content-Type: application/json`, parses the JSON response, and throws a typed `ApiError{code, message, status}` on any non-2xx. A thin facade `api.get`, `api.post`, `api.del` wraps it. 401 responses trigger `auth.logout({silent: true})` before the error propagates. + +2. **Auth state (`lib/auth/`).** A Svelte 5 rune-based store — `let _user = $state(null)` plus helpers `bootstrap()`, `login(u, p)`, `logout()`. The store is the single synchronous source of truth for "is the current user authenticated, and if so who." + +3. **Query layer (`lib/query/`).** TanStack Query's Svelte 5 adapter. A `QueryClient` is instantiated once in the root layout and installed via ``. Components consume data with `createQuery({queryKey, queryFn: () => api.get(path)})`. `auth.logout()` calls `queryClient.clear()` so stale data doesn't leak to the next user. + +**Bootstrap timing is load-time, not render-time.** The root `+layout.ts` `load()` awaits `auth.bootstrap()` before SvelteKit renders, so `+layout.svelte` sees the store already populated. There is no "flash of login screen" on refresh. + +## File structure + +New files under `web/`: + +``` +src/ +├── lib/ +│ ├── api/ +│ │ ├── client.ts # apiFetch + api.{get,post,del} + ApiError + User/LoginResponse types +│ │ └── client.test.ts +│ ├── auth/ +│ │ ├── store.svelte.ts # $user rune, bootstrap/login/logout +│ │ └── store.test.ts +│ ├── query/ +│ │ └── client.ts # export const queryClient = new QueryClient({...}) +│ └── components/ +│ ├── Shell.svelte # header + sidebar +
slot +│ └── Shell.test.ts +├── routes/ +│ ├── +layout.ts # load() → await auth.bootstrap() +│ ├── +layout.svelte # QueryClientProvider + guard + Shell or bare +│ ├── +page.svelte # placeholder "Library" — replaced by the library plan +│ ├── login/ +│ │ ├── +page.svelte # LoginForm +│ │ └── +page.test.ts +│ ├── search/+page.svelte # "Coming soon" placeholder +│ └── playlists/+page.svelte # "Coming soon" placeholder +└── app.d.ts # (untouched) +``` + +Files that change: +- `web/package.json` — add `@tanstack/svelte-query`, `@testing-library/svelte`, `@testing-library/jest-dom`. +- `web/vitest.config.ts` — include `./vitest.setup.ts` to install jest-dom matchers. +- `web/src/routes/+page.svelte` — replace the scaffold placeholder with a bare "Library" placeholder that survives into the library plan. + +## Routes + +| Path | Guard | Component | +|---|---|---| +| `/login` | public; if already authenticated, navigate to `/` | `routes/login/+page.svelte` | +| `/` | guarded | Shell + `routes/+page.svelte` (Library placeholder) | +| `/search` | guarded | Shell + placeholder | +| `/playlists` | guarded | Shell + placeholder | +| (anything else) | guarded | Shell + whatever the URL resolves to, or 404 | + +The guard lives in `+layout.svelte` (not per-page `+page.ts`). On every render, it checks `user.value`. If `null` and path is not `/login`, it calls `goto('/login?returnTo=' + encodeURIComponent(path), {replaceState: true})`. If non-null and path is `/login`, it calls `goto('/', {replaceState: true})`. Otherwise it renders `` (authed) or `` (bare `/login`). + +## `apiFetch` contract + +```ts +// lib/api/client.ts +export type ApiError = { code: string; message: string; status: number }; +export type User = { id: string; username: string; is_admin: boolean }; +export type LoginResponse = { token: string; user: User }; + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + ...init, + }); + const body = res.status === 204 ? null : await res.json().catch(() => null); + if (!res.ok) { + if (res.status === 401) { + // Avoid a circular import: lazy-require the auth store. + const { logout } = await import('$lib/auth/store.svelte'); + logout({ silent: true }); + } + const err = (body && body.error) || { code: 'unknown', message: res.statusText }; + throw { ...err, status: res.status } as ApiError; + } + return body; +} + +export const api = { + get: (p: string) => apiFetch(p) as Promise, + post: (p: string, b: unknown) => + apiFetch(p, { method: 'POST', body: JSON.stringify(b) }) as Promise, + del: (p: string) => apiFetch(p, { method: 'DELETE' }) as Promise, +}; +``` + +Notes: +- `credentials: 'same-origin'` is correct for both the Vite proxy (dev) and the embedded production serve — both are same-origin. Cross-origin would need `'include'` plus a CORS policy, which the backend does not currently implement. +- `body.error` matches the backend's error envelope: `{"error":{"code":"...","message":"..."}}` (see `internal/api/errors.go`). Non-JSON error bodies degrade to `{code:'unknown', message: statusText}` — pragmatic. +- Lazy-importing `$lib/auth/store.svelte` inside the 401 branch avoids a circular import (`auth/store` imports `api`). + +## `auth` store contract + +```ts +// lib/auth/store.svelte.ts +import { api, type User, type LoginResponse } from '$lib/api/client'; +import { queryClient } from '$lib/query/client'; + +let _user = $state(null); +export const user = { get value() { return _user; } }; + +export async function bootstrap(): Promise { + try { _user = await api.get('/api/me'); } + catch { _user = null; } +} + +export async function login(username: string, password: string): Promise { + const res = await api.post('/api/auth/login', { username, password }); + _user = res.user; +} + +export async function logout(opts: { silent?: boolean } = {}): Promise { + if (!opts.silent) { + try { await api.post('/api/auth/logout', {}); } catch { /* best effort */ } + } + _user = null; + queryClient.clear(); +} +``` + +The `silent: true` path is used only by `apiFetch`'s 401 interceptor. Without it, a 401 response would trigger a logout POST that itself 401s — pointless round-trip. Everywhere else (the Shell's "Log out" button), `logout()` is called without flags and does the full round-trip. + +## Login page + +**Layout.** Centered card on the dark palette. Minstrel wordmark above the card. The card contains the form: label+input for username, label+input for password, a full-width "Sign in" button, and a slot for error text below the button. + +**Behavior.** + +1. Username and password are bound to local `$state` with `$state('')`. +2. On submit (button click or Enter in either field): + - Set `pending = true`. Disable the button and set `aria-busy="true"`. + - `await login(username, password)`. + - On success: read `returnTo` from `$page.url.searchParams`; validate it matches `/^\/(?!\/)/` (starts with exactly one `/`, not `//`, which would be a protocol-relative URL) AND does not start with `/login`; `goto(validatedReturnTo || '/', {replaceState: true})`. + - On `ApiError{code: 'invalid_credentials', status: 401}`: set `error = 'Invalid username or password.'`, clear password. + - On any other error: set `error = 'Sign-in unavailable. Try again in a moment.'`, leave fields intact. + - Finally: `pending = false`. +3. Enter in either input triggers form `submit`. +4. The error region has `role="alert"` so screen readers announce it on change. + +## Shell component + +**Structure.** + +``` +
+
+ Minstrel [username ▾] (dropdown has "Log out") +
+ +
{@render children()}
+
+``` + +- Sidebar hides below `md:`. (Mobile gets a hamburger button in a later plan — not this one.) +- Active nav item uses `aria-current="page"` so the active-styling CSS selector is semantic. +- The user-menu dropdown is a minimal disclosure: click to toggle, outside-click closes, Escape closes. Inside it is a single `` that calls `auth.logout()` then `goto('/login', {replaceState: true})`. + +## Error handling summary + +| Source | Shape | User sees | +|---|---|---| +| Login, bad creds | `ApiError{code:'invalid_credentials', status:401}` | Inline text under form: "Invalid username or password." | +| Login, server 500 / network | `ApiError{status:500}` or fetch rejection | Inline text: "Sign-in unavailable. Try again in a moment." | +| 401 mid-session on any API call | `ApiError{code:'unauthorized', status:401}` | Store clears; guard navigates to `/login?returnTo=`; no visible error | +| Non-2xx elsewhere | `ApiError` thrown into TanStack Query | TanStack Query `error` state; each consuming component renders its own UI (out of scope for this plan — the library plan handles it) | + +## Testing + +**Unit (Vitest, jsdom).** + +- `api/client.test.ts` — `fetch` stubbed via `vi.stubGlobal`. + - `api.get` resolves with parsed JSON on 200. + - `api.post` serializes body and sets `Content-Type`. + - Non-2xx throws `ApiError` with `code`, `message`, `status`. + - 401 calls `auth.logout({silent: true})` — spy on the dynamically-imported `logout` via module mocking. + - 204 resolves to `null` without calling `.json()`. + - JSON parse failure on an error body degrades to `{code:'unknown', message: statusText}`. + +- `auth/store.test.ts` — `api` module mocked. + - `bootstrap()` sets `user` on success; leaves `null` on rejection. + - `login()` stores the returned `user`. + - `logout()` calls `api.post('/api/auth/logout', {})`, clears `user`, calls `queryClient.clear()`. + - `logout({silent: true})` does NOT call `api.post`; still clears store and cache. + +- `components/Shell.test.ts` — render with a non-null `user`. + - Header shows `user.username`. + - Sidebar has exactly three `` elements to `/`, `/search`, `/playlists`. + - When `$page.url.pathname === '/'`, the Library link has `aria-current="page"`. + - Clicking the user menu reveals "Log out"; outside-click hides it. + +**Component test — `routes/login/+page.test.ts`** (using `@testing-library/svelte`). +- Renders two inputs + submit button. +- Invalid creds: stub `api.post` to reject with `{code:'invalid_credentials', status:401}`; submit; assert inline "Invalid username or password." is shown; password input is empty; username input is preserved. +- Server error: stub to reject with `{status:500}`; assert generic error message; both inputs preserved. +- Success: stub to resolve; set `?returnTo=/artists/abc` in `$page.url`; assert `goto` called with `/artists/abc` and `{replaceState: true}`. +- Invalid `returnTo` variants: `http://evil.com`, `//evil.com`, `/login` → each should fall back to `goto('/')`, never the original value. +- Loading: during a pending promise, `aria-busy="true"` on the button and button is disabled. + +**Manual integration (Plan Task 8).** +- `npm run dev` with backend running on `:4533`. Seed a user via the existing auth plan's flow. +- Visit `/artists/abc` → redirected to `/login?returnTo=%2Fartists%2Fabc`. +- Sign in → land on `/artists/abc` (which 404s from SPA fallback — that's expected; library plan fixes it). +- Hard-refresh `/` while authenticated → Shell renders immediately, no login flash. +- Click user menu → "Log out" → redirected to `/login`. Visit `/` → redirected back to `/login`. +- In a second tab, also authenticated: open dev tools, delete the `minstrel_session` cookie manually, navigate in the SPA → auto-redirected to `/login`. + +## Dependencies added + +| Package | Kind | Why | +|---|---|---| +| `@tanstack/svelte-query` | runtime | Data-layer caching/invalidation per design section 1. | +| `@testing-library/svelte` | dev | Component test rendering. | +| `@testing-library/jest-dom` | dev | `toBeInTheDocument`, `toHaveAttribute`, etc. | + +Versions pinned to latest Svelte-5-compatible majors at plan-writing time. + +## Success criteria + +- A user can sign in with valid creds, see the Shell, navigate to `/`, `/search`, `/playlists`, and log out. +- Deep link `/artists/` while signed out lands on `/login?returnTo=`; after login, lands on ``. +- Refreshing any guarded page while authenticated renders immediately with no login flash. +- A 401 on any API call silently clears auth and redirects to `/login`. +- All unit and component tests pass in CI (`npm test` in the `web/` directory). +- No backend changes required; the plan is pure frontend.