# Auth & Roles Implementation Plan (Sub-project A) > **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:** Put the existing flashcard app behind email+password authentication with self-service registration, admin invites, password reset, profile management, and a sysadmin role for user management. **Architecture:** Server-side sessions in SQLite (Drizzle ORM) backed by HttpOnly cookies, with double-submit CSRF tokens for mutations. Email delivery via nodemailer over SMTP (Mailpit in dev, Amazon SES in prod) with a stub-mailer fallback that logs to console when SMTP is unreachable. New backend modules under `src/services/auth/`, `src/middleware/`, and route files for `/api/auth/*` and `/api/admin/users/*`. New React pages for login/register/verify/forgot/reset/accept-invite/profile/admin-users, gated by an `AuthBoundary` + `RoleGuard`. **Tech Stack:** bcryptjs, nodemailer, cookie-parser, express-rate-limit, Drizzle ORM, Zod, React + Zustand, Vitest + supertest, Playwright + Mailpit HTTP API. **Spec:** `docs/superpowers/specs/2026-05-20-auth-and-roles-design.md` **Pre-implementation:** The current dev DB at `data/flashcard.db` will be dropped and re-migrated. No production data exists yet. --- ## File Structure ``` flashcard/ ├── docker-compose.yml NEW (mailpit) ├── .env.example NEW ├── packages/ │ ├── shared/src/ │ │ ├── types.ts MODIFIED (+ User, AuthSession, Role) │ │ └── schemas.ts MODIFIED (+ auth zod schemas) │ ├── backend/src/ │ │ ├── db/ │ │ │ ├── schema.ts MODIFIED (+ users, sessions_auth, auth_tokens) │ │ │ └── seed.ts MODIFIED (creates demo user + lessons) │ │ ├── services/ │ │ │ ├── auth/ │ │ │ │ ├── passwords.ts NEW │ │ │ │ ├── passwords.test.ts NEW │ │ │ │ ├── tokens.ts NEW │ │ │ │ ├── tokens.test.ts NEW │ │ │ │ ├── sessions.ts NEW │ │ │ │ ├── sessions.test.ts NEW │ │ │ │ ├── email.ts NEW │ │ │ │ └── templates.ts NEW │ │ │ └── users.ts NEW (admin user management) │ │ ├── middleware/ │ │ │ ├── auth.ts NEW (requireAuth, requireRole, currentUserOrNull) │ │ │ ├── csrf.ts NEW │ │ │ └── rate-limit.ts NEW │ │ ├── lib/ │ │ │ └── cookies.ts NEW │ │ ├── routes/ │ │ │ ├── auth.ts NEW │ │ │ ├── admin-users.ts NEW │ │ │ ├── cards.ts MODIFIED (no functional change beyond mounting) │ │ │ ├── lessons.ts (unchanged) │ │ │ ├── sessions.ts (unchanged) │ │ │ └── stats.ts (unchanged) │ │ ├── tests/ │ │ │ ├── dbHelper.ts MODIFIED (helper to seed a user + return session cookie) │ │ │ ├── auth.integration.test.ts NEW │ │ │ └── admin-users.integration.test.ts NEW │ │ ├── app.ts MODIFIED (mount auth, csrf, rate limit, protect all) │ │ └── index.ts MODIFIED (env validation) │ └── frontend/src/ │ ├── api/ │ │ ├── client.ts MODIFIED (CSRF header + 401 handling) │ │ ├── auth.ts NEW │ │ └── admin-users.ts NEW │ ├── stores/ │ │ └── authStore.ts NEW │ ├── components/ │ │ ├── AuthBoundary.tsx NEW │ │ ├── RoleGuard.tsx NEW │ │ ├── UserMenu.tsx NEW │ │ └── Layout.tsx MODIFIED (UserMenu + sysadmin link) │ ├── pages/ │ │ ├── auth/ │ │ │ ├── Login.tsx NEW │ │ │ ├── Register.tsx NEW │ │ │ ├── VerifyEmail.tsx NEW │ │ │ ├── ForgotPassword.tsx NEW │ │ │ ├── ResetPassword.tsx NEW │ │ │ └── AcceptInvite.tsx NEW │ │ ├── Profile.tsx NEW │ │ └── AdminUsers.tsx NEW │ └── router.tsx MODIFIED (auth routes + boundaries) ├── e2e/ │ ├── smoke.spec.ts MODIFIED (registers + logs in first) │ └── auth.spec.ts NEW (full mailpit-based auth flow) └── README.md MODIFIED (auth setup section) ``` --- ## Task 1: Database schema for auth **Files:** - Modify: `packages/backend/src/db/schema.ts` - Generate: `packages/backend/drizzle/0001_*.sql` - Modify: `packages/backend/src/db/seed.ts` - [ ] **Step 1: Drop existing dev DB** ```bash cd /Users/berthausmans/Documents/Development/flashcard rm -f data/flashcard.db data/flashcard.db-* packages/backend/data/*.db packages/backend/data/*.db-* ``` - [ ] **Step 2: Append new tables to `schema.ts`** Add to `packages/backend/src/db/schema.ts` (after the existing `attempts` table, before the type exports): ```ts export const users = sqliteTable( 'users', { id: integer('id').primaryKey({ autoIncrement: true }), email: text('email').notNull().unique(), displayName: text('display_name').notNull(), passwordHash: text('password_hash'), role: text('role', { enum: ['user', 'sysadmin'] }).notNull().default('user'), isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), emailVerifiedAt: integer('email_verified_at'), pendingEmail: text('pending_email'), createdAt: integer('created_at').notNull().default(sql`(unixepoch())`), updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`), }, (t) => ({ emailIdx: index('users_email_idx').on(t.email) }) ); export const sessionsAuth = sqliteTable( 'sessions_auth', { id: text('id').primaryKey(), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), createdAt: integer('created_at').notNull().default(sql`(unixepoch())`), expiresAt: integer('expires_at').notNull(), lastUsedAt: integer('last_used_at').notNull().default(sql`(unixepoch())`), userAgent: text('user_agent'), ip: text('ip'), }, (t) => ({ userIdx: index('sessions_auth_user_idx').on(t.userId), expIdx: index('sessions_auth_expires_idx').on(t.expiresAt), }) ); export const authTokens = sqliteTable( 'auth_tokens', { id: integer('id').primaryKey({ autoIncrement: true }), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), tokenHash: text('token_hash').notNull(), purpose: text('purpose', { enum: ['verify_email', 'password_reset', 'invite', 'change_email'] }).notNull(), payload: text('payload'), expiresAt: integer('expires_at').notNull(), usedAt: integer('used_at'), createdAt: integer('created_at').notNull().default(sql`(unixepoch())`), }, (t) => ({ hashIdx: index('auth_tokens_hash_idx').on(t.tokenHash), userPurposeIdx: index('auth_tokens_user_purpose_idx').on(t.userId, t.purpose), }) ); export type UserRow = typeof users.$inferSelect; export type SessionAuthRow = typeof sessionsAuth.$inferSelect; export type AuthTokenRow = typeof authTokens.$inferSelect; ``` - [ ] **Step 3: Generate migration** ```bash npm -w @flashcard/backend run db:generate ``` Expected: a new file `packages/backend/drizzle/0001_*.sql` is created. - [ ] **Step 4: Run migrations on a fresh DB** ```bash DB_PATH=./data/flashcard.db npm -w @flashcard/backend run db:migrate ``` Expected: `Migrations applied.` and `data/flashcard.db` exists. - [ ] **Step 5: Update `seed.ts` to be safe with the new tables** Replace the contents of `packages/backend/src/db/seed.ts` with: ```ts import { createDb } from './client.js'; import { cards, lessons } from './schema.js'; const { db, sqlite } = createDb(); // Seed only demo lessons + cards. Users are created via /api/auth/register in app. const [root] = db.insert(lessons).values({ name: 'Demo: Spaans', position: 0 }).returning().all(); const [sub] = db.insert(lessons).values({ name: 'Begroetingen', parentId: root!.id, position: 0 }).returning().all(); db.insert(cards).values([ { lessonId: sub!.id, question: 'Hallo', answer: 'Hola', position: 0 }, { lessonId: sub!.id, question: 'Goedemorgen', answer: 'Buenos días', position: 1 }, { lessonId: sub!.id, question: 'Tot ziens', answer: 'Adiós', position: 2 }, ]).run(); sqlite.close(); console.log('Seed inserted.'); ``` (No change to logic; only confirming that seed continues to work after the new tables exist.) - [ ] **Step 6: Typecheck and commit** ```bash npm -w @flashcard/backend run typecheck git add packages/backend/src/db/schema.ts packages/backend/drizzle packages/backend/src/db/seed.ts git -c commit.gpgsign=false commit -m "feat(db): add users, sessions_auth, auth_tokens tables" ``` --- ## Task 2: Shared auth types & Zod schemas **Files:** - Modify: `packages/shared/src/types.ts` - Modify: `packages/shared/src/schemas.ts` - [ ] **Step 1: Append types to `types.ts`** Append these to `packages/shared/src/types.ts`: ```ts export type Role = 'user' | 'sysadmin'; export interface User { id: number; email: string; displayName: string; role: Role; isActive: boolean; emailVerifiedAt: number | null; pendingEmail: string | null; createdAt: number; updatedAt: number; } export interface PublicUser { id: number; email: string; displayName: string; role: Role; } ``` - [ ] **Step 2: Append Zod schemas to `schemas.ts`** Append to `packages/shared/src/schemas.ts`: ```ts export const emailSchema = z.string().email().max(320).transform((v) => v.trim().toLowerCase()); export const registerSchema = z.object({ email: emailSchema, displayName: z.string().trim().min(1).max(120), password: z.string().min(8).max(200), }); export const loginSchema = z.object({ email: emailSchema, password: z.string().min(1).max(200), }); export const forgotPasswordSchema = z.object({ email: emailSchema }); export const resetPasswordSchema = z.object({ token: z.string().min(20).max(200), password: z.string().min(8).max(200), }); export const verifyEmailSchema = z.object({ token: z.string().min(20).max(200), }); export const resendVerificationSchema = z.object({ email: emailSchema }); export const profileUpdateSchema = z.object({ displayName: z.string().trim().min(1).max(120).optional(), email: emailSchema.optional(), }); export const changePasswordSchema = z.object({ currentPassword: z.string().min(1).max(200), newPassword: z.string().min(8).max(200), }); export const acceptInviteSchema = z.object({ token: z.string().min(20).max(200), displayName: z.string().trim().min(1).max(120), password: z.string().min(8).max(200), }); export const inviteUserSchema = z.object({ email: emailSchema, role: z.enum(['user', 'sysadmin']).default('user'), }); export const adminUserUpdateSchema = z.object({ displayName: z.string().trim().min(1).max(120).optional(), role: z.enum(['user', 'sysadmin']).optional(), isActive: z.boolean().optional(), }); export type RegisterInput = z.infer; export type LoginInput = z.infer; export type ForgotPasswordInput = z.infer; export type ResetPasswordInput = z.infer; export type VerifyEmailInput = z.infer; export type ResendVerificationInput = z.infer; export type ProfileUpdateInput = z.infer; export type ChangePasswordInput = z.infer; export type AcceptInviteInput = z.infer; export type InviteUserInput = z.infer; export type AdminUserUpdateInput = z.infer; ``` - [ ] **Step 3: Typecheck and commit** ```bash npm -w @flashcard/shared run typecheck git add packages/shared/src/types.ts packages/shared/src/schemas.ts git -c commit.gpgsign=false commit -m "feat(shared): add auth types and zod schemas" ``` --- ## Task 3: Password service (TDD) **Files:** - Create: `packages/backend/src/services/auth/passwords.ts` - Create: `packages/backend/src/services/auth/passwords.test.ts` - [ ] **Step 1: Install bcryptjs** ```bash cd /Users/berthausmans/Documents/Development/flashcard npm i -w @flashcard/backend bcryptjs npm i -D -w @flashcard/backend @types/bcryptjs ``` - [ ] **Step 2: Write the failing tests** Create `packages/backend/src/services/auth/passwords.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { hashPassword, verifyPassword } from './passwords.js'; describe('passwords', () => { it('hashes a password and verifies it', async () => { const hash = await hashPassword('correcthorse'); expect(hash).toMatch(/^\$2[aby]\$/); expect(await verifyPassword('correcthorse', hash)).toBe(true); }); it('rejects a wrong password', async () => { const hash = await hashPassword('correcthorse'); expect(await verifyPassword('wrong', hash)).toBe(false); }); it('returns false on malformed hash', async () => { expect(await verifyPassword('x', 'not-a-bcrypt-hash')).toBe(false); }); }); ``` - [ ] **Step 3: Run — fail** ```bash npm -w @flashcard/backend test ``` Expected: failure (module not found). - [ ] **Step 4: Implement `passwords.ts`** ```ts import bcrypt from 'bcryptjs'; const COST = 12; export async function hashPassword(plain: string): Promise { return bcrypt.hash(plain, COST); } export async function verifyPassword(plain: string, hash: string): Promise { try { return await bcrypt.compare(plain, hash); } catch { return false; } } ``` - [ ] **Step 5: Run — pass** ```bash npm -w @flashcard/backend test ``` Expected: 3 password tests pass. - [ ] **Step 6: Commit** ```bash git add packages/backend/package.json package-lock.json packages/backend/src/services/auth/ git -c commit.gpgsign=false commit -m "feat(auth): password hashing service" ``` --- ## Task 4: Token service (TDD) **Files:** - Create: `packages/backend/src/services/auth/tokens.ts` - Create: `packages/backend/src/services/auth/tokens.test.ts` - [ ] **Step 1: Write failing tests** ```ts import { describe, it, expect, beforeEach } from 'vitest'; import { makeTestDb } from '../../tests/dbHelper.js'; import { createUserDirect } from '../../tests/dbHelper.js'; import { generateToken, createAuthToken, consumeAuthToken, findValidAuthToken } from './tokens.js'; let env: ReturnType; beforeEach(() => { env = makeTestDb(); }); describe('tokens', () => { it('generates a URL-safe random token of the expected length', () => { const t = generateToken(); expect(t).toMatch(/^[A-Za-z0-9_-]+$/); expect(t.length).toBeGreaterThanOrEqual(32); }); it('stores a hashed token and finds it by plaintext', async () => { const user = await createUserDirect(env.db, { email: 'a@example.com' }); const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600); const found = await findValidAuthToken(env.db, plaintext, 'verify_email'); expect(found?.userId).toBe(user.id); }); it('rejects an expired token', async () => { const user = await createUserDirect(env.db, { email: 'b@example.com' }); const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', -1); expect(await findValidAuthToken(env.db, plaintext, 'verify_email')).toBeNull(); }); it('rejects a wrong purpose', async () => { const user = await createUserDirect(env.db, { email: 'c@example.com' }); const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600); expect(await findValidAuthToken(env.db, plaintext, 'password_reset')).toBeNull(); }); it('consumes a token once', async () => { const user = await createUserDirect(env.db, { email: 'd@example.com' }); const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600); const first = await consumeAuthToken(env.db, plaintext, 'verify_email'); expect(first?.userId).toBe(user.id); const second = await consumeAuthToken(env.db, plaintext, 'verify_email'); expect(second).toBeNull(); }); it('stores and returns the payload', async () => { const user = await createUserDirect(env.db, { email: 'e@example.com' }); const { plaintext } = await createAuthToken(env.db, user.id, 'change_email', 3600, 'new@example.com'); const found = await findValidAuthToken(env.db, plaintext, 'change_email'); expect(found?.payload).toBe('new@example.com'); }); }); ``` - [ ] **Step 2: Add helper to dbHelper.ts** Modify `packages/backend/src/tests/dbHelper.ts` to also export `createUserDirect`: ```ts import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { resolve } from 'node:path'; import { createDb, type Db } from '../db/client.js'; import { users } from '../db/schema.js'; import type { UserRow } from '../db/schema.js'; export function makeTestDb(): { db: Db; close: () => void } { const { db, sqlite } = createDb(':memory:'); migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') }); return { db, close: () => sqlite.close() }; } export async function createUserDirect( db: Db, init: { email: string; displayName?: string; role?: 'user' | 'sysadmin'; passwordHash?: string | null; emailVerifiedAt?: number | null; isActive?: boolean } ): Promise { const [row] = db.insert(users).values({ email: init.email, displayName: init.displayName ?? 'Test User', role: init.role ?? 'user', passwordHash: init.passwordHash ?? null, emailVerifiedAt: init.emailVerifiedAt ?? null, isActive: init.isActive ?? true, }).returning().all(); return row!; } ``` - [ ] **Step 3: Run — fail** ```bash npm -w @flashcard/backend test ``` Expected: tokens module not found. - [ ] **Step 4: Implement `tokens.ts`** ```ts import { randomBytes, createHash } from 'node:crypto'; import { and, eq, isNull, sql } from 'drizzle-orm'; import type { Db } from '../../db/client.js'; import { authTokens } from '../../db/schema.js'; const TOKEN_BYTES = 32; export type Purpose = 'verify_email' | 'password_reset' | 'invite' | 'change_email'; export function generateToken(): string { return randomBytes(TOKEN_BYTES).toString('base64url'); } function hashToken(plaintext: string): string { return createHash('sha256').update(plaintext).digest('hex'); } export interface CreatedToken { plaintext: string; hash: string; expiresAt: number; } export async function createAuthToken( db: Db, userId: number, purpose: Purpose, ttlSeconds: number, payload?: string | null ): Promise { const plaintext = generateToken(); const hash = hashToken(plaintext); const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds; db.insert(authTokens).values({ userId, tokenHash: hash, purpose, payload: payload ?? null, expiresAt, }).run(); return { plaintext, hash, expiresAt }; } export async function findValidAuthToken( db: Db, plaintext: string, purpose: Purpose ): Promise<{ id: number; userId: number; payload: string | null } | null> { const now = Math.floor(Date.now() / 1000); const hash = hashToken(plaintext); const row = db.select().from(authTokens).where( and( eq(authTokens.tokenHash, hash), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt), sql`${authTokens.expiresAt} > ${now}` ) ).get(); if (!row) return null; return { id: row.id, userId: row.userId, payload: row.payload ?? null }; } export async function consumeAuthToken( db: Db, plaintext: string, purpose: Purpose ): Promise<{ userId: number; payload: string | null } | null> { const now = Math.floor(Date.now() / 1000); const row = await findValidAuthToken(db, plaintext, purpose); if (!row) return null; const r = db.update(authTokens).set({ usedAt: now }) .where(and(eq(authTokens.id, row.id), isNull(authTokens.usedAt))) .run(); if (r.changes === 0) return null; return { userId: row.userId, payload: row.payload }; } export async function invalidateTokensForUser(db: Db, userId: number, purpose: Purpose): Promise { const now = Math.floor(Date.now() / 1000); db.update(authTokens).set({ usedAt: now }) .where(and(eq(authTokens.userId, userId), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt))) .run(); } ``` - [ ] **Step 5: Run — pass** ```bash npm -w @flashcard/backend test ``` Expected: 6 token tests pass. - [ ] **Step 6: Commit** ```bash git add packages/backend/src/services/auth/tokens.ts packages/backend/src/services/auth/tokens.test.ts packages/backend/src/tests/dbHelper.ts git -c commit.gpgsign=false commit -m "feat(auth): token service with single-use, hashed storage" ``` --- ## Task 5: Auth session service (TDD) **Files:** - Create: `packages/backend/src/services/auth/sessions.ts` - Create: `packages/backend/src/services/auth/sessions.test.ts` Note: this file is for **auth sessions**, not the existing practice sessions. Filename `sessions.ts` inside `services/auth/` keeps them disambiguated. - [ ] **Step 1: Write failing tests** ```ts import { describe, it, expect, beforeEach } from 'vitest'; import { makeTestDb, createUserDirect } from '../../tests/dbHelper.js'; import { createAuthSession, validateAuthSession, invalidateAuthSession, invalidateAllForUser } from './sessions.js'; let env: ReturnType; beforeEach(() => { env = makeTestDb(); }); describe('auth sessions', () => { it('creates a session and validates it', async () => { const u = await createUserDirect(env.db, { email: 'a@example.com' }); const s = await createAuthSession(env.db, u.id, { userAgent: 'jest', ip: '127.0.0.1' }); const v = await validateAuthSession(env.db, s.id); expect(v?.userId).toBe(u.id); }); it('rejects an unknown session id', async () => { expect(await validateAuthSession(env.db, 'nope')).toBeNull(); }); it('rejects an expired session', async () => { const u = await createUserDirect(env.db, { email: 'b@example.com' }); const s = await createAuthSession(env.db, u.id, { ttlSeconds: -1 }); expect(await validateAuthSession(env.db, s.id)).toBeNull(); }); it('invalidates one session', async () => { const u = await createUserDirect(env.db, { email: 'c@example.com' }); const s = await createAuthSession(env.db, u.id); await invalidateAuthSession(env.db, s.id); expect(await validateAuthSession(env.db, s.id)).toBeNull(); }); it('invalidates all sessions for a user', async () => { const u = await createUserDirect(env.db, { email: 'd@example.com' }); const a = await createAuthSession(env.db, u.id); const b = await createAuthSession(env.db, u.id); await invalidateAllForUser(env.db, u.id); expect(await validateAuthSession(env.db, a.id)).toBeNull(); expect(await validateAuthSession(env.db, b.id)).toBeNull(); }); it('supports excluding one session when invalidating (for change-password keep-current)', async () => { const u = await createUserDirect(env.db, { email: 'e@example.com' }); const a = await createAuthSession(env.db, u.id); const b = await createAuthSession(env.db, u.id); await invalidateAllForUser(env.db, u.id, { except: b.id }); expect(await validateAuthSession(env.db, a.id)).toBeNull(); expect(await validateAuthSession(env.db, b.id)?.then?.((v) => v?.userId)).resolves !== undefined; // structural; assertion below const v = await validateAuthSession(env.db, b.id); expect(v?.userId).toBe(u.id); }); }); ``` - [ ] **Step 2: Run — fail** ```bash npm -w @flashcard/backend test ``` - [ ] **Step 3: Implement `sessions.ts`** ```ts import { randomBytes } from 'node:crypto'; import { and, eq, ne, sql } from 'drizzle-orm'; import type { Db } from '../../db/client.js'; import { sessionsAuth } from '../../db/schema.js'; const DEFAULT_TTL = 30 * 24 * 60 * 60; // 30 days const REFRESH_THRESHOLD = 24 * 60 * 60; // refresh expires_at when within 1 day of expiry function nowSec() { return Math.floor(Date.now() / 1000); } function genId(): string { return randomBytes(32).toString('hex'); } export interface CreatedSession { id: string; expiresAt: number; } export async function createAuthSession( db: Db, userId: number, opts: { ttlSeconds?: number; userAgent?: string; ip?: string } = {} ): Promise { const id = genId(); const ttl = opts.ttlSeconds ?? DEFAULT_TTL; const expiresAt = nowSec() + ttl; db.insert(sessionsAuth).values({ id, userId, expiresAt, lastUsedAt: nowSec(), userAgent: opts.userAgent ?? null, ip: opts.ip ?? null, }).run(); return { id, expiresAt }; } export async function validateAuthSession( db: Db, id: string ): Promise<{ userId: number; expiresAt: number } | null> { const row = db.select().from(sessionsAuth).where(eq(sessionsAuth.id, id)).get(); if (!row) return null; const now = nowSec(); if (row.expiresAt <= now) return null; // rolling refresh if (row.expiresAt - now < REFRESH_THRESHOLD) { db.update(sessionsAuth) .set({ expiresAt: now + DEFAULT_TTL, lastUsedAt: now }) .where(eq(sessionsAuth.id, id)) .run(); } else { db.update(sessionsAuth).set({ lastUsedAt: now }).where(eq(sessionsAuth.id, id)).run(); } return { userId: row.userId, expiresAt: row.expiresAt }; } export async function invalidateAuthSession(db: Db, id: string): Promise { db.delete(sessionsAuth).where(eq(sessionsAuth.id, id)).run(); } export async function invalidateAllForUser( db: Db, userId: number, opts: { except?: string } = {} ): Promise { if (opts.except) { db.delete(sessionsAuth).where(and(eq(sessionsAuth.userId, userId), ne(sessionsAuth.id, opts.except))).run(); } else { db.delete(sessionsAuth).where(eq(sessionsAuth.userId, userId)).run(); } } export async function purgeExpiredSessions(db: Db): Promise { const now = nowSec(); db.delete(sessionsAuth).where(sql`${sessionsAuth.expiresAt} <= ${now}`).run(); } ``` - [ ] **Step 4: Run — pass** ```bash npm -w @flashcard/backend test ``` Expected: 6 session tests pass. - [ ] **Step 5: Commit** ```bash git add packages/backend/src/services/auth/sessions.ts packages/backend/src/services/auth/sessions.test.ts git -c commit.gpgsign=false commit -m "feat(auth): server-side auth sessions with rolling expiry" ``` --- ## Task 6: Email service + templates **Files:** - Create: `packages/backend/src/services/auth/templates.ts` - Create: `packages/backend/src/services/auth/email.ts` - [ ] **Step 1: Install nodemailer** ```bash npm i -w @flashcard/backend nodemailer npm i -D -w @flashcard/backend @types/nodemailer ``` - [ ] **Step 2: Create `templates.ts`** ```ts function layout(title: string, body: string): { html: string; text: string } { const html = `

