From ee7ca9187a5792af0eee6c7cb31e20a113efd356 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 22:32:38 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20implementation=20plan=20for=20sub-proje?= =?UTF-8?q?ct=20A=20=E2=80=94=20auth=20&=20roles=20(22=20TDD=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-05-20-auth-and-roles.md | 3438 +++++++++++++++++ 1 file changed, 3438 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-auth-and-roles.md diff --git a/docs/superpowers/plans/2026-05-20-auth-and-roles.md b/docs/superpowers/plans/2026-05-20-auth-and-roles.md new file mode 100644 index 0000000..040c12c --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-auth-and-roles.md @@ -0,0 +1,3438 @@ +# 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) => ( + + + + + + + + ))} + +
EmailNaamRolStatus
{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?**