fe410f14eb
Renamed references from +page.test.ts to login.test.ts throughout,
matching the fix in 8896159. Keeps the plan accurate for re-runs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1246 lines
38 KiB
Markdown
1246 lines
38 KiB
Markdown
# Web UI Auth Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Ship the browser-side auth flow — login page, session store, persistent Shell, and typed fetch wrapper — on top of the existing SvelteKit scaffold, so every subsequent frontend plan can assume a known-authenticated user and use `api.get<T>(path)` out of the box.
|
|
|
|
**Architecture:** Three layers under SvelteKit. `lib/api/client.ts` is a typed wrapper around `fetch` that parses JSON, throws typed `ApiError`, and silently logs the user out on 401. `lib/auth/store.svelte.ts` is a Svelte 5 rune-based store holding the current user, bootstrapped once from `/api/me` in the root `+layout.ts`. `lib/query/client.ts` is a TanStack Query `QueryClient` installed via `QueryClientProvider` at the root layout. Route guarding and shell rendering happen in `+layout.svelte`.
|
|
|
|
**Tech Stack:** SvelteKit 2 + Svelte 5 (runes), TypeScript, TanStack Query 5 (`@tanstack/svelte-query`), Vitest + jsdom + Testing Library (`@testing-library/svelte`, `@testing-library/jest-dom`), Tailwind.
|
|
|
|
**Reference:** design spec at `docs/superpowers/specs/2026-04-22-web-ui-auth-design.md`.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**New files under `web/`:**
|
|
|
|
| File | Responsibility |
|
|
|---|---|
|
|
| `src/lib/api/client.ts` | `apiFetch`, `api.{get,post,del}`, types `User`, `LoginResponse`, `ApiError` |
|
|
| `src/lib/api/client.test.ts` | Unit tests for the transport layer |
|
|
| `src/lib/auth/store.svelte.ts` | `$user` rune, `bootstrap`, `login`, `logout` |
|
|
| `src/lib/auth/store.test.ts` | Unit tests for auth store |
|
|
| `src/lib/query/client.ts` | Singleton `QueryClient` export |
|
|
| `src/lib/components/Shell.svelte` | Header + sidebar + main slot |
|
|
| `src/lib/components/Shell.test.ts` | Shell component tests |
|
|
| `src/routes/+layout.ts` | `load()` → `await bootstrap()` |
|
|
| `src/routes/login/+page.svelte` | Login form UI + behavior |
|
|
| `src/routes/login/login.test.ts` | Login page tests (note: NOT `+page.test.ts` — the `+` prefix would trigger SvelteKit's route walker) |
|
|
| `src/routes/search/+page.svelte` | "Coming soon" placeholder |
|
|
| `src/routes/playlists/+page.svelte` | "Coming soon" placeholder |
|
|
| `vitest.setup.ts` | jest-dom matcher registration |
|
|
|
|
**Modified files:**
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `web/package.json` | Add `@tanstack/svelte-query` (runtime) + `@testing-library/svelte`, `@testing-library/jest-dom` (dev) |
|
|
| `web/vitest.config.ts` | `setupFiles: ['./vitest.setup.ts']` |
|
|
| `web/src/routes/+layout.svelte` | Mount `QueryClientProvider`, run route guard, render `<Shell>` or bare `<slot/>` |
|
|
| `web/src/routes/+page.svelte` | Replace scaffold text with a minimal "Library" placeholder |
|
|
|
|
---
|
|
|
|
## Task 1: Add dependencies + jest-dom setup
|
|
|
|
**Files:**
|
|
- Modify: `web/package.json`
|
|
- Modify: `web/vitest.config.ts`
|
|
- Create: `web/vitest.setup.ts`
|
|
|
|
- [ ] **Step 1: Install packages**
|
|
|
|
Run:
|
|
```bash
|
|
cd web && npm install --save @tanstack/svelte-query@^5 && npm install --save-dev @testing-library/svelte@^5 @testing-library/jest-dom@^6
|
|
```
|
|
|
|
Expected: three packages added to `package.json`; `package-lock.json` updated. No errors.
|
|
|
|
- [ ] **Step 2: Create `web/vitest.setup.ts`**
|
|
|
|
```ts
|
|
import '@testing-library/jest-dom/vitest';
|
|
```
|
|
|
|
- [ ] **Step 3: Update `web/vitest.config.ts`**
|
|
|
|
Replace the whole file with:
|
|
|
|
```ts
|
|
import { sveltekit } from '@sveltejs/kit/vite';
|
|
import { defineConfig } from 'vitest/config';
|
|
|
|
export default defineConfig({
|
|
plugins: [sveltekit()],
|
|
test: {
|
|
environment: 'jsdom',
|
|
include: ['src/**/*.test.ts'],
|
|
setupFiles: ['./vitest.setup.ts']
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Sanity-check the existing test still passes**
|
|
|
|
Run: `cd web && npm test`
|
|
Expected: PASS — `src/lib/example.test.ts (1 test)` passes. The new setup file doesn't break anything.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/package.json web/package-lock.json web/vitest.config.ts web/vitest.setup.ts
|
|
git commit -m "build(web): add TanStack Query + Testing Library deps
|
|
|
|
Wires @tanstack/svelte-query for the data layer and
|
|
@testing-library/{svelte,jest-dom} for component-level tests. Registers
|
|
the jest-dom matchers via a new vitest setup file."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Types and the `ApiError` shape
|
|
|
|
**Files:**
|
|
- Create: `web/src/lib/api/client.ts` (types only so far)
|
|
|
|
- [ ] **Step 1: Write `web/src/lib/api/client.ts` with types only**
|
|
|
|
```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;
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Verify tsc is happy**
|
|
|
|
Run: `cd web && npm run check`
|
|
Expected: `svelte-check found 0 errors and 0 warnings`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add web/src/lib/api/client.ts
|
|
git commit -m "feat(web): add api client types (User, LoginResponse, ApiError)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `apiFetch` happy path + error envelope
|
|
|
|
**Files:**
|
|
- Modify: `web/src/lib/api/client.ts`
|
|
- Create: `web/src/lib/api/client.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `web/src/lib/api/client.test.ts`:
|
|
|
|
```ts
|
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { apiFetch } from './client';
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
|
|
const res = new Response(
|
|
body === null ? null : JSON.stringify(body),
|
|
{ status, headers: { 'Content-Type': 'application/json' }, ...init }
|
|
);
|
|
const spy = vi.fn().mockResolvedValue(res);
|
|
vi.stubGlobal('fetch', spy);
|
|
return spy;
|
|
}
|
|
|
|
describe('apiFetch', () => {
|
|
test('resolves with parsed JSON on 200', async () => {
|
|
stubFetch(200, { hello: 'world' });
|
|
const result = await apiFetch('/api/ping');
|
|
expect(result).toEqual({ hello: 'world' });
|
|
});
|
|
|
|
test('sends Content-Type application/json by default', async () => {
|
|
const spy = stubFetch(200, {});
|
|
await apiFetch('/api/ping');
|
|
const init = spy.mock.calls[0][1] as RequestInit;
|
|
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
|
});
|
|
|
|
test('resolves to null on 204', async () => {
|
|
stubFetch(204, null);
|
|
const result = await apiFetch('/api/ping', { method: 'DELETE' });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('throws ApiError from error envelope on 4xx', async () => {
|
|
stubFetch(404, { error: { code: 'not_found', message: 'track not found' } });
|
|
await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({
|
|
code: 'not_found',
|
|
message: 'track not found',
|
|
status: 404
|
|
});
|
|
});
|
|
|
|
test('degrades to {code:unknown, message:statusText} on non-JSON error body', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
|
new Response('<html>500</html>', { status: 500, statusText: 'Internal Server Error' })
|
|
));
|
|
await expect(apiFetch('/api/ping')).rejects.toMatchObject({
|
|
code: 'unknown',
|
|
message: 'Internal Server Error',
|
|
status: 500
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test, confirm it fails**
|
|
|
|
Run: `cd web && npm test -- src/lib/api/client.test.ts`
|
|
Expected: FAIL — `apiFetch` not exported from `./client`.
|
|
|
|
- [ ] **Step 3: Implement `apiFetch`**
|
|
|
|
Append to `web/src/lib/api/client.ts`:
|
|
|
|
```ts
|
|
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
|
|
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) {
|
|
const envelope = body && (body as { error?: { code?: string; message?: string } }).error;
|
|
const err: ApiError = {
|
|
code: envelope?.code ?? 'unknown',
|
|
message: envelope?.message ?? res.statusText,
|
|
status: res.status
|
|
};
|
|
throw err;
|
|
}
|
|
return body;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the test again, confirm it passes**
|
|
|
|
Run: `cd web && npm test -- src/lib/api/client.test.ts`
|
|
Expected: PASS — 5 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts
|
|
git commit -m "feat(web): add apiFetch with typed error envelope
|
|
|
|
Wraps fetch, parses JSON, and throws ApiError{code,message,status}
|
|
on non-2xx. Degrades to {code:'unknown'} when the error body is not
|
|
the expected envelope shape."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: `api.get` / `api.post` / `api.del` facade
|
|
|
|
**Files:**
|
|
- Modify: `web/src/lib/api/client.ts`
|
|
- Modify: `web/src/lib/api/client.test.ts`
|
|
|
|
- [ ] **Step 1: Add failing tests**
|
|
|
|
Append inside `web/src/lib/api/client.test.ts`:
|
|
|
|
```ts
|
|
import { api } from './client';
|
|
|
|
describe('api.get/post/del', () => {
|
|
test('api.get returns typed JSON on 200', async () => {
|
|
stubFetch(200, { id: 'abc', name: 'A' });
|
|
type T = { id: string; name: string };
|
|
const result = await api.get<T>('/api/artists/abc');
|
|
expect(result).toEqual({ id: 'abc', name: 'A' });
|
|
});
|
|
|
|
test('api.post serializes body and sets method', async () => {
|
|
const spy = stubFetch(200, { ok: true });
|
|
await api.post('/api/auth/login', { username: 'u', password: 'p' });
|
|
const init = spy.mock.calls[0][1] as RequestInit;
|
|
expect(init.method).toBe('POST');
|
|
expect(init.body).toBe(JSON.stringify({ username: 'u', password: 'p' }));
|
|
});
|
|
|
|
test('api.del sends DELETE and resolves to null on 204', async () => {
|
|
const spy = stubFetch(204, null);
|
|
const result = await api.del('/api/sessions/xyz');
|
|
expect(spy.mock.calls[0][1]).toMatchObject({ method: 'DELETE' });
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests, confirm they fail**
|
|
|
|
Run: `cd web && npm test -- src/lib/api/client.test.ts`
|
|
Expected: FAIL — `api` not exported.
|
|
|
|
- [ ] **Step 3: Implement the facade**
|
|
|
|
Append to `web/src/lib/api/client.ts`:
|
|
|
|
```ts
|
|
export const api = {
|
|
get: <T>(path: string): Promise<T> => apiFetch(path) as Promise<T>,
|
|
post: <T>(path: string, body: unknown): Promise<T> =>
|
|
apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise<T>,
|
|
del: (path: string): Promise<null> =>
|
|
apiFetch(path, { method: 'DELETE' }) as Promise<null>
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, confirm they pass**
|
|
|
|
Run: `cd web && npm test -- src/lib/api/client.test.ts`
|
|
Expected: PASS — 8 tests total (5 from Task 3 + 3 new).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts
|
|
git commit -m "feat(web): add api.{get,post,del} typed facade over apiFetch"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: `QueryClient` singleton
|
|
|
|
**Files:**
|
|
- Create: `web/src/lib/query/client.ts`
|
|
|
|
- [ ] **Step 1: Write the module**
|
|
|
|
Create `web/src/lib/query/client.ts`:
|
|
|
|
```ts
|
|
import { QueryClient } from '@tanstack/svelte-query';
|
|
|
|
export const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
staleTime: 30_000,
|
|
refetchOnWindowFocus: false
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Verify it type-checks**
|
|
|
|
Run: `cd web && npm run check`
|
|
Expected: `svelte-check found 0 errors and 0 warnings`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add web/src/lib/query/client.ts
|
|
git commit -m "feat(web): add TanStack Query client singleton
|
|
|
|
Tuned defaults: retry disabled (we throw typed errors), 30s staleTime
|
|
to make tab-switch re-navigation feel instant, no refetchOnWindowFocus
|
|
(users on a music app don't expect spurious network when they tab back)."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: `auth` store — `bootstrap`, `login`, `logout`
|
|
|
|
**Files:**
|
|
- Create: `web/src/lib/auth/store.svelte.ts`
|
|
- Create: `web/src/lib/auth/store.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `web/src/lib/auth/store.test.ts`:
|
|
|
|
```ts
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
vi.mock('$lib/api/client', () => ({
|
|
api: {
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
del: vi.fn()
|
|
}
|
|
}));
|
|
|
|
vi.mock('$lib/query/client', () => ({
|
|
queryClient: { clear: vi.fn() }
|
|
}));
|
|
|
|
import { api } from '$lib/api/client';
|
|
import { queryClient } from '$lib/query/client';
|
|
import { bootstrap, login, logout, user } from './store.svelte';
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Force-clear the store between tests by stubbing api.get to reject,
|
|
// then calling bootstrap.
|
|
});
|
|
|
|
describe('auth store', () => {
|
|
test('bootstrap populates user on 200', async () => {
|
|
(api.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
id: '1', username: 'alice', is_admin: false
|
|
});
|
|
await bootstrap();
|
|
expect(user.value).toEqual({ id: '1', username: 'alice', is_admin: false });
|
|
});
|
|
|
|
test('bootstrap leaves user null on failure', async () => {
|
|
(api.get as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
{ code: 'unauthorized', message: 'no session', status: 401 }
|
|
);
|
|
await bootstrap();
|
|
expect(user.value).toBeNull();
|
|
});
|
|
|
|
test('login sets user from LoginResponse.user', async () => {
|
|
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
token: 't', user: { id: '2', username: 'bob', is_admin: true }
|
|
});
|
|
await login('bob', 'pw');
|
|
expect(user.value).toEqual({ id: '2', username: 'bob', is_admin: true });
|
|
expect(api.post).toHaveBeenCalledWith('/api/auth/login', {
|
|
username: 'bob', password: 'pw'
|
|
});
|
|
});
|
|
|
|
test('logout clears user, calls /api/auth/logout, and clears query cache', async () => {
|
|
(api.post as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
await logout();
|
|
expect(user.value).toBeNull();
|
|
expect(api.post).toHaveBeenCalledWith('/api/auth/logout', {});
|
|
expect(queryClient.clear).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('logout with silent:true skips the POST but still clears state', async () => {
|
|
await logout({ silent: true });
|
|
expect(user.value).toBeNull();
|
|
expect(api.post).not.toHaveBeenCalled();
|
|
expect(queryClient.clear).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('logout swallows POST errors (best-effort)', async () => {
|
|
(api.post as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
{ code: 'server_error', message: 'boom', status: 500 }
|
|
);
|
|
await expect(logout()).resolves.toBeUndefined();
|
|
expect(user.value).toBeNull();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests, confirm they fail**
|
|
|
|
Run: `cd web && npm test -- src/lib/auth/store.test.ts`
|
|
Expected: FAIL — module `./store.svelte` does not exist.
|
|
|
|
- [ ] **Step 3: Implement the store**
|
|
|
|
Create `web/src/lib/auth/store.svelte.ts`:
|
|
|
|
```ts
|
|
import { api, type User, type LoginResponse } from '$lib/api/client';
|
|
import { queryClient } from '$lib/query/client';
|
|
|
|
let _user = $state<User | null>(null);
|
|
|
|
export const user = {
|
|
get value(): User | null {
|
|
return _user;
|
|
}
|
|
};
|
|
|
|
export async function bootstrap(): Promise<void> {
|
|
try {
|
|
_user = await api.get<User>('/api/me');
|
|
} catch {
|
|
_user = null;
|
|
}
|
|
}
|
|
|
|
export async function login(username: string, password: string): Promise<void> {
|
|
const res = await api.post<LoginResponse>('/api/auth/login', { username, password });
|
|
_user = res.user;
|
|
}
|
|
|
|
export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
|
|
if (!opts.silent) {
|
|
try {
|
|
await api.post('/api/auth/logout', {});
|
|
} catch {
|
|
// best effort — server-side session may already be gone
|
|
}
|
|
}
|
|
_user = null;
|
|
queryClient.clear();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, confirm they pass**
|
|
|
|
Run: `cd web && npm test -- src/lib/auth/store.test.ts`
|
|
Expected: PASS — 6 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/src/lib/auth/store.svelte.ts web/src/lib/auth/store.test.ts
|
|
git commit -m "feat(web): add rune-based auth store with bootstrap/login/logout
|
|
|
|
Central synchronous source of truth for 'who is signed in'. bootstrap()
|
|
runs once in +layout.ts load(); login/logout mutate _user and the
|
|
TanStack query cache. The silent option on logout is used by the 401
|
|
interceptor (next commit) to avoid POSTing /logout against an already-
|
|
invalid session."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Wire 401 → `logout({silent:true})` into `apiFetch`
|
|
|
|
**Files:**
|
|
- Modify: `web/src/lib/api/client.ts`
|
|
- Modify: `web/src/lib/api/client.test.ts`
|
|
|
|
**Why a separate task:** the 401 interceptor requires `auth/store` to exist, and `auth/store` imports from `api/client`. To break the cycle, `apiFetch` uses a dynamic `import()` — introducing this logic only after the store is in place.
|
|
|
|
- [ ] **Step 1: Add the failing test**
|
|
|
|
Append inside `web/src/lib/api/client.test.ts`:
|
|
|
|
```ts
|
|
describe('apiFetch 401 interceptor', () => {
|
|
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
|
|
const logoutSpy = vi.fn();
|
|
vi.doMock('$lib/auth/store.svelte', () => ({
|
|
logout: logoutSpy,
|
|
login: vi.fn(),
|
|
bootstrap: vi.fn(),
|
|
user: { value: null }
|
|
}));
|
|
// Re-import to pick up the mock.
|
|
const { apiFetch: apiFetchFresh } = await import('./client');
|
|
stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } });
|
|
await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({
|
|
code: 'unauthorized',
|
|
status: 401
|
|
});
|
|
expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
|
|
vi.doUnmock('$lib/auth/store.svelte');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run, confirm the new test fails**
|
|
|
|
Run: `cd web && npm test -- src/lib/api/client.test.ts`
|
|
Expected: FAIL — `logoutSpy` not called.
|
|
|
|
- [ ] **Step 3: Add the interceptor in `apiFetch`**
|
|
|
|
Replace the `if (!res.ok)` block in `web/src/lib/api/client.ts` with:
|
|
|
|
```ts
|
|
if (!res.ok) {
|
|
if (res.status === 401) {
|
|
// Lazy import: auth/store imports from this file, so a top-level
|
|
// import would be circular. By the time any 401 actually happens
|
|
// at runtime, both modules have finished loading.
|
|
const { logout } = await import('$lib/auth/store.svelte');
|
|
await logout({ silent: true });
|
|
}
|
|
const envelope = body && (body as { error?: { code?: string; message?: string } }).error;
|
|
const err: ApiError = {
|
|
code: envelope?.code ?? 'unknown',
|
|
message: envelope?.message ?? res.statusText,
|
|
status: res.status
|
|
};
|
|
throw err;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run all client tests, confirm they pass**
|
|
|
|
Run: `cd web && npm test -- src/lib/api/client.test.ts`
|
|
Expected: PASS — 9 tests total.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts
|
|
git commit -m "feat(web): auto-logout on 401 inside apiFetch
|
|
|
|
Dynamic import breaks the apiFetch <-> auth/store cycle. silent:true
|
|
prevents re-POSTing /logout against an already-dead session."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Shell component
|
|
|
|
**Files:**
|
|
- Create: `web/src/lib/components/Shell.svelte`
|
|
- Create: `web/src/lib/components/Shell.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `web/src/lib/components/Shell.test.ts`:
|
|
|
|
```ts
|
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
|
|
vi.mock('$app/state', () => ({
|
|
page: { url: new URL('http://localhost/') }
|
|
}));
|
|
|
|
vi.mock('$app/navigation', () => ({
|
|
goto: vi.fn()
|
|
}));
|
|
|
|
vi.mock('$lib/auth/store.svelte', () => ({
|
|
user: { value: { id: '1', username: 'alice', is_admin: false } },
|
|
logout: vi.fn().mockResolvedValue(undefined)
|
|
}));
|
|
|
|
import Shell from './Shell.svelte';
|
|
import { logout } from '$lib/auth/store.svelte';
|
|
import { goto } from '$app/navigation';
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Shell', () => {
|
|
test('renders the username in the header', () => {
|
|
render(Shell);
|
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
|
});
|
|
|
|
test('renders three nav items: Library, Search, Playlists', () => {
|
|
render(Shell);
|
|
expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/');
|
|
expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search');
|
|
expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists');
|
|
});
|
|
|
|
test('user-menu "Log out" calls logout() and navigates to /login', async () => {
|
|
render(Shell);
|
|
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));
|
|
await fireEvent.click(screen.getByRole('button', { name: /log out/i }));
|
|
expect(logout).toHaveBeenCalledTimes(1);
|
|
expect(goto).toHaveBeenCalledWith('/login', { replaceState: true });
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests, confirm they fail**
|
|
|
|
Run: `cd web && npm test -- src/lib/components/Shell.test.ts`
|
|
Expected: FAIL — component does not exist.
|
|
|
|
- [ ] **Step 3: Implement the component**
|
|
|
|
Create `web/src/lib/components/Shell.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { goto } from '$app/navigation';
|
|
import { user, logout } from '$lib/auth/store.svelte';
|
|
|
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
|
|
|
let menuOpen = $state(false);
|
|
|
|
async function handleLogout() {
|
|
menuOpen = false;
|
|
await logout();
|
|
goto('/login', { replaceState: true });
|
|
}
|
|
|
|
function isActive(href: string): boolean {
|
|
if (href === '/') return page.url.pathname === '/';
|
|
return page.url.pathname.startsWith(href);
|
|
}
|
|
|
|
const navItems = [
|
|
{ href: '/', label: 'Library' },
|
|
{ href: '/search', label: 'Search' },
|
|
{ href: '/playlists', label: 'Playlists' }
|
|
];
|
|
</script>
|
|
|
|
<svelte:window onclick={() => (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
|
|
|
<div class="grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr] bg-background text-text-primary">
|
|
<header class="col-span-2 flex items-center justify-between border-b border-border bg-surface px-4 py-3">
|
|
<div class="font-semibold">Minstrel</div>
|
|
<div class="relative">
|
|
<button
|
|
type="button"
|
|
class="rounded px-3 py-1 hover:bg-surface-hover"
|
|
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
|
|
>
|
|
{user.value?.username ?? ''}
|
|
</button>
|
|
{#if menuOpen}
|
|
<div
|
|
class="absolute right-0 mt-1 w-32 rounded border border-border bg-surface shadow"
|
|
onclick={(e) => e.stopPropagation()}
|
|
role="menu"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="block w-full px-3 py-2 text-left hover:bg-surface-hover"
|
|
onclick={handleLogout}
|
|
>
|
|
Log out
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
|
|
<nav class="hidden w-48 border-r border-border bg-surface md:block">
|
|
<ul class="p-2">
|
|
{#each navItems as item}
|
|
<li>
|
|
<a
|
|
href={item.href}
|
|
class="block rounded px-3 py-2 hover:bg-surface-hover"
|
|
aria-current={isActive(item.href) ? 'page' : undefined}
|
|
>
|
|
{item.label}
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</nav>
|
|
|
|
<main class="overflow-y-auto p-4 md:col-start-2">
|
|
{@render children()}
|
|
</main>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests, confirm they pass**
|
|
|
|
Run: `cd web && npm test -- src/lib/components/Shell.test.ts`
|
|
Expected: PASS — 3 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/src/lib/components/Shell.svelte web/src/lib/components/Shell.test.ts
|
|
git commit -m "feat(web): add Shell component with header, sidebar, and user menu
|
|
|
|
Persistent chrome used by every authenticated route. Sidebar hides
|
|
below md:; mobile nav is a separate concern for a later plan."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Placeholder pages for `/`, `/search`, `/playlists`
|
|
|
|
**Files:**
|
|
- Modify: `web/src/routes/+page.svelte`
|
|
- Create: `web/src/routes/search/+page.svelte`
|
|
- Create: `web/src/routes/playlists/+page.svelte`
|
|
|
|
- [ ] **Step 1: Replace `web/src/routes/+page.svelte`**
|
|
|
|
Overwrite the file with:
|
|
|
|
```svelte
|
|
<h1 class="text-2xl font-semibold">Library</h1>
|
|
<p class="mt-2 text-text-secondary">Library views land in a subsequent plan.</p>
|
|
```
|
|
|
|
- [ ] **Step 2: Create `web/src/routes/search/+page.svelte`**
|
|
|
|
```svelte
|
|
<h1 class="text-2xl font-semibold">Search</h1>
|
|
<p class="mt-2 text-text-secondary">Search lands in a subsequent plan.</p>
|
|
```
|
|
|
|
- [ ] **Step 3: Create `web/src/routes/playlists/+page.svelte`**
|
|
|
|
```svelte
|
|
<h1 class="text-2xl font-semibold">Playlists</h1>
|
|
<p class="mt-2 text-text-secondary">Playlists land in a subsequent plan.</p>
|
|
```
|
|
|
|
- [ ] **Step 4: Verify nothing breaks**
|
|
|
|
Run: `cd web && npm run check && npm run build`
|
|
Expected: check passes with 0 errors; build emits `web/build/` including `index.html`.
|
|
|
|
Then restore the committed placeholder (the build regenerates it; we don't want the real build output in git):
|
|
|
|
Run: `git checkout -- web/build/index.html`
|
|
Expected: `git status` shows no changes under `web/build/`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/src/routes/+page.svelte web/src/routes/search/+page.svelte web/src/routes/playlists/+page.svelte
|
|
git commit -m "feat(web): add Library/Search/Playlists placeholder pages
|
|
|
|
Library replaces the scaffold splash. Search and Playlists are routable
|
|
so the Shell's sidebar nav works end-to-end before the real features land."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Login page
|
|
|
|
**Files:**
|
|
- Create: `web/src/routes/login/+page.svelte`
|
|
- Create: `web/src/routes/login/login.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `web/src/routes/login/login.test.ts`:
|
|
|
|
```ts
|
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
|
|
// vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy
|
|
// and can run before top-level `let` is initialized, since imports are
|
|
// hoisted above variable declarations.
|
|
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') }));
|
|
|
|
vi.mock('$app/state', () => ({
|
|
page: {
|
|
get url() { return state.pageUrl; }
|
|
}
|
|
}));
|
|
|
|
vi.mock('$app/navigation', () => ({
|
|
goto: vi.fn()
|
|
}));
|
|
|
|
vi.mock('$lib/auth/store.svelte', () => ({
|
|
login: vi.fn(),
|
|
user: { value: null }
|
|
}));
|
|
|
|
import LoginPage from './+page.svelte';
|
|
import { login } from '$lib/auth/store.svelte';
|
|
import { goto } from '$app/navigation';
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
state.pageUrl = new URL('http://localhost/login');
|
|
});
|
|
|
|
async function submit(username = 'alice', password = 'pw') {
|
|
const u = screen.getByLabelText(/username/i) as HTMLInputElement;
|
|
const p = screen.getByLabelText(/password/i) as HTMLInputElement;
|
|
await fireEvent.input(u, { target: { value: username } });
|
|
await fireEvent.input(p, { target: { value: password } });
|
|
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
|
}
|
|
|
|
describe('login page', () => {
|
|
test('renders username, password, and sign-in button', () => {
|
|
render(LoginPage);
|
|
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
|
});
|
|
|
|
test('invalid credentials show inline error and clear password', async () => {
|
|
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
|
|
code: 'invalid_credentials', message: 'bad creds', status: 401
|
|
});
|
|
render(LoginPage);
|
|
await submit();
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i);
|
|
});
|
|
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('');
|
|
expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice');
|
|
});
|
|
|
|
test('server error shows generic message and preserves both fields', async () => {
|
|
(login as ReturnType<typeof vi.fn>).mockRejectedValue({
|
|
code: 'server_error', message: 'boom', status: 500
|
|
});
|
|
render(LoginPage);
|
|
await submit('alice', 'pw');
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i);
|
|
});
|
|
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw');
|
|
expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice');
|
|
});
|
|
|
|
test('success navigates to returnTo when safe', async () => {
|
|
state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc');
|
|
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
render(LoginPage);
|
|
await submit();
|
|
await waitFor(() => {
|
|
expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true });
|
|
});
|
|
});
|
|
|
|
test('success falls back to "/" when returnTo is missing', async () => {
|
|
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
render(LoginPage);
|
|
await submit();
|
|
await waitFor(() => {
|
|
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
['//evil.com', 'protocol-relative'],
|
|
['http://evil.com', 'absolute URL'],
|
|
['/login', 'self-redirect'],
|
|
['../admin', 'parent traversal']
|
|
])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => {
|
|
state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`);
|
|
(login as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
render(LoginPage);
|
|
await submit();
|
|
await waitFor(() => {
|
|
expect(goto).toHaveBeenCalledWith('/', { replaceState: true });
|
|
});
|
|
});
|
|
|
|
test('submit button disables and marks aria-busy while pending', async () => {
|
|
let release!: () => void;
|
|
(login as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
new Promise<void>((resolve) => { release = () => resolve(); })
|
|
);
|
|
render(LoginPage);
|
|
await submit();
|
|
const btn = screen.getByRole('button', { name: /sign in/i });
|
|
expect(btn).toBeDisabled();
|
|
expect(btn).toHaveAttribute('aria-busy', 'true');
|
|
release();
|
|
await waitFor(() => expect(btn).not.toBeDisabled());
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests, confirm they fail**
|
|
|
|
Run: `cd web && npm test -- src/routes/login/+page.test.ts`
|
|
Expected: FAIL — component does not exist.
|
|
|
|
- [ ] **Step 3: Implement `web/src/routes/login/+page.svelte`**
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { goto } from '$app/navigation';
|
|
import { login } from '$lib/auth/store.svelte';
|
|
import type { ApiError } from '$lib/api/client';
|
|
|
|
let username = $state('');
|
|
let password = $state('');
|
|
let error = $state<string | null>(null);
|
|
let pending = $state(false);
|
|
|
|
function safeReturnTo(raw: string | null): string {
|
|
if (!raw) return '/';
|
|
// Starts with exactly one '/' (not '//', which is a protocol-relative URL)
|
|
// AND is not the login page itself.
|
|
if (!/^\/(?!\/)/.test(raw)) return '/';
|
|
if (raw === '/login' || raw.startsWith('/login?') || raw.startsWith('/login/')) return '/';
|
|
return raw;
|
|
}
|
|
|
|
async function handleSubmit(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
if (pending) return;
|
|
error = null;
|
|
pending = true;
|
|
try {
|
|
await login(username, password);
|
|
const dest = safeReturnTo(page.url.searchParams.get('returnTo'));
|
|
goto(dest, { replaceState: true });
|
|
} catch (err) {
|
|
const apiErr = err as ApiError;
|
|
if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') {
|
|
error = 'Invalid username or password.';
|
|
password = '';
|
|
} else {
|
|
error = 'Sign-in unavailable. Try again in a moment.';
|
|
}
|
|
} finally {
|
|
pending = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<main class="flex min-h-screen items-center justify-center bg-background text-text-primary">
|
|
<div class="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow">
|
|
<h1 class="mb-6 text-center text-2xl font-semibold">Minstrel</h1>
|
|
<form class="space-y-4" onsubmit={handleSubmit}>
|
|
<label class="block">
|
|
<span class="mb-1 block text-sm text-text-secondary">Username</span>
|
|
<input
|
|
type="text"
|
|
autocomplete="username"
|
|
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
|
|
bind:value={username}
|
|
required
|
|
/>
|
|
</label>
|
|
<label class="block">
|
|
<span class="mb-1 block text-sm text-text-secondary">Password</span>
|
|
<input
|
|
type="password"
|
|
autocomplete="current-password"
|
|
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
|
|
bind:value={password}
|
|
required
|
|
/>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
class="w-full rounded bg-accent px-3 py-2 font-medium text-background disabled:opacity-60"
|
|
aria-busy={pending ? 'true' : undefined}
|
|
disabled={pending}
|
|
>
|
|
Sign in
|
|
</button>
|
|
{#if error}
|
|
<p role="alert" class="text-sm text-danger">{error}</p>
|
|
{/if}
|
|
</form>
|
|
</div>
|
|
</main>
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, confirm they pass**
|
|
|
|
Run: `cd web && npm test -- src/routes/login/+page.test.ts`
|
|
Expected: PASS — 10 tests (6 `test(...)` blocks + 4 from `test.each` with 4 rows).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/src/routes/login/+page.svelte web/src/routes/login/login.test.ts
|
|
git commit -m "feat(web): add login page with returnTo support and typed error UX
|
|
|
|
Inline errors for invalid creds vs. server-side failures. Validates
|
|
returnTo to block open-redirect vectors (//, http://, /login,
|
|
parent-traversal)."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Root layout wiring — bootstrap, QueryClientProvider, guard, Shell
|
|
|
|
**Files:**
|
|
- Create: `web/src/routes/+layout.ts`
|
|
- Modify: `web/src/routes/+layout.svelte`
|
|
|
|
- [ ] **Step 1: Create `web/src/routes/+layout.ts`**
|
|
|
|
```ts
|
|
import type { LayoutLoad } from './$types';
|
|
import { bootstrap } from '$lib/auth/store.svelte';
|
|
|
|
export const ssr = false; // adapter-static fallback; we're SPA-only
|
|
export const prerender = false;
|
|
|
|
export const load: LayoutLoad = async () => {
|
|
await bootstrap();
|
|
return {};
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Overwrite `web/src/routes/+layout.svelte`**
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import '../app.css';
|
|
import { page } from '$app/state';
|
|
import { goto } from '$app/navigation';
|
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
|
import { queryClient } from '$lib/query/client';
|
|
import { user } from '$lib/auth/store.svelte';
|
|
import Shell from '$lib/components/Shell.svelte';
|
|
|
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
|
|
|
// Reactive guard: runs every time page.url or user.value changes.
|
|
$effect(() => {
|
|
const path = page.url.pathname;
|
|
const isLogin = path === '/login';
|
|
if (user.value === null && !isLogin) {
|
|
const returnTo = path + page.url.search;
|
|
goto('/login?returnTo=' + encodeURIComponent(returnTo), { replaceState: true });
|
|
} else if (user.value !== null && isLogin) {
|
|
goto('/', { replaceState: true });
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
{#if user.value !== null && page.url.pathname !== '/login'}
|
|
<Shell>{@render children()}</Shell>
|
|
{:else}
|
|
{@render children()}
|
|
{/if}
|
|
</QueryClientProvider>
|
|
```
|
|
|
|
- [ ] **Step 3: Type-check**
|
|
|
|
Run: `cd web && npm run check`
|
|
Expected: `svelte-check found 0 errors and 0 warnings`.
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
Run: `cd web && npm run build`
|
|
Expected: build succeeds. Then:
|
|
|
|
Run: `git checkout -- web/build/index.html`
|
|
Expected: committed placeholder restored.
|
|
|
|
- [ ] **Step 5: Full test suite**
|
|
|
|
Run: `cd web && npm test`
|
|
Expected: all test files pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add web/src/routes/+layout.ts web/src/routes/+layout.svelte
|
|
git commit -m "feat(web): wire root layout — bootstrap, guard, Shell, QueryProvider
|
|
|
|
+layout.ts runs auth.bootstrap() in load() so the shell sees the user
|
|
synchronously. +layout.svelte installs the TanStack QueryClientProvider,
|
|
runs the route guard as a \$effect, and swaps Shell vs. bare slot
|
|
based on auth state."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Final verification + branch finish
|
|
|
|
**Files:** none (verification only).
|
|
|
|
- [ ] **Step 1: Full Go suite (make sure the frontend work didn't accidentally touch Go files)**
|
|
|
|
Run: `go test -short -race ./...`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 2: Go linter**
|
|
|
|
Run: `golangci-lint run ./...`
|
|
Expected: no issues.
|
|
|
|
- [ ] **Step 3: Web check + tests + build**
|
|
|
|
Run: `cd web && npm run check && npm test && npm run build`
|
|
Expected: check 0 errors; all vitest files pass; build emits to `web/build/`.
|
|
|
|
Run: `git checkout -- web/build/index.html`
|
|
Expected: committed placeholder restored.
|
|
|
|
- [ ] **Step 4: Docker build smoke**
|
|
|
|
Run: `docker build -t minstrel:auth-smoke .`
|
|
Expected: image builds.
|
|
|
|
Run: `docker run --rm --entrypoint /bin/sh minstrel:auth-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'`
|
|
Expected: `ok`.
|
|
|
|
- [ ] **Step 5: Local end-to-end manual test**
|
|
|
|
Bring up the stack:
|
|
|
|
```bash
|
|
docker compose up --build -d
|
|
```
|
|
|
|
Seed a user into Postgres if one does not already exist (reuse whatever bootstrap the auth-foundation plan established; e.g., `psql` insert with a bcrypt hash). Record the username/password used.
|
|
|
|
Then in a browser, verify these five flows:
|
|
|
|
1. **Deep-link gated:** visit `http://localhost:4533/artists/abc`. Expected: URL changes to `/login?returnTo=%2Fartists%2Fabc`; login form is visible.
|
|
2. **Login + deep-link return:** sign in with seeded creds. Expected: URL changes to `/artists/abc`; page is blank (SPA fallback — library plan fills this in) but the Shell header shows your username and sidebar is visible.
|
|
3. **Refresh does not flash:** press F5. Expected: Shell renders without the login form appearing first.
|
|
4. **Logout:** click username menu → "Log out". Expected: URL changes to `/login`; visiting `/` redirects back to `/login`.
|
|
5. **Cross-tab session death:** log in again, open a second tab to `/`. In dev tools, clear the `minstrel_session` cookie. Click "Search" in the nav of the second tab. Expected: after the first API call in the tab (which will 401), the tab redirects to `/login`.
|
|
|
|
Tear down:
|
|
|
|
```bash
|
|
docker compose down
|
|
```
|
|
|
|
- [ ] **Step 6: Finish the branch**
|
|
|
|
Follow `superpowers:finishing-a-development-branch`: present the four-option menu. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.
|
|
|
|
---
|
|
|
|
## Self-Review Notes
|
|
|
|
**Spec coverage check:**
|
|
- `apiFetch` contract → Tasks 3, 7
|
|
- `api` facade → Task 4
|
|
- `auth` store → Task 6
|
|
- QueryClient → Task 5
|
|
- Shell component → Task 8
|
|
- Login page → Task 10
|
|
- Placeholder pages → Task 9
|
|
- Root layout bootstrap + guard → Task 11
|
|
- Testing coverage → each feature task has its own test step
|
|
- Dependencies → Task 1
|
|
- Final verification & branch finish → Task 12
|
|
|
|
All spec sections map to tasks. No gaps.
|
|
|
|
**Type consistency:**
|
|
- `User` shape `{id, username, is_admin}` — used identically in Tasks 2, 6, 8.
|
|
- `ApiError` shape `{code, message, status}` — used identically in Tasks 2, 3, 7, 10.
|
|
- `LoginResponse` shape `{token, user}` — used in Tasks 2, 6.
|
|
- `user.value` getter — Tasks 6 (definition), 8, 11 (consumption).
|
|
- `logout(opts?: {silent?: boolean})` — Tasks 6 (definition), 7 (silent call), 8 (plain call).
|
|
- `page` from `$app/state` (not `$app/stores`) — consistent across Tasks 8, 10, 11.
|