${title}

${body}

Flashcard — leer slimmer met spaced repetition

`; return { html, text: stripHtml(body) }; } function stripHtml(s: string): string { return s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } export function verifyEmailTemplate(appUrl: string, token: string, displayName: string) { const link = `${appUrl}/verify-email?token=${encodeURIComponent(token)}`; return { subject: 'Bevestig je e-mailadres', ...layout('Welkom bij Flashcard 👋', `

Hi ${escapeHtml(displayName)},

Klik op de knop hieronder om je e-mailadres te bevestigen. De link is 24 uur geldig.

Bevestig e-mail

Werkt de knop niet? Kopieer deze link in je browser:
${link}

`), }; } export function passwordResetTemplate(appUrl: string, token: string, displayName: string) { const link = `${appUrl}/reset-password?token=${encodeURIComponent(token)}`; return { subject: 'Reset je wachtwoord', ...layout('Wachtwoord resetten', `

Hi ${escapeHtml(displayName)},

Iemand vroeg een wachtwoord-reset aan voor je account. Was jij dat niet? Negeer dan deze e-mail.

De link is 1 uur geldig.

Reset wachtwoord

${link}

`), }; } export function inviteTemplate(appUrl: string, token: string, inviterName: string) { const link = `${appUrl}/accept-invite?token=${encodeURIComponent(token)}`; return { subject: 'Je bent uitgenodigd voor Flashcard', ...layout('Je bent uitgenodigd ✨', `

