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.