${escapeHtml(inviterName)} heeft je uitgenodigd voor Flashcard.

Maak je account aan via onderstaande knop. De link is 24 uur geldig.

Account aanmaken

${link}

`), }; } export function changeEmailTemplate(appUrl: string, token: string, displayName: string, newEmail: string) { const link = `${appUrl}/verify-email?token=${encodeURIComponent(token)}`; return { subject: 'Bevestig je nieuwe e-mailadres', ...layout('Nieuw e-mailadres bevestigen', `

Hi ${escapeHtml(displayName)},

Bevestig ${escapeHtml(newEmail)} als je nieuwe e-mailadres.

Bevestig adres

${link}

`), }; } function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!)); } ``` - [ ] **Step 3: Create `email.ts`** ```ts import nodemailer, { type Transporter } from 'nodemailer'; export interface Mailer { send(to: string, msg: { subject: string; html: string; text: string }): Promise; } class SmtpMailer implements Mailer { constructor(private transporter: Transporter, private from: string) {} async send(to: string, msg: { subject: string; html: string; text: string }): Promise { await this.transporter.sendMail({ from: this.from, to, ...msg }); } } class StubMailer implements Mailer { async send(to: string, msg: { subject: string; html: string; text: string }): Promise { console.log('\n=== EMAIL (stub) ==='); console.log(`TO: ${to}`); console.log(`SUBJECT: ${msg.subject}`); console.log('---'); console.log(msg.text); console.log('====================\n'); } } let cached: Mailer | null = null; export function getMailer(): Mailer { if (cached) return cached; const host = process.env.SMTP_HOST; const from = process.env.SMTP_FROM ?? 'Flashcard '; if (!host) { cached = new StubMailer(); return cached; } const transporter = nodemailer.createTransport({ host, port: Number(process.env.SMTP_PORT ?? 587), secure: process.env.SMTP_SECURE === 'true', auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS ?? '' } : undefined, }); cached = new SmtpMailer(transporter, from); return cached; } export function setMailerForTests(m: Mailer | null): void { cached = m; } ``` - [ ] **Step 4: Typecheck and commit** ```bash npm -w @flashcard/backend run typecheck git add packages/backend/package.json package-lock.json packages/backend/src/services/auth/templates.ts packages/backend/src/services/auth/email.ts git -c commit.gpgsign=false commit -m "feat(auth): email service with stub fallback + html templates" ``` --- ## Task 7: Cookies & CSRF middleware **Files:** - Create: `packages/backend/src/lib/cookies.ts` - Create: `packages/backend/src/middleware/csrf.ts` - [ ] **Step 1: Install cookie-parser** ```bash npm i -w @flashcard/backend cookie-parser npm i -D -w @flashcard/backend @types/cookie-parser ``` - [ ] **Step 2: Create `lib/cookies.ts`** ```ts import type { CookieOptions } from 'express'; export const SID_COOKIE = 'flashcard_sid'; export const CSRF_COOKIE = 'flashcard_csrf'; export const CSRF_HEADER = 'x-csrf-token'; export function sidCookieOptions(expiresAtSec: number): CookieOptions { return { httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', path: '/', expires: new Date(expiresAtSec * 1000), }; } export function csrfCookieOptions(expiresAtSec: number): CookieOptions { return { httpOnly: false, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', path: '/', expires: new Date(expiresAtSec * 1000), }; } export function clearCookieOptions(): CookieOptions { return { httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', path: '/', }; } ``` - [ ] **Step 3: Create `middleware/csrf.ts`** ```ts import { randomBytes } from 'node:crypto'; import type { Request, Response, NextFunction } from 'express'; import { CSRF_COOKIE, CSRF_HEADER, csrfCookieOptions } from '../lib/cookies.js'; import { ApiError } from '../lib/errors.js'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); export function ensureCsrfToken(req: Request, res: Response, next: NextFunction): void { if (!req.cookies?.[CSRF_COOKIE]) { const token = randomBytes(24).toString('base64url'); const expires = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; res.cookie(CSRF_COOKIE, token, csrfCookieOptions(expires)); } next(); } export function verifyCsrf(req: Request, _res: Response, next: NextFunction): void { if (SAFE_METHODS.has(req.method)) return next(); const cookieToken = req.cookies?.[CSRF_COOKIE]; const headerToken = req.headers[CSRF_HEADER]; if (!cookieToken || !headerToken || cookieToken !== headerToken) { return next(new ApiError(403, 'CSRF_MISMATCH', 'CSRF token mismatch')); } next(); } ``` - [ ] **Step 4: Typecheck and commit** ```bash npm -w @flashcard/backend run typecheck git add packages/backend/package.json package-lock.json packages/backend/src/lib/cookies.ts packages/backend/src/middleware/ git -c commit.gpgsign=false commit -m "feat(auth): cookies helpers and CSRF middleware" ``` --- ## Task 8: Rate limit middleware **Files:** - Create: `packages/backend/src/middleware/rate-limit.ts` - [ ] **Step 1: Install express-rate-limit** ```bash npm i -w @flashcard/backend express-rate-limit ``` - [ ] **Step 2: Create `rate-limit.ts`** ```ts import rateLimit from 'express-rate-limit'; const fifteenMin = 15 * 60 * 1000; function makeLimiter(max: number, codeMessage = 'Too many attempts, please try again later') { return rateLimit({ windowMs: fifteenMin, limit: max, standardHeaders: 'draft-7', legacyHeaders: false, skip: () => process.env.NODE_ENV === 'test', handler: (_req, res) => { res.status(429).json({ error: { code: 'RATE_LIMITED', message: codeMessage } }); }, }); } export const loginLimiter = makeLimiter(10); export const registerLimiter = makeLimiter(5); export const forgotPasswordLimiter = makeLimiter(5); export const tokenLimiter = makeLimiter(20); ``` - [ ] **Step 3: Commit** ```bash git add packages/backend/package.json package-lock.json packages/backend/src/middleware/rate-limit.ts git -c commit.gpgsign=false commit -m "feat(auth): named rate limiters (skip in tests)" ``` --- ## Task 9: Auth middleware **Files:** - Create: `packages/backend/src/middleware/auth.ts` - [ ] **Step 1: Create `auth.ts`** ```ts import type { Request, Response, NextFunction } from 'express'; import { eq } from 'drizzle-orm'; import type { Db } from '../db/client.js'; import { users } from '../db/schema.js'; import { validateAuthSession } from '../services/auth/sessions.js'; import { ApiError } from '../lib/errors.js'; import { SID_COOKIE } from '../lib/cookies.js'; import type { Role, User } from '@flashcard/shared'; declare module 'express-serve-static-core' { interface Request { user?: User; sessionId?: string; } } function rowToUser(r: typeof users.$inferSelect): User { return { id: r.id, email: r.email, displayName: r.displayName, role: r.role, isActive: r.isActive, emailVerifiedAt: r.emailVerifiedAt ?? null, pendingEmail: r.pendingEmail ?? null, createdAt: r.createdAt, updatedAt: r.updatedAt, }; } export function currentUserOrNull(db: Db) { return async (req: Request, _res: Response, next: NextFunction) => { const sid = req.cookies?.[SID_COOKIE]; if (!sid) return next(); const session = await validateAuthSession(db, sid); if (!session) return next(); const row = db.select().from(users).where(eq(users.id, session.userId)).get(); if (!row || !row.isActive) return next(); req.user = rowToUser(row); req.sessionId = sid; next(); }; } export function requireAuth(req: Request, _res: Response, next: NextFunction): void { if (!req.user) return next(new ApiError(401, 'UNAUTHENTICATED', 'Authentication required')); next(); } export function requireRole(role: Role) { return (req: Request, _res: Response, next: NextFunction): void => { if (!req.user) return next(new ApiError(401, 'UNAUTHENTICATED', 'Authentication required')); if (req.user.role !== role) return next(new ApiError(403, 'FORBIDDEN', 'Insufficient role')); next(); }; } ``` - [ ] **Step 2: Typecheck** ```bash npm -w @flashcard/backend run typecheck ``` Expected: passes. - [ ] **Step 3: Commit** ```bash git add packages/backend/src/middleware/auth.ts git -c commit.gpgsign=false commit -m "feat(auth): currentUser, requireAuth, requireRole middleware" ``` --- ## Task 10: Auth routes — register, login, logout, me **Files:** - Create: `packages/backend/src/routes/auth.ts` - Create: `packages/backend/src/tests/auth.integration.test.ts` - Modify: `packages/backend/src/tests/dbHelper.ts` (add `makeTestApp`) - [ ] **Step 1: Extend `tests/dbHelper.ts` with `makeTestApp` helper** Append to `packages/backend/src/tests/dbHelper.ts`: ```ts import type { Express } from 'express'; export interface TestApp { app: Express; db: Db; close: () => void; } ``` (Actual `makeTestApp` is added once `createApp(db)` is wired in Task 14. For now, expose `makeTestDb` and `createUserDirect` only.) - [ ] **Step 2: Write integration tests covering register → verify → login → me → logout** Create `packages/backend/src/tests/auth.integration.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest'; import request from 'supertest'; import { createApp } from '../app.js'; import { makeTestDb, createUserDirect } from './dbHelper.js'; import { setMailerForTests, type Mailer } from '../services/auth/email.js'; class CaptureMailer implements Mailer { sent: { to: string; subject: string; text: string; html: string }[] = []; async send(to: string, m: { subject: string; html: string; text: string }) { this.sent.push({ to, ...m }); } } let env: ReturnType; let mailer: CaptureMailer; let app: ReturnType; beforeEach(() => { env = makeTestDb(); mailer = new CaptureMailer(); setMailerForTests(mailer); app = createApp(env.db); }); function tokenFromMail(text: string): string { const m = text.match(/token=([^\s&"]+)/); if (!m) throw new Error('no token in mail'); return decodeURIComponent(m[1]!); } describe('auth: register → verify → login → me → logout', () => { it('registers, blocks unverified login, verifies, logs in, returns me, logs out', async () => { // 1. Register const reg = await request(app).post('/api/auth/register').send({ email: 'alice@example.com', displayName: 'Alice', password: 'secretpass', }); expect(reg.status).toBe(201); expect(mailer.sent).toHaveLength(1); const verifyToken = tokenFromMail(mailer.sent[0]!.text); // 2. Login unverified -> 403 const bad = await request(app).post('/api/auth/login').send({ email: 'alice@example.com', password: 'secretpass' }); expect(bad.status).toBe(403); expect(bad.body.error.code).toBe('EMAIL_NOT_VERIFIED'); // 3. Verify const ver = await request(app).post('/api/auth/verify-email').send({ token: verifyToken }); expect(ver.status).toBe(200); // 4. Login -> 200 + cookies const ok = await request(app).post('/api/auth/login').send({ email: 'alice@example.com', password: 'secretpass' }); expect(ok.status).toBe(200); const cookies = ok.headers['set-cookie'] as unknown as string[]; expect(cookies.find((c) => c.startsWith('flashcard_sid='))).toBeDefined(); expect(cookies.find((c) => c.startsWith('flashcard_csrf='))).toBeDefined(); // 5. /me works const me = await request(app).get('/api/auth/me').set('Cookie', cookies); expect(me.status).toBe(200); expect(me.body.email).toBe('alice@example.com'); // 6. Logout const logout = await request(app).post('/api/auth/logout').set('Cookie', cookies) .set('x-csrf-token', extractCookieValue(cookies, 'flashcard_csrf')); expect(logout.status).toBe(204); // 7. /me unauth after logout const me2 = await request(app).get('/api/auth/me').set('Cookie', cookies); expect(me2.status).toBe(401); }); it('marks first registered user as sysadmin', async () => { const r = await request(app).post('/api/auth/register').send({ email: 'first@example.com', displayName: 'First', password: 'secretpass', }); expect(r.status).toBe(201); const token = tokenFromMail(mailer.sent[0]!.text); await request(app).post('/api/auth/verify-email').send({ token }); const login = await request(app).post('/api/auth/login').send({ email: 'first@example.com', password: 'secretpass' }); const cookies = login.headers['set-cookie'] as unknown as string[]; const me = await request(app).get('/api/auth/me').set('Cookie', cookies); expect(me.body.role).toBe('sysadmin'); }); it('rejects duplicate email', async () => { await request(app).post('/api/auth/register').send({ email: 'a@example.com', displayName: 'A', password: 'secretpass' }); const dup = await request(app).post('/api/auth/register').send({ email: 'A@example.com', displayName: 'A2', password: 'secretpass' }); expect(dup.status).toBe(409); expect(dup.body.error.code).toBe('EMAIL_TAKEN'); }); }); function extractCookieValue(cookies: string[], name: string): string { for (const c of cookies) { if (c.startsWith(`${name}=`)) { const v = c.split(';')[0]!.slice(name.length + 1); return decodeURIComponent(v); } } throw new Error(`cookie ${name} not found`); } ``` - [ ] **Step 3: Run — tests fail (routes not implemented)** ```bash npm -w @flashcard/backend test ``` Expected: tests fail. - [ ] **Step 4: Implement `routes/auth.ts` (first slice — register/verify/login/logout/me)** ```ts import { Router } from 'express'; import { eq, sql } from 'drizzle-orm'; import { registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema, forgotPasswordSchema, resetPasswordSchema, profileUpdateSchema, changePasswordSchema, acceptInviteSchema, type PublicUser, } from '@flashcard/shared'; import type { Db } from '../db/client.js'; import { users } from '../db/schema.js'; import { hashPassword, verifyPassword } from '../services/auth/passwords.js'; import { createAuthToken, consumeAuthToken, invalidateTokensForUser } from '../services/auth/tokens.js'; import { createAuthSession, invalidateAuthSession, invalidateAllForUser } from '../services/auth/sessions.js'; import { getMailer } from '../services/auth/email.js'; import { verifyEmailTemplate, passwordResetTemplate, inviteTemplate, changeEmailTemplate, } from '../services/auth/templates.js'; import { SID_COOKIE, CSRF_COOKIE, sidCookieOptions, csrfCookieOptions, clearCookieOptions } from '../lib/cookies.js'; import { ApiError } from '../lib/errors.js'; import { requireAuth } from '../middleware/auth.js'; import { loginLimiter, registerLimiter, forgotPasswordLimiter, tokenLimiter } from '../middleware/rate-limit.js'; import { randomBytes } from 'node:crypto'; const VERIFY_TTL = 24 * 60 * 60; const INVITE_TTL = 24 * 60 * 60; const RESET_TTL = 60 * 60; const CHANGE_EMAIL_TTL = 24 * 60 * 60; function nowSec() { return Math.floor(Date.now() / 1000); } function appUrl() { return process.env.APP_URL ?? 'http://localhost:5173'; } function toPublicUser(r: typeof users.$inferSelect): PublicUser { return { id: r.id, email: r.email, displayName: r.displayName, role: r.role }; } export function authRouter(db: Db): Router { const r = Router(); r.get('/me', (req, res) => { if (!req.user) { res.status(401).json({ error: { code: 'UNAUTHENTICATED', message: 'Not signed in' } }); return; } res.json(req.user); }); r.post('/register', registerLimiter, async (req, res, next) => { try { const input = registerSchema.parse(req.body); const existing = db.select().from(users).where(eq(users.email, input.email)).get(); if (existing) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); const totalUsers = db.select({ c: sql`count(*)`.as('c') }).from(users).get()?.c ?? 0; const role: 'user' | 'sysadmin' = Number(totalUsers) === 0 ? 'sysadmin' : 'user'; const passwordHash = await hashPassword(input.password); const [user] = db.insert(users).values({ email: input.email, displayName: input.displayName, passwordHash, role, }).returning().all(); const { plaintext } = await createAuthToken(db, user!.id, 'verify_email', VERIFY_TTL); const tpl = verifyEmailTemplate(appUrl(), plaintext, user!.displayName); await getMailer().send(user!.email, tpl); res.status(201).json({ id: user!.id, email: user!.email, displayName: user!.displayName, role: user!.role }); } catch (e) { next(e); } }); r.post('/verify-email', tokenLimiter, async (req, res, next) => { try { const { token } = verifyEmailSchema.parse(req.body); // Try verify_email first; if not found, try change_email let consumed = await consumeAuthToken(db, token, 'verify_email'); let purpose: 'verify_email' | 'change_email' = 'verify_email'; if (!consumed) { consumed = await consumeAuthToken(db, token, 'change_email'); purpose = 'change_email'; } if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); if (purpose === 'verify_email') { db.update(users).set({ emailVerifiedAt: nowSec(), updatedAt: nowSec() }) .where(eq(users.id, consumed.userId)).run(); } else { const newEmail = consumed.payload; if (!newEmail) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid token payload'); const taken = db.select().from(users).where(eq(users.email, newEmail)).get(); if (taken && taken.id !== consumed.userId) { throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); } db.update(users).set({ email: newEmail, pendingEmail: null, updatedAt: nowSec() }) .where(eq(users.id, consumed.userId)).run(); } res.status(200).json({ ok: true }); } catch (e) { next(e); } }); r.post('/resend-verification', tokenLimiter, async (req, res, next) => { try { const { email } = resendVerificationSchema.parse(req.body); const u = db.select().from(users).where(eq(users.email, email)).get(); // Generic 200 response (no enumeration) if (u && !u.emailVerifiedAt && u.isActive) { await invalidateTokensForUser(db, u.id, 'verify_email'); const { plaintext } = await createAuthToken(db, u.id, 'verify_email', VERIFY_TTL); const tpl = verifyEmailTemplate(appUrl(), plaintext, u.displayName); await getMailer().send(u.email, tpl); } res.status(200).json({ ok: true }); } catch (e) { next(e); } }); r.post('/login', loginLimiter, async (req, res, next) => { try { const input = loginSchema.parse(req.body); const u = db.select().from(users).where(eq(users.email, input.email)).get(); if (!u || !u.passwordHash || !(await verifyPassword(input.password, u.passwordHash))) { throw new ApiError(401, 'INVALID_CREDENTIALS', 'Invalid email or password'); } if (!u.isActive) throw new ApiError(403, 'ACCOUNT_DISABLED', 'Account is disabled'); if (!u.emailVerifiedAt) throw new ApiError(403, 'EMAIL_NOT_VERIFIED', 'Email not verified'); const s = await createAuthSession(db, u.id, { userAgent: req.headers['user-agent'] ?? null, ip: req.ip ?? null, }); const csrf = randomBytes(24).toString('base64url'); res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt)); res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt)); res.json(toPublicUser(u)); } catch (e) { next(e); } }); r.post('/logout', requireAuth, async (req, res, next) => { try { if (req.sessionId) await invalidateAuthSession(db, req.sessionId); res.clearCookie(SID_COOKIE, clearCookieOptions()); res.clearCookie(CSRF_COOKIE, { ...clearCookieOptions(), httpOnly: false }); res.status(204).end(); } catch (e) { next(e); } }); r.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) => { try { const { email } = forgotPasswordSchema.parse(req.body); const u = db.select().from(users).where(eq(users.email, email)).get(); if (u && u.isActive && u.emailVerifiedAt) { await invalidateTokensForUser(db, u.id, 'password_reset'); const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL); const tpl = passwordResetTemplate(appUrl(), plaintext, u.displayName); await getMailer().send(u.email, tpl); } res.status(200).json({ ok: true }); } catch (e) { next(e); } }); r.post('/reset-password', tokenLimiter, async (req, res, next) => { try { const { token, password } = resetPasswordSchema.parse(req.body); const consumed = await consumeAuthToken(db, token, 'password_reset'); if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); const passwordHash = await hashPassword(password); db.update(users).set({ passwordHash, updatedAt: nowSec() }) .where(eq(users.id, consumed.userId)).run(); await invalidateAllForUser(db, consumed.userId); res.status(200).json({ ok: true }); } catch (e) { next(e); } }); r.post('/accept-invite', tokenLimiter, async (req, res, next) => { try { const input = acceptInviteSchema.parse(req.body); const consumed = await consumeAuthToken(db, input.token, 'invite'); if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); const passwordHash = await hashPassword(input.password); const [updated] = db.update(users).set({ passwordHash, displayName: input.displayName, emailVerifiedAt: nowSec(), updatedAt: nowSec(), }).where(eq(users.id, consumed.userId)).returning().all(); if (!updated) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid invitation'); const s = await createAuthSession(db, updated.id, { userAgent: req.headers['user-agent'] ?? null, ip: req.ip ?? null, }); const csrf = randomBytes(24).toString('base64url'); res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt)); res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt)); res.status(200).json(toPublicUser(updated)); } catch (e) { next(e); } }); r.patch('/profile', requireAuth, async (req, res, next) => { try { const input = profileUpdateSchema.parse(req.body); const user = req.user!; const updates: Record = { updatedAt: nowSec() }; if (input.displayName !== undefined) updates.displayName = input.displayName; let sendEmailChange: string | null = null; if (input.email !== undefined && input.email !== user.email) { const taken = db.select().from(users).where(eq(users.email, input.email)).get(); if (taken) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); updates.pendingEmail = input.email; sendEmailChange = input.email; } const [updated] = db.update(users).set(updates).where(eq(users.id, user.id)).returning().all(); if (sendEmailChange && updated) { const { plaintext } = await createAuthToken(db, updated.id, 'change_email', CHANGE_EMAIL_TTL, sendEmailChange); const tpl = changeEmailTemplate(appUrl(), plaintext, updated.displayName, sendEmailChange); await getMailer().send(sendEmailChange, tpl); } res.json(toPublicUser(updated!)); } catch (e) { next(e); } }); r.post('/change-password', requireAuth, async (req, res, next) => { try { const input = changePasswordSchema.parse(req.body); const user = req.user!; const row = db.select().from(users).where(eq(users.id, user.id)).get(); if (!row?.passwordHash || !(await verifyPassword(input.currentPassword, row.passwordHash))) { throw new ApiError(401, 'INVALID_CREDENTIALS', 'Current password is incorrect'); } const passwordHash = await hashPassword(input.newPassword); db.update(users).set({ passwordHash, updatedAt: nowSec() }).where(eq(users.id, user.id)).run(); if (req.sessionId) await invalidateAllForUser(db, user.id, { except: req.sessionId }); res.status(204).end(); } catch (e) { next(e); } }); return r; } ``` - [ ] **Step 5: Install supertest if not already** ```bash # Already installed per Task 3 backend bootstrap (supertest is in devDeps). # Verify: node -p "require('./packages/backend/node_modules/supertest/package.json').version" || npm i -D -w @flashcard/backend supertest @types/supertest ``` - [ ] **Step 6: Stop here — full integration test runs after app wiring (Task 14)** Don't run the integration test yet. It needs `createApp` to mount the new router. We'll un-skip it after Task 14. For now, just typecheck: ```bash npm -w @flashcard/backend run typecheck ``` Expected: passes. - [ ] **Step 7: Commit** ```bash git add packages/backend/src/routes/auth.ts packages/backend/src/tests/auth.integration.test.ts packages/backend/src/tests/dbHelper.ts git -c commit.gpgsign=false commit -m "feat(auth): /api/auth routes (register, verify, login, logout, me, forgot, reset, profile, change-password, accept-invite)" ``` --- ## Task 11: Users service & admin routes **Files:** - Create: `packages/backend/src/services/users.ts` - Create: `packages/backend/src/routes/admin-users.ts` - Create: `packages/backend/src/tests/admin-users.integration.test.ts` - [ ] **Step 1: Implement `services/users.ts`** ```ts import { and, asc, desc, eq, like, ne, or, sql } from 'drizzle-orm'; import type { Db } from '../db/client.js'; import { users } from '../db/schema.js'; import type { Role, User } from '@flashcard/shared'; import { ApiError } from '../lib/errors.js'; function rowToUser(r: typeof users.$inferSelect): User { return { id: r.id, email: r.email, displayName: r.displayName, role: r.role, isActive: r.isActive, emailVerifiedAt: r.emailVerifiedAt ?? null, pendingEmail: r.pendingEmail ?? null, createdAt: r.createdAt, updatedAt: r.updatedAt, }; } export interface ListUsersParams { q?: string; role?: Role; active?: boolean; limit?: number; offset?: number; } export interface ListUsersResult { rows: User[]; total: number; } export async function listUsers(db: Db, p: ListUsersParams = {}): Promise { const conditions = [] as ReturnType[]; if (p.q && p.q.trim() !== '') { const q = `%${p.q.toLowerCase()}%`; conditions.push(or(like(sql`lower(${users.email})`, q), like(sql`lower(${users.displayName})`, q))!); } if (p.role) conditions.push(eq(users.role, p.role)); if (p.active !== undefined) conditions.push(eq(users.isActive, p.active)); const where = conditions.length ? and(...conditions) : undefined; const limit = Math.min(200, p.limit ?? 50); const offset = Math.max(0, p.offset ?? 0); const rows = db.select().from(users) .where(where as any) .orderBy(asc(users.email)) .limit(limit).offset(offset).all(); const totalRow = db.select({ c: sql`count(*)`.as('c') }).from(users).where(where as any).get(); return { rows: rows.map(rowToUser), total: Number(totalRow?.c ?? 0) }; } export async function adminUpdateUser( db: Db, actorId: number, id: number, updates: { displayName?: string; role?: Role; isActive?: boolean } ): Promise { const target = db.select().from(users).where(eq(users.id, id)).get(); if (!target) throw ApiError.notFound('User'); // Last-sysadmin guard if (target.id === actorId && (updates.isActive === false || updates.role === 'user')) { const otherActiveSysadmins = db.select({ c: sql`count(*)`.as('c') }) .from(users) .where(and(eq(users.role, 'sysadmin'), eq(users.isActive, true), ne(users.id, actorId))) .get()?.c ?? 0; if (Number(otherActiveSysadmins) === 0) { throw new ApiError(409, 'LAST_SYSADMIN', 'Cannot demote or disable the last active sysadmin'); } } const [row] = db.update(users).set({ ...(updates.displayName !== undefined && { displayName: updates.displayName }), ...(updates.role !== undefined && { role: updates.role }), ...(updates.isActive !== undefined && { isActive: updates.isActive }), updatedAt: Math.floor(Date.now() / 1000), }).where(eq(users.id, id)).returning().all(); return rowToUser(row!); } ``` - [ ] **Step 2: Implement `routes/admin-users.ts`** ```ts import { Router } from 'express'; import { eq } from 'drizzle-orm'; import { adminUserUpdateSchema, inviteUserSchema, } from '@flashcard/shared'; import type { Db } from '../db/client.js'; import { users } from '../db/schema.js'; import { listUsers, adminUpdateUser } from '../services/users.js'; import { createAuthToken, invalidateTokensForUser } from '../services/auth/tokens.js'; import { getMailer } from '../services/auth/email.js'; import { inviteTemplate, passwordResetTemplate } from '../services/auth/templates.js'; import { ApiError } from '../lib/errors.js'; const INVITE_TTL = 24 * 60 * 60; const RESET_TTL = 60 * 60; function appUrl() { return process.env.APP_URL ?? 'http://localhost:5173'; } function nowSec() { return Math.floor(Date.now() / 1000); } export function adminUsersRouter(db: Db): Router { const r = Router(); r.get('/', async (req, res, next) => { try { const q = typeof req.query.q === 'string' ? req.query.q : undefined; const role = req.query.role === 'user' || req.query.role === 'sysadmin' ? req.query.role : undefined; const active = req.query.active === 'true' ? true : req.query.active === 'false' ? false : undefined; const limit = req.query.limit ? Number(req.query.limit) : undefined; const offset = req.query.offset ? Number(req.query.offset) : undefined; res.json(await listUsers(db, { q, role, active, limit, offset })); } catch (e) { next(e); } }); r.post('/invite', async (req, res, next) => { try { const input = inviteUserSchema.parse(req.body); const existing = db.select().from(users).where(eq(users.email, input.email)).get(); if (existing) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); const [u] = db.insert(users).values({ email: input.email, displayName: input.email.split('@')[0]!, role: input.role, passwordHash: null, emailVerifiedAt: null, isActive: true, }).returning().all(); const { plaintext } = await createAuthToken(db, u!.id, 'invite', INVITE_TTL); const inviter = req.user!; const tpl = inviteTemplate(appUrl(), plaintext, inviter.displayName); await getMailer().send(u!.email, tpl); res.status(201).json({ id: u!.id, email: u!.email, role: u!.role }); } catch (e) { next(e); } }); r.patch('/:id', async (req, res, next) => { try { const input = adminUserUpdateSchema.parse(req.body); const actor = req.user!; const updated = await adminUpdateUser(db, actor.id, Number(req.params.id), input); res.json(updated); } catch (e) { next(e); } }); r.post('/:id/send-reset', async (req, res, next) => { try { const id = Number(req.params.id); const u = db.select().from(users).where(eq(users.id, id)).get(); if (!u) throw ApiError.notFound('User'); if (!u.emailVerifiedAt) { // resend invite instead await invalidateTokensForUser(db, u.id, 'invite'); const { plaintext } = await createAuthToken(db, u.id, 'invite', INVITE_TTL); await getMailer().send(u.email, inviteTemplate(appUrl(), plaintext, req.user!.displayName)); } else { await invalidateTokensForUser(db, u.id, 'password_reset'); const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL); await getMailer().send(u.email, passwordResetTemplate(appUrl(), plaintext, u.displayName)); } res.status(204).end(); } catch (e) { next(e); } }); return r; } ``` - [ ] **Step 3: Write integration tests** Create `packages/backend/src/tests/admin-users.integration.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest'; import request from 'supertest'; import { eq } from 'drizzle-orm'; import { createApp } from '../app.js'; import { users } from '../db/schema.js'; import { makeTestDb, createUserDirect } from './dbHelper.js'; import { hashPassword } from '../services/auth/passwords.js'; import { setMailerForTests, type Mailer } from '../services/auth/email.js'; class CaptureMailer implements Mailer { sent: { to: string; subject: string; text: string }[] = []; async send(to: string, m: { subject: string; html: string; text: string }) { this.sent.push({ to, subject: m.subject, text: m.text }); } } async function loginAs(app: ReturnType, email: string, password = 'secretpass') { const r = await request(app).post('/api/auth/login').send({ email, password }); if (r.status !== 200) throw new Error(`login failed: ${r.status} ${JSON.stringify(r.body)}`); const cookies = (r.headers['set-cookie'] as unknown as string[]); const csrf = cookies.find((c) => c.startsWith('flashcard_csrf='))!.split(';')[0]!.split('=')[1]!; return { cookies, csrf }; } async function makeAdmin(env: ReturnType, email: string) { return createUserDirect(env.db, { email, role: 'sysadmin', isActive: true, passwordHash: await hashPassword('secretpass'), emailVerifiedAt: Math.floor(Date.now() / 1000), }); } let env: ReturnType; let mailer: CaptureMailer; let app: ReturnType; beforeEach(async () => { env = makeTestDb(); mailer = new CaptureMailer(); setMailerForTests(mailer); app = createApp(env.db); }); describe('admin users', () => { it('invites a user, target accepts and can log in', async () => { await makeAdmin(env, 'admin@example.com'); const { cookies, csrf } = await loginAs(app, 'admin@example.com'); const inv = await request(app).post('/api/admin/users/invite') .set('Cookie', cookies).set('x-csrf-token', csrf) .send({ email: 'newbie@example.com', role: 'user' }); expect(inv.status).toBe(201); expect(mailer.sent).toHaveLength(1); const link = mailer.sent[0]!.text; const token = decodeURIComponent(link.match(/token=([^\s&"]+)/)![1]!); const accept = await request(app).post('/api/auth/accept-invite') .send({ token, displayName: 'Newbie', password: 'anotherpass' }); expect(accept.status).toBe(200); expect(accept.body.email).toBe('newbie@example.com'); }); it('lists users with filtering', async () => { await makeAdmin(env, 'admin@example.com'); await createUserDirect(env.db, { email: 'a@example.com', role: 'user' }); await createUserDirect(env.db, { email: 'b@example.com', role: 'user' }); const { cookies } = await loginAs(app, 'admin@example.com'); const list = await request(app).get('/api/admin/users?q=a').set('Cookie', cookies); expect(list.status).toBe(200); expect(list.body.rows.find((u: { email: string }) => u.email === 'a@example.com')).toBeTruthy(); }); it('blocks demoting the last sysadmin', async () => { const admin = await makeAdmin(env, 'admin@example.com'); const { cookies, csrf } = await loginAs(app, 'admin@example.com'); const r = await request(app).patch(`/api/admin/users/${admin.id}`) .set('Cookie', cookies).set('x-csrf-token', csrf) .send({ role: 'user' }); expect(r.status).toBe(409); expect(r.body.error.code).toBe('LAST_SYSADMIN'); }); it('rejects non-admin access to admin routes', async () => { // Create a normal verified user await createUserDirect(env.db, { email: 'plain@example.com', role: 'user', isActive: true, passwordHash: await hashPassword('secretpass'), emailVerifiedAt: Math.floor(Date.now() / 1000), }); const { cookies } = await loginAs(app, 'plain@example.com'); const r = await request(app).get('/api/admin/users').set('Cookie', cookies); expect(r.status).toBe(403); }); it('blocks unauthenticated access to admin routes', async () => { const r = await request(app).get('/api/admin/users'); expect(r.status).toBe(401); }); }); ``` - [ ] **Step 4: Typecheck** ```bash npm -w @flashcard/backend run typecheck ``` - [ ] **Step 5: Commit** ```bash git add packages/backend/src/services/users.ts packages/backend/src/routes/admin-users.ts packages/backend/src/tests/admin-users.integration.test.ts git -c commit.gpgsign=false commit -m "feat(auth): admin user management service, routes, and integration tests" ``` --- ## Task 12: Wire auth into app.ts + protect existing endpoints **Files:** - Modify: `packages/backend/src/app.ts` - Modify: `packages/backend/src/index.ts` - [ ] **Step 1: Rewrite `app.ts`** Replace `packages/backend/src/app.ts` with: ```ts import express, { type Express, type NextFunction, type Request, type Response } from 'express'; import cookieParser from 'cookie-parser'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { ZodError } from 'zod'; import type { Db } from './db/client.js'; import { ApiError } from './lib/errors.js'; import { currentUserOrNull, requireAuth, requireRole } from './middleware/auth.js'; import { ensureCsrfToken, verifyCsrf } from './middleware/csrf.js'; import { authRouter } from './routes/auth.js'; import { adminUsersRouter } from './routes/admin-users.js'; import { lessonsRouter } from './routes/lessons.js'; import { cardsRouter } from './routes/cards.js'; import { sessionsRouter } from './routes/sessions.js'; import { statsRouter } from './routes/stats.js'; export function createApp(db: Db): Express { const app = express(); app.set('trust proxy', 1); app.use(cookieParser()); app.use(express.json({ limit: '5mb' })); app.use(ensureCsrfToken); app.use(currentUserOrNull(db)); app.get('/api/health', (_req, res) => res.json({ ok: true })); // Public auth endpoints — verifyCsrf applied INSIDE the router for mutations that don't need it // (register/login/forgot/reset/verify/accept-invite/resend-verification are public mutations, so CSRF is not relevant). // However, /api/auth/logout, /api/auth/profile, /api/auth/change-password DO need CSRF. app.use('/api/auth', authRouter(db)); // Protected app routes — auth + csrf on mutations app.use('/api/lessons', requireAuth, verifyCsrf, lessonsRouter(db)); app.use('/api', requireAuth, verifyCsrf, cardsRouter(db)); app.use('/api/sessions', requireAuth, verifyCsrf, sessionsRouter(db)); app.use('/api/stats', requireAuth, statsRouter(db)); // GET only — no CSRF needed app.use('/api/admin/users', requireAuth, requireRole('sysadmin'), verifyCsrf, adminUsersRouter(db)); // Static frontend in production const frontendDist = resolve(import.meta.dirname, '../../frontend/dist'); if (existsSync(frontendDist)) { app.use(express.static(frontendDist)); app.get('*', (_req, res) => res.sendFile(resolve(frontendDist, 'index.html'))); } app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { if (err instanceof ZodError) { res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() } }); return; } if (err instanceof ApiError) { res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } }); return; } console.error(err); res.status(500).json({ error: { code: 'INTERNAL', message: 'Internal server error' } }); }); return app; } ``` Note: `verifyCsrf` is not applied to the entire `/api/auth` router because login/register/forgot/reset don't have a session yet — they bootstrap the cookie set. Mutations that DO have a session (logout, profile, change-password) get CSRF protection added inline in Task 13. - [ ] **Step 2: Add CSRF to authenticated auth mutations** Modify `packages/backend/src/routes/auth.ts` — wrap the three authenticated mutation routes with `verifyCsrf`: ```ts import { verifyCsrf } from '../middleware/csrf.js'; // ... r.post('/logout', requireAuth, verifyCsrf, async (req, res, next) => { /* unchanged */ }); r.patch('/profile', requireAuth, verifyCsrf, async (req, res, next) => { /* unchanged */ }); r.post('/change-password', requireAuth, verifyCsrf, async (req, res, next) => { /* unchanged */ }); ``` (The other auth endpoints remain unprotected — they're public mutations.) - [ ] **Step 3: Run all backend tests** ```bash npm -w @flashcard/backend test ``` Expected: all auth + admin-users integration tests pass, plus existing unit tests. Existing lesson/card/session/stats tests do NOT go through HTTP, so they're unaffected by the new requireAuth middleware. - [ ] **Step 4: Commit** ```bash git add packages/backend/src/app.ts packages/backend/src/routes/auth.ts git -c commit.gpgsign=false commit -m "feat(auth): wire auth middleware in app, protect all /api endpoints" ``` --- ## Task 13: Dev tooling — docker-compose for Mailpit + .env.example + README **Files:** - Create: `docker-compose.yml` - Create: `.env.example` - Modify: `README.md` - [ ] **Step 1: Create `docker-compose.yml`** ```yaml services: mailpit: image: axllent/mailpit:latest container_name: flashcard-mailpit ports: - "1025:1025" # SMTP - "8025:8025" # Web UI environment: MP_MAX_MESSAGES: 5000 restart: unless-stopped ``` - [ ] **Step 2: Create `.env.example`** ``` # Backend PORT=3000 DB_PATH=./data/flashcard.db APP_URL=http://localhost:5173 # Cookies COOKIE_SECURE=false # SMTP (Mailpit dev defaults; override in production with SES SMTP) SMTP_HOST=localhost SMTP_PORT=1025 SMTP_SECURE=false SMTP_USER= SMTP_PASS= SMTP_FROM="Flashcard " ``` - [ ] **Step 3: Append README section** Append to `README.md`: ```markdown ## Auth & e-mail De applicatie zit achter een login. Eerste registratie (POST /api/auth/register via /register pagina) wordt automatisch sysadmin. ### Lokaal e-mail (Mailpit) ```bash docker compose up -d mailpit # Web UI: http://localhost:8025 # SMTP: localhost:1025 ``` Kopieer `.env.example` → `.env` in repo-root, of zet de waarden inline. ### Productie (Amazon SES) In productie: ``` SMTP_HOST=email-smtp.eu-west-1.amazonaws.com SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM="Flashcard " COOKIE_SECURE=true APP_URL=https://yourdomain.com ``` ### Fallback (geen SMTP) Als `SMTP_HOST` ontbreekt, schrijft het systeem de e-mails (incl. links) naar de server-log. ``` - [ ] **Step 4: Commit** ```bash git add docker-compose.yml .env.example README.md git -c commit.gpgsign=false commit -m "chore: docker-compose mailpit, env.example, README auth section" ``` --- ## Task 14: Frontend API client — CSRF + auth modules **Files:** - Modify: `packages/frontend/src/api/client.ts` - Create: `packages/frontend/src/api/auth.ts` - Create: `packages/frontend/src/api/admin-users.ts` - [ ] **Step 1: Rewrite `client.ts` with CSRF header + 401 handling** ```ts const CSRF_COOKIE = 'flashcard_csrf'; function readCookie(name: string): string | null { const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]+)`)); return m ? decodeURIComponent(m[1]!) : null; } export class ApiClientError extends Error { constructor(public status: number, public code: string, message: string, public details?: unknown) { super(message); } } const listeners = new Set<() => void>(); export function onUnauthorized(cb: () => void): () => void { listeners.add(cb); return () => listeners.delete(cb); } async function request( method: string, path: string, body?: unknown, opts?: { isFormData?: boolean } ): Promise { const headers: Record = {}; let payload: BodyInit | undefined; if (opts?.isFormData) { payload = body as FormData; } else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); } if (method !== 'GET' && method !== 'HEAD') { const csrf = readCookie(CSRF_COOKIE); if (csrf) headers['X-CSRF-Token'] = csrf; } const res = await fetch(`/api${path}`, { method, headers, body: payload, credentials: 'same-origin', }); if (res.status === 204) return undefined as T; const isJson = res.headers.get('content-type')?.includes('application/json'); const data = isJson ? await res.json() : await res.blob(); if (!res.ok) { const e = (data as { error?: { code: string; message: string; details?: unknown } }).error; if (res.status === 401) listeners.forEach((l) => l()); throw new ApiClientError(res.status, e?.code ?? 'UNKNOWN', e?.message ?? 'Request failed', e?.details); } return data as T; } export const api = { get: (path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body), postForm: (path: string, form: FormData) => request('POST', path, form, { isFormData: true }), patch: (path: string, body: unknown) => request('PATCH', path, body), delete: (path: string) => request('DELETE', path), getBlob: async (path: string): Promise => { const res = await fetch(`/api${path}`, { credentials: 'same-origin' }); if (!res.ok) throw new ApiClientError(res.status, 'UNKNOWN', 'Request failed'); return res.blob(); }, }; ``` - [ ] **Step 2: Create `api/auth.ts`** ```ts import type { PublicUser, User, LoginInput, RegisterInput, VerifyEmailInput, ResendVerificationInput, ForgotPasswordInput, ResetPasswordInput, AcceptInviteInput, ProfileUpdateInput, ChangePasswordInput, } from '@flashcard/shared'; import { api } from './client.js'; export const authApi = { me: () => api.get('/auth/me'), register: (input: RegisterInput) => api.post('/auth/register', input), verifyEmail: (input: VerifyEmailInput) => api.post<{ ok: true }>('/auth/verify-email', input), resendVerification: (input: ResendVerificationInput) => api.post<{ ok: true }>('/auth/resend-verification', input), login: (input: LoginInput) => api.post('/auth/login', input), logout: () => api.post('/auth/logout'), forgotPassword: (input: ForgotPasswordInput) => api.post<{ ok: true }>('/auth/forgot-password', input), resetPassword: (input: ResetPasswordInput) => api.post<{ ok: true }>('/auth/reset-password', input), acceptInvite: (input: AcceptInviteInput) => api.post('/auth/accept-invite', input), updateProfile: (input: ProfileUpdateInput) => api.patch('/auth/profile', input), changePassword: (input: ChangePasswordInput) => api.post('/auth/change-password', input), }; ``` - [ ] **Step 3: Create `api/admin-users.ts`** ```ts import type { User, AdminUserUpdateInput, InviteUserInput, Role } from '@flashcard/shared'; import { api } from './client.js'; export interface ListUsersResponse { rows: User[]; total: number; } export const adminUsersApi = { list: (params: { q?: string; role?: Role; active?: boolean; limit?: number; offset?: number } = {}) => { const qs = new URLSearchParams(); if (params.q) qs.set('q', params.q); if (params.role) qs.set('role', params.role); if (params.active !== undefined) qs.set('active', String(params.active)); if (params.limit !== undefined) qs.set('limit', String(params.limit)); if (params.offset !== undefined) qs.set('offset', String(params.offset)); const s = qs.toString(); return api.get(`/admin/users${s ? '?' + s : ''}`); }, invite: (input: InviteUserInput) => api.post<{ id: number; email: string; role: Role }>('/admin/users/invite', input), update: (id: number, input: AdminUserUpdateInput) => api.patch(`/admin/users/${id}`, input), sendReset: (id: number) => api.post(`/admin/users/${id}/send-reset`), }; ``` - [ ] **Step 4: Typecheck and commit** ```bash npm -w @flashcard/frontend run typecheck git add packages/frontend/src/api/ git -c commit.gpgsign=false commit -m "feat(frontend): API client CSRF support + auth and admin-users API modules" ``` --- ## Task 15: Frontend authStore **Files:** - Create: `packages/frontend/src/stores/authStore.ts` - [ ] **Step 1: Create `authStore.ts`** ```ts import { create } from 'zustand'; import type { User } from '@flashcard/shared'; import { authApi } from '../api/auth.js'; import { ApiClientError } from '../api/client.js'; interface AuthState { user: User | null; loading: boolean; ready: boolean; hydrate: () => Promise; refreshMe: () => Promise; login: (email: string, password: string) => Promise; logout: () => Promise; setUserFromAuthResponse: (publicUser: { id: number; email: string; displayName: string; role: 'user' | 'sysadmin' }) => void; } export const useAuth = create((set, get) => ({ user: null, loading: false, ready: false, hydrate: async () => { set({ loading: true }); try { const user = await authApi.me(); set({ user, ready: true }); } catch (e) { if (e instanceof ApiClientError && e.status === 401) { set({ user: null, ready: true }); return; } set({ user: null, ready: true }); } finally { set({ loading: false }); } }, refreshMe: async () => { try { set({ user: await authApi.me() }); } catch { set({ user: null }); } }, login: async (email, password) => { await authApi.login({ email, password }); await get().refreshMe(); }, logout: async () => { await authApi.logout(); set({ user: null }); }, setUserFromAuthResponse: (pu) => { // Optimistic set after register/accept-invite where backend returns PublicUser set({ user: { id: pu.id, email: pu.email, displayName: pu.displayName, role: pu.role, isActive: true, emailVerifiedAt: Math.floor(Date.now() / 1000), pendingEmail: null, createdAt: 0, updatedAt: 0, }, }); }, })); ``` - [ ] **Step 2: Commit** ```bash git add packages/frontend/src/stores/authStore.ts git -c commit.gpgsign=false commit -m "feat(frontend): authStore (Zustand)" ``` --- ## Task 16: AuthBoundary, RoleGuard, UserMenu + Layout integration **Files:** - Create: `packages/frontend/src/components/AuthBoundary.tsx` - Create: `packages/frontend/src/components/RoleGuard.tsx` - Create: `packages/frontend/src/components/UserMenu.tsx` - Modify: `packages/frontend/src/components/Layout.tsx` - Modify: `packages/frontend/src/main.tsx` - [ ] **Step 1: Create `AuthBoundary.tsx`** ```tsx import { useEffect } from 'react'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useAuth } from '../stores/authStore.js'; export function AuthBoundary() { const { user, ready, hydrate } = useAuth(); const location = useLocation(); useEffect(() => { if (!ready) hydrate(); }, [ready, hydrate]); if (!ready) { return (
); } if (!user) { const next = encodeURIComponent(location.pathname + location.search); return ; } return ; } ``` - [ ] **Step 2: Create `RoleGuard.tsx`** ```tsx import { Navigate, Outlet } from 'react-router-dom'; import type { Role } from '@flashcard/shared'; import { useAuth } from '../stores/authStore.js'; export function RoleGuard({ role }: { role: Role }) { const user = useAuth((s) => s.user); if (!user) return ; if (user.role !== role) { return (

Geen toegang

Deze pagina is alleen voor beheerders.

); } return ; } ``` - [ ] **Step 3: Create `UserMenu.tsx`** ```tsx import { useState, useRef, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../stores/authStore.js'; export function UserMenu() { const { user, logout } = useAuth(); const [open, setOpen] = useState(false); const ref = useRef(null); const navigate = useNavigate(); useEffect(() => { function onClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, []); if (!user) return null; const initials = user.displayName.trim().split(/\s+/).map((p) => p[0]).slice(0, 2).join('').toUpperCase(); async function handleLogout() { await logout(); navigate('/login'); } return (
{open && (
{user.displayName}
{user.email}

setOpen(false)}>Profiel {user.role === 'sysadmin' && ( setOpen(false)}>👑 Systeembeheer )}
)}
); } ``` - [ ] **Step 4: Replace `Layout.tsx`** ```tsx import { NavLink, Outlet } from 'react-router-dom'; import { useSettings } from '../stores/settingsStore.js'; import { useAuth } from '../stores/authStore.js'; import { UserMenu } from './UserMenu.js'; const navItems = [ { to: '/', label: 'Dashboard', end: true }, { to: '/admin', label: 'Lessen' }, { to: '/stats', label: 'Stats' }, ]; export function Layout() { const { theme, toggleTheme } = useSettings(); const user = useAuth((s) => s.user); return (
Flashcards {user && ( )}
{user && ( )}
); } ``` - [ ] **Step 5: Hook the 401-handler in `main.tsx` to bounce to /login** Replace `packages/frontend/src/main.tsx` with: ```tsx import React from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import { router } from './router.js'; import { useSettings } from './stores/settingsStore.js'; import { useAuth } from './stores/authStore.js'; import { onUnauthorized } from './api/client.js'; import './styles.css'; useSettings.getState().hydrate(); onUnauthorized(() => { useAuth.setState({ user: null, ready: true }); }); const root = createRoot(document.getElementById('root')!); root.render( ); ``` - [ ] **Step 6: Typecheck + commit** ```bash npm -w @flashcard/frontend run typecheck git add packages/frontend/src/components/ packages/frontend/src/main.tsx git -c commit.gpgsign=false commit -m "feat(frontend): AuthBoundary, RoleGuard, UserMenu + Layout integration" ``` --- ## Task 17: Auth pages — Login + Register **Files:** - Create: `packages/frontend/src/pages/auth/Login.tsx` - Create: `packages/frontend/src/pages/auth/Register.tsx` - [ ] **Step 1: Create `Login.tsx`** ```tsx import { useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { authApi } from '../../api/auth.js'; import { ApiClientError } from '../../api/client.js'; import { useAuth } from '../../stores/authStore.js'; export function LoginPage() { const [searchParams] = useSearchParams(); const next = searchParams.get('next') ?? '/'; const navigate = useNavigate(); const login = useAuth((s) => s.login); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [needsVerification, setNeedsVerification] = useState(false); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); setNeedsVerification(false); try { await login(email, password); navigate(next, { replace: true }); } catch (err) { if (err instanceof ApiClientError) { if (err.code === 'EMAIL_NOT_VERIFIED') setNeedsVerification(true); else setError(err.message); } else setError('Onbekende fout'); } finally { setBusy(false); } } async function resend() { try { await authApi.resendVerification({ email }); setError('Bevestigingsmail opnieuw verstuurd.'); } catch { setError('Kon mail niet versturen.'); } } return (
setEmail(e.target.value)} autoComplete="email" /> setPassword(e.target.value)} autoComplete="current-password" /> {error &&

{error}

} {needsVerification && (
Je e-mailadres is nog niet bevestigd.
)}
Wachtwoord vergeten? Account aanmaken
); } export function AuthLayout({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } export function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( ); } ``` - [ ] **Step 2: Create `Register.tsx`** ```tsx import { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { authApi } from '../../api/auth.js'; import { ApiClientError } from '../../api/client.js'; import { AuthLayout, Field } from './Login.js'; export function RegisterPage() { const navigate = useNavigate(); const [email, setEmail] = useState(''); const [displayName, setDisplayName] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [done, setDone] = useState(false); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); try { await authApi.register({ email, displayName, password }); setDone(true); } catch (err) { if (err instanceof ApiClientError) setError(err.message); else setError('Onbekende fout'); } finally { setBusy(false); } } if (done) { return (

We hebben een bevestigingsmail gestuurd naar {email}. Klik op de link om je account te activeren.

Terug naar inloggen
); } return (
setDisplayName(e.target.value)} autoComplete="name" /> setEmail(e.target.value)} autoComplete="email" /> setPassword(e.target.value)} autoComplete="new-password" /> {error &&

{error}

}

De eerste registratie wordt automatisch beheerder.

Heb je al een account? Inloggen
); } ``` - [ ] **Step 3: Typecheck and commit** ```bash npm -w @flashcard/frontend run typecheck git add packages/frontend/src/pages/auth/ git -c commit.gpgsign=false commit -m "feat(frontend): Login + Register pages" ``` --- ## Task 18: Auth pages — VerifyEmail, ForgotPassword, ResetPassword, AcceptInvite **Files:** - Create: `packages/frontend/src/pages/auth/VerifyEmail.tsx` - Create: `packages/frontend/src/pages/auth/ForgotPassword.tsx` - Create: `packages/frontend/src/pages/auth/ResetPassword.tsx` - Create: `packages/frontend/src/pages/auth/AcceptInvite.tsx` - [ ] **Step 1: Create `VerifyEmail.tsx`** ```tsx import { useEffect, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { authApi } from '../../api/auth.js'; import { ApiClientError } from '../../api/client.js'; import { AuthLayout } from './Login.js'; export function VerifyEmailPage() { const [params] = useSearchParams(); const token = params.get('token'); const [state, setState] = useState<'pending' | 'ok' | 'err'>('pending'); const [message, setMessage] = useState(''); useEffect(() => { if (!token) { setState('err'); setMessage('Token ontbreekt.'); return; } (async () => { try { await authApi.verifyEmail({ token }); setState('ok'); } catch (e) { setState('err'); setMessage(e instanceof ApiClientError ? e.message : 'Verificatie mislukt.'); } })(); }, [token]); return ( {state === 'pending' &&

Bezig met verifiëren…

} {state === 'ok' && ( <>

Je e-mailadres is bevestigd ✅

Naar inloggen )} {state === 'err' && ( <>

{message}

Terug naar inloggen )}
); } ``` - [ ] **Step 2: Create `ForgotPassword.tsx`** ```tsx import { useState } from 'react'; import { Link } from 'react-router-dom'; import { authApi } from '../../api/auth.js'; import { AuthLayout, Field } from './Login.js'; export function ForgotPasswordPage() { const [email, setEmail] = useState(''); const [busy, setBusy] = useState(false); const [sent, setSent] = useState(false); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); try { await authApi.forgotPassword({ email }); } finally { setBusy(false); setSent(true); } } if (sent) { return (

Als {email} bekend is, hebben we een reset-link gestuurd. De link is 1 uur geldig.

Terug naar inloggen
); } return (
setEmail(e.target.value)} autoComplete="email" />
Terug naar inloggen
); } ``` - [ ] **Step 3: Create `ResetPassword.tsx`** ```tsx import { useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { authApi } from '../../api/auth.js'; import { ApiClientError } from '../../api/client.js'; import { AuthLayout, Field } from './Login.js'; export function ResetPasswordPage() { const [params] = useSearchParams(); const token = params.get('token') ?? ''; const navigate = useNavigate(); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [done, setDone] = useState(false); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); try { await authApi.resetPassword({ token, password }); setDone(true); } catch (err) { setError(err instanceof ApiClientError ? err.message : 'Reset mislukt.'); } finally { setBusy(false); } } if (done) { return (

Je kunt nu inloggen met je nieuwe wachtwoord.

); } return ( {!token &&

Geen token in URL.

}
setPassword(e.target.value)} autoComplete="new-password" /> {error &&

{error}

}
Terug naar inloggen
); } ``` - [ ] **Step 4: Create `AcceptInvite.tsx`** ```tsx import { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { authApi } from '../../api/auth.js'; import { ApiClientError } from '../../api/client.js'; import { useAuth } from '../../stores/authStore.js'; import { AuthLayout, Field } from './Login.js'; export function AcceptInvitePage() { const [params] = useSearchParams(); const token = params.get('token') ?? ''; const navigate = useNavigate(); const refreshMe = useAuth((s) => s.refreshMe); const [displayName, setDisplayName] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); try { await authApi.acceptInvite({ token, displayName, password }); await refreshMe(); navigate('/', { replace: true }); } catch (err) { setError(err instanceof ApiClientError ? err.message : 'Accepteren mislukt.'); } finally { setBusy(false); } } return ( {!token &&

Geen token in URL.

}
setDisplayName(e.target.value)} autoComplete="name" /> setPassword(e.target.value)} autoComplete="new-password" /> {error &&

{error}

}
); } ``` - [ ] **Step 5: Typecheck + commit** ```bash npm -w @flashcard/frontend run typecheck git add packages/frontend/src/pages/auth/ git -c commit.gpgsign=false commit -m "feat(frontend): VerifyEmail + ForgotPassword + ResetPassword + AcceptInvite pages" ``` --- ## Task 19: Profile page **Files:** - Create: `packages/frontend/src/pages/Profile.tsx` - [ ] **Step 1: Create `Profile.tsx`** ```tsx import { useState } from 'react'; import { authApi } from '../api/auth.js'; import { ApiClientError } from '../api/client.js'; import { useAuth } from '../stores/authStore.js'; export function ProfilePage() { const { user, refreshMe } = useAuth(); const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [email, setEmail] = useState(user?.email ?? ''); const [profileBusy, setProfileBusy] = useState(false); const [profileMsg, setProfileMsg] = useState(null); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [pwBusy, setPwBusy] = useState(false); const [pwMsg, setPwMsg] = useState(null); const [pwErr, setPwErr] = useState(null); async function saveProfile(e: React.FormEvent) { e.preventDefault(); setProfileBusy(true); setProfileMsg(null); try { const updates: { displayName?: string; email?: string } = {}; if (displayName !== user?.displayName) updates.displayName = displayName; if (email !== user?.email) updates.email = email; if (Object.keys(updates).length === 0) { setProfileMsg('Geen wijzigingen.'); return; } await authApi.updateProfile(updates); await refreshMe(); setProfileMsg(updates.email ? 'Profiel opgeslagen. Bevestig je nieuwe e-mailadres via de link in de mail.' : 'Profiel opgeslagen.'); } catch (err) { setProfileMsg(err instanceof ApiClientError ? err.message : 'Opslaan mislukt.'); } finally { setProfileBusy(false); } } async function changePassword(e: React.FormEvent) { e.preventDefault(); setPwBusy(true); setPwMsg(null); setPwErr(null); try { await authApi.changePassword({ currentPassword, newPassword }); setPwMsg('Wachtwoord gewijzigd.'); setCurrentPassword(''); setNewPassword(''); } catch (err) { setPwErr(err instanceof ApiClientError ? err.message : 'Wijzigen mislukt.'); } finally { setPwBusy(false); } } if (!user) return null; return (

Profiel

Beheer je naam, e-mailadres en wachtwoord.

Gegevens

{profileMsg &&

{profileMsg}

}

Wachtwoord

{pwMsg &&

{pwMsg}

} {pwErr &&

{pwErr}

}
); } ``` - [ ] **Step 2: Commit** ```bash git add packages/frontend/src/pages/Profile.tsx git -c commit.gpgsign=false commit -m "feat(frontend): profile page (display name, email, change password)" ``` --- ## Task 20: AdminUsers page **Files:** - Create: `packages/frontend/src/pages/AdminUsers.tsx` - [ ] **Step 1: Create `AdminUsers.tsx`** ```tsx import { useEffect, useState } from 'react'; import { adminUsersApi, type ListUsersResponse } from '../api/admin-users.js'; import type { User } from '@flashcard/shared'; import { ApiClientError } from '../api/client.js'; export function AdminUsersPage() { const [data, setData] = useState({ rows: [], total: 0 }); const [q, setQ] = useState(''); const [busy, setBusy] = useState(false); const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState<'user' | 'sysadmin'>('user'); const [inviteMsg, setInviteMsg] = useState(null); async function refresh() { setBusy(true); try { setData(await adminUsersApi.list({ q: q.trim() || undefined })); } finally { setBusy(false); } } useEffect(() => { refresh(); }, []); async function invite(e: React.FormEvent) { e.preventDefault(); setInviteMsg(null); try { await adminUsersApi.invite({ email: inviteEmail, role: inviteRole }); setInviteMsg('Uitnodiging verstuurd.'); setInviteEmail(''); await refresh(); } catch (err) { setInviteMsg(err instanceof ApiClientError ? err.message : 'Uitnodigen mislukt.'); } } async function setActive(u: User, isActive: boolean) { try { await adminUsersApi.update(u.id, { isActive }); await refresh(); } catch (err) { alert(err instanceof ApiClientError ? err.message : 'Bijwerken mislukt.'); } } async function setRole(u: User, role: 'user' | 'sysadmin') { try { await adminUsersApi.update(u.id, { role }); await refresh(); } catch (err) { alert(err instanceof ApiClientError ? err.message : 'Bijwerken mislukt.'); } } async function sendReset(u: User) { if (!confirm(`Reset/uitnodigingsmail sturen naar ${u.email}?`)) return; try { await adminUsersApi.sendReset(u.id); alert('Mail verstuurd.'); } catch (err) { alert(err instanceof ApiClientError ? err.message : 'Versturen mislukt.'); } } return (

👑 Gebruikersbeheer

{data.total} gebruiker(s) totaal

{inviteMsg &&

{inviteMsg}

}
setQ(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && refresh()} />
{data.rows.map((u) => ( ))}
Email Naam Rol Status
{u.email} {u.displayName} {u.isActive ? ( actief ) : ( uit )} {!u.emailVerifiedAt && ( onbevestigd )}
); } ``` - [ ] **Step 2: Commit** ```bash git add packages/frontend/src/pages/AdminUsers.tsx git -c commit.gpgsign=false commit -m "feat(frontend): admin users page (invite, role, activate, send-reset)" ``` --- ## Task 21: Router — wire all auth routes + protect existing routes **Files:** - Modify: `packages/frontend/src/router.tsx` - [ ] **Step 1: Replace `router.tsx`** ```tsx import { createBrowserRouter, Navigate } from 'react-router-dom'; import { Layout } from './components/Layout.js'; import { AuthBoundary } from './components/AuthBoundary.js'; import { RoleGuard } from './components/RoleGuard.js'; import { DashboardPage } from './pages/Dashboard.js'; import { AdminPage } from './pages/Admin.js'; import { AdminLessonPage } from './pages/AdminLesson.js'; import { PracticeSetupPage } from './pages/PracticeSetup.js'; import { PracticePage } from './pages/Practice.js'; import { PracticeDonePage } from './pages/PracticeDone.js'; import { StatsPage } from './pages/Stats.js'; import { StatsLessonPage } from './pages/StatsLesson.js'; import { StatsCardPage } from './pages/StatsCard.js'; import { SettingsPage } from './pages/Settings.js'; import { LoginPage } from './pages/auth/Login.js'; import { RegisterPage } from './pages/auth/Register.js'; import { VerifyEmailPage } from './pages/auth/VerifyEmail.js'; import { ForgotPasswordPage } from './pages/auth/ForgotPassword.js'; import { ResetPasswordPage } from './pages/auth/ResetPassword.js'; import { AcceptInvitePage } from './pages/auth/AcceptInvite.js'; import { ProfilePage } from './pages/Profile.js'; import { AdminUsersPage } from './pages/AdminUsers.js'; export const router = createBrowserRouter([ { path: '/', element: , children: [ // Public auth routes { path: 'login', element: }, { path: 'register', element: }, { path: 'verify-email', element: }, { path: 'forgot-password', element: }, { path: 'reset-password', element: }, { path: 'accept-invite', element: }, // Authenticated routes { element: , children: [ { index: true, element: }, { path: 'admin', element: }, { path: 'admin/lessons/:id', element: }, { path: 'practice/:lessonId/setup', element: }, { path: 'practice/:lessonId', element: }, { path: 'practice/:lessonId/done', element: }, { path: 'stats', element: }, { path: 'stats/lessons/:id', element: }, { path: 'stats/cards/:id', element: }, { path: 'settings', element: }, { path: 'profile', element: }, { element: , children: [ { path: 'admin/users', element: }, ], }, { path: '*', element: }, ], }, ], }, ]); ``` - [ ] **Step 2: Typecheck + frontend build** ```bash npm -w @flashcard/frontend run typecheck npm -w @flashcard/frontend run build ``` Expected: both succeed. - [ ] **Step 3: Commit** ```bash git add packages/frontend/src/router.tsx git -c commit.gpgsign=false commit -m "feat(frontend): router with auth boundary, role guard, and all auth pages" ``` --- ## Task 22: Update existing E2E smoke + add new auth E2E (Mailpit-based) **Files:** - Modify: `e2e/smoke.spec.ts` - Create: `e2e/auth.spec.ts` - Modify: `packages/frontend/playwright.config.ts` - [ ] **Step 1: Update playwright config to use mailpit and a unique DB per run** ```ts import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: '../../e2e', webServer: [ { command: 'rm -f packages/backend/data/e2e.db data/e2e.db && DB_PATH=./data/e2e.db SMTP_HOST=localhost SMTP_PORT=1025 APP_URL=http://localhost:5173 npm -w @flashcard/backend run db:migrate && DB_PATH=./data/e2e.db SMTP_HOST=localhost SMTP_PORT=1025 APP_URL=http://localhost:5173 npm -w @flashcard/backend run dev', cwd: '../..', port: 3000, reuseExistingServer: false, timeout: 60_000, }, { command: 'npm -w @flashcard/frontend run dev', cwd: '../..', port: 5173, reuseExistingServer: false, timeout: 60_000, }, ], use: { baseURL: 'http://localhost:5173' }, timeout: 30_000, }); ``` - [ ] **Step 2: Replace existing `e2e/smoke.spec.ts` with a flow that first registers/logs in** ```ts import { test, expect } from '@playwright/test'; async function fetchVerifyLink(email: string): Promise { // Mailpit HTTP API: list latest message and pull link from text body const res = await fetch('http://localhost:8025/api/v1/messages?limit=10'); const data = await res.json() as { messages: { ID: string; To: { Address: string }[] }[] }; const msg = data.messages.find((m) => m.To.some((t) => t.Address === email)); if (!msg) throw new Error('no message for ' + email); const body = await fetch(`http://localhost:8025/api/v1/message/${msg.ID}`); const full = await body.json() as { Text: string }; const match = full.Text.match(/https?:\/\/[^\s]+verify-email\?token=[^\s]+/); if (!match) throw new Error('no verify link'); return match[0]; } test('register → verify → login → create lesson → add card → practice once', async ({ page, request }) => { const email = `user+${Date.now()}@example.com`; const password = 'secretpass'; await page.goto('/register'); await page.getByLabel(/Naam/).fill('E2E User'); await page.getByLabel(/E-mailadres/).fill(email); await page.getByLabel(/Wachtwoord/).fill(password); await page.getByRole('button', { name: /Account aanmaken/ }).click(); await expect(page.getByText(/bevestigingsmail/i)).toBeVisible({ timeout: 10_000 }); const link = await fetchVerifyLink(email); await page.goto(link); await expect(page.getByText(/bevestigd/i)).toBeVisible(); await page.goto('/login'); await page.getByLabel(/E-mailadres/).fill(email); await page.getByLabel(/Wachtwoord/).fill(password); await page.getByRole('button', { name: 'Inloggen' }).click(); await page.goto('/admin'); await page.getByPlaceholder(/Nieuwe wortel-les/).fill('E2E les'); await page.getByRole('button', { name: /Toevoegen/ }).first().click(); await page.getByRole('link', { name: /E2E les/ }).first().click(); await page.getByPlaceholder('Nieuwe vraag').fill('q1'); await page.getByPlaceholder('Antwoord').fill('a1'); await page.getByRole('button', { name: 'Kaart toevoegen' }).click(); await page.getByRole('link', { name: /Start oefenen/ }).click(); await page.getByRole('button', { name: /Start sessie/ }).click(); await page.getByRole('button', { name: 'Toon antwoord' }).click(); await page.getByRole('button', { name: /Goed/ }).click(); await expect(page.getByText(/Sessie klaar/)).toBeVisible({ timeout: 8_000 }); }); ``` - [ ] **Step 3: Create `e2e/auth.spec.ts` — admin invites a user** ```ts import { test, expect } from '@playwright/test'; async function fetchLink(email: string, kind: 'invite' | 'verify-email' | 'reset-password'): Promise { for (let i = 0; i < 20; i++) { const res = await fetch('http://localhost:8025/api/v1/messages?limit=20'); const data = await res.json() as { messages: { ID: string; To: { Address: string }[] }[] }; const msg = data.messages.find((m) => m.To.some((t) => t.Address === email)); if (msg) { const full = await (await fetch(`http://localhost:8025/api/v1/message/${msg.ID}`)).json() as { Text: string }; const pattern = new RegExp(`https?:\\/\\/[^\\s]+${kind}\\?token=[^\\s]+`); const m = full.Text.match(pattern); if (m) return m[0]; } await new Promise((r) => setTimeout(r, 250)); } throw new Error(`no ${kind} link for ${email}`); } test('admin invites user; user accepts and logs in', async ({ page }) => { // Make admin (first registration) const adminEmail = `admin+${Date.now()}@example.com`; const adminPw = 'secretpass'; await page.goto('/register'); await page.getByLabel(/Naam/).fill('Admin'); await page.getByLabel(/E-mailadres/).fill(adminEmail); await page.getByLabel(/Wachtwoord/).fill(adminPw); await page.getByRole('button', { name: /Account aanmaken/ }).click(); await expect(page.getByText(/bevestigingsmail/i)).toBeVisible(); await page.goto(await fetchLink(adminEmail, 'verify-email')); await page.goto('/login'); await page.getByLabel(/E-mailadres/).fill(adminEmail); await page.getByLabel(/Wachtwoord/).fill(adminPw); await page.getByRole('button', { name: 'Inloggen' }).click(); // Invite await page.goto('/admin/users'); const inviteeEmail = `invitee+${Date.now()}@example.com`; await page.getByPlaceholder(/naam@voorbeeld/).fill(inviteeEmail); await page.getByRole('button', { name: /Uitnodiging sturen/ }).click(); await expect(page.getByText(/Uitnodiging verstuurd/)).toBeVisible(); // Accept const inviteLink = await fetchLink(inviteeEmail, 'accept-invite'); // logout admin first via UI await page.getByRole('button', { name: 'Account menu' }).click(); await page.getByRole('button', { name: 'Uitloggen' }).click(); await page.goto(inviteLink); await page.getByLabel(/Naam/).fill('Newbie'); await page.getByLabel(/Wachtwoord/).fill('newuserpass'); await page.getByRole('button', { name: /Account aanmaken/ }).click(); await expect(page).toHaveURL(/\/$/); }); ``` - [ ] **Step 4: Run mailpit before running E2E** ```bash docker compose up -d mailpit ``` - [ ] **Step 5: Run E2E** ```bash lsof -ti tcp:3000 2>/dev/null | xargs kill -9 2>/dev/null lsof -ti tcp:5173 2>/dev/null | xargs kill -9 2>/dev/null rm -f packages/backend/data/e2e.db data/e2e.db sleep 2 npm run e2e ``` Expected: both tests pass. - [ ] **Step 6: Commit** ```bash git add e2e/ packages/frontend/playwright.config.ts git -c commit.gpgsign=false commit -m "test(e2e): register+verify smoke and admin invite flow via Mailpit" ``` --- ## Self-review **Spec coverage:** | Spec section | Implemented in task | |---|---| | 3.1 Self-service register + first-user becomes sysadmin | 10 | | 3.2 Email verification (24h, single-use) | 4, 10 | | 3.3 Login / logout with cookie session | 5, 7, 10, 12 | | 3.4 Forgot / reset (1h, all sessions invalidated) | 10 | | 3.5 Profile + email-change via pending_email + change-password keep-current | 10 | | 3.6 Invite + accept-invite | 11 (invite) + 10 (accept) | | 3.7 Admin user list / role / active / send-reset | 11 | | 3.8 Rate limiting | 8 | | 3.9 CSRF double-submit + headers | 7, 12, 14 | | Data model (users, sessions_auth, auth_tokens) | 1 | | Bcrypt cost 12 | 3 | | Token TTLs and purposes | 4, 10 | | Last-sysadmin guard | 11 | | Email templates (4 types) | 6 | | StubMailer fallback when SMTP missing | 6 | | Mailpit docker-compose | 13 | | Env.example | 13 | | Frontend auth pages + AuthBoundary + RoleGuard + UserMenu | 16–21 | | E2E mailpit-based smoke | 22 | All spec sections covered. **Placeholders:** none — every step has concrete code or commands. **Type consistency:** PublicUser/User shapes match between shared types, server responses, and authStore. CSRF cookie/header naming uses `CSRF_COOKIE` and `X-CSRF-Token` consistently. --- ## Execution Handoff **Plan complete and saved to `docs/superpowers/plans/2026-05-20-auth-and-roles.md`. Two execution options:** **1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration **2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints **Which approach?**