Files
flashcards/docs/superpowers/plans/2026-05-20-auth-and-roles.md

3439 lines
125 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;
export type ResendVerificationInput = z.infer<typeof resendVerificationSchema>;
export type ProfileUpdateInput = z.infer<typeof profileUpdateSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
export type AcceptInviteInput = z.infer<typeof acceptInviteSchema>;
export type InviteUserInput = z.infer<typeof inviteUserSchema>;
export type AdminUserUpdateInput = z.infer<typeof adminUserUpdateSchema>;
```
- [ ] **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<string> {
return bcrypt.hash(plain, COST);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
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<typeof makeTestDb>;
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<UserRow> {
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<CreatedToken> {
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<void> {
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<typeof makeTestDb>;
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<CreatedSession> {
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<void> {
db.delete(sessionsAuth).where(eq(sessionsAuth.id, id)).run();
}
export async function invalidateAllForUser(
db: Db,
userId: number,
opts: { except?: string } = {}
): Promise<void> {
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<void> {
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 = `<!doctype html>
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; background:#FAF5FF; padding:24px;">
<div style="max-width:520px; margin:0 auto; background:white; border-radius:24px; padding:32px; box-shadow:0 8px 32px rgba(124,58,237,0.12);">
<h1 style="margin:0 0 16px; font-size:22px; color:#6D28D9;">${title}</h1>
${body}
<hr style="border:none; border-top:1px solid #EFE7FC; margin:24px 0;" />
<p style="font-size:12px; color:#94A3B8;">Flashcard — leer slimmer met spaced repetition</p>
</div>
</body></html>`;
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 👋', `
<p>Hi ${escapeHtml(displayName)},</p>
<p>Klik op de knop hieronder om je e-mailadres te bevestigen. De link is 24 uur geldig.</p>
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Bevestig e-mail</a></p>
<p style="font-size:13px; color:#64748B;">Werkt de knop niet? Kopieer deze link in je browser:<br/><span style="word-break:break-all;">${link}</span></p>
`),
};
}
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', `
<p>Hi ${escapeHtml(displayName)},</p>
<p>Iemand vroeg een wachtwoord-reset aan voor je account. Was jij dat niet? Negeer dan deze e-mail.</p>
<p>De link is 1 uur geldig.</p>
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Reset wachtwoord</a></p>
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
`),
};
}
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 ✨', `
<p>${escapeHtml(inviterName)} heeft je uitgenodigd voor Flashcard.</p>
<p>Maak je account aan via onderstaande knop. De link is 24 uur geldig.</p>
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Account aanmaken</a></p>
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
`),
};
}
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', `
<p>Hi ${escapeHtml(displayName)},</p>
<p>Bevestig <strong>${escapeHtml(newEmail)}</strong> als je nieuwe e-mailadres.</p>
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Bevestig adres</a></p>
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
`),
};
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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<void>;
}
class SmtpMailer implements Mailer {
constructor(private transporter: Transporter, private from: string) {}
async send(to: string, msg: { subject: string; html: string; text: string }): Promise<void> {
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<void> {
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 <noreply@example.com>';
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<typeof makeTestDb>;
let mailer: CaptureMailer;
let app: ReturnType<typeof createApp>;
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<number>`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<string, unknown> = { 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<ListUsersResult> {
const conditions = [] as ReturnType<typeof eq>[];
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<number>`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<User> {
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<number>`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<typeof createApp>, 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<typeof makeTestDb>, email: string) {
return createUserDirect(env.db, {
email, role: 'sysadmin', isActive: true,
passwordHash: await hashPassword('secretpass'),
emailVerifiedAt: Math.floor(Date.now() / 1000),
});
}
let env: ReturnType<typeof makeTestDb>;
let mailer: CaptureMailer;
let app: ReturnType<typeof createApp>;
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 <noreply@flashcard.local>"
```
- [ ] **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=<SES SMTP username>
SMTP_PASS=<SES SMTP password>
SMTP_FROM="Flashcard <noreply@yourdomain.com>"
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<T>(
method: string,
path: string,
body?: unknown,
opts?: { isFormData?: boolean }
): Promise<T> {
const headers: Record<string, string> = {};
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: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
postForm: <T>(path: string, form: FormData) => request<T>('POST', path, form, { isFormData: true }),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
getBlob: async (path: string): Promise<Blob> => {
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<User>('/auth/me'),
register: (input: RegisterInput) => api.post<PublicUser>('/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<PublicUser>('/auth/login', input),
logout: () => api.post<void>('/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<PublicUser>('/auth/accept-invite', input),
updateProfile: (input: ProfileUpdateInput) => api.patch<PublicUser>('/auth/profile', input),
changePassword: (input: ChangePasswordInput) => api.post<void>('/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<ListUsersResponse>(`/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<User>(`/admin/users/${id}`, input),
sendReset: (id: number) => api.post<void>(`/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<void>;
refreshMe: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
setUserFromAuthResponse: (publicUser: { id: number; email: string; displayName: string; role: 'user' | 'sysadmin' }) => void;
}
export const useAuth = create<AuthState>((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 (
<div className="flex h-full items-center justify-center">
<div className="h-2 w-32 animate-shimmer rounded-full bg-gradient-to-r from-brand-100 via-brand-200 to-brand-100 bg-[length:1000px_100%]" />
</div>
);
}
if (!user) {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />;
}
return <Outlet />;
}
```
- [ ] **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 <Navigate to="/login" replace />;
if (user.role !== role) {
return (
<div className="mx-auto max-w-md p-12 text-center">
<h1 className="font-display text-2xl font-bold">Geen toegang</h1>
<p className="mt-2 text-sm text-slate-500">Deze pagina is alleen voor beheerders.</p>
</div>
);
}
return <Outlet />;
}
```
- [ ] **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<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="grid h-9 w-9 place-items-center rounded-full bg-brand-gradient text-sm font-bold text-white shadow-glow hover:brightness-110"
aria-label="Account menu"
>
{initials || '?'}
</button>
{open && (
<div className="absolute right-0 top-11 z-30 w-56 rounded-2xl border border-white/60 bg-white/95 p-2 shadow-soft backdrop-blur dark:border-slate-800 dark:bg-slate-900/95">
<div className="px-3 py-2">
<div className="truncate text-sm font-semibold">{user.displayName}</div>
<div className="truncate text-xs text-slate-500">{user.email}</div>
</div>
<hr className="my-1 border-brand-100 dark:border-slate-800" />
<Link to="/profile" className="block rounded-xl px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-slate-800" onClick={() => setOpen(false)}>Profiel</Link>
{user.role === 'sysadmin' && (
<Link to="/admin/users" className="block rounded-xl px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-slate-800" onClick={() => setOpen(false)}>👑 Systeembeheer</Link>
)}
<button onClick={handleLogout} className="block w-full rounded-xl px-3 py-2 text-left text-sm text-danger-600 hover:bg-danger-50 dark:hover:bg-danger-400/10">
Uitloggen
</button>
</div>
)}
</div>
);
}
```
- [ ] **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 (
<div className="flex h-full flex-col">
<header className="sticky top-0 z-20 border-b border-white/40 bg-white/70 backdrop-blur-xl dark:border-slate-800/60 dark:bg-slate-950/70">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 sm:px-6">
<NavLink to="/" className="flex items-center gap-2 font-display text-lg font-bold">
<span className="grid h-8 w-8 place-items-center rounded-xl bg-brand-gradient text-white shadow-glow"></span>
<span className="bg-brand-gradient bg-clip-text text-transparent">Flashcards</span>
</NavLink>
{user && (
<nav className="ml-4 hidden gap-1 sm:flex">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`rounded-xl px-3 py-1.5 text-sm font-medium transition ${
isActive
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
: 'text-slate-600 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-900/60'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
)}
<div className="ml-auto flex items-center gap-2">
<button
onClick={toggleTheme}
className="grid h-9 w-9 place-items-center rounded-xl border border-white/60 bg-white/70 text-base shadow-sm transition hover:scale-105 dark:border-slate-800 dark:bg-slate-900/70"
aria-label="Toggle dark mode"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
<UserMenu />
</div>
</div>
{user && (
<nav className="flex gap-1 overflow-x-auto px-4 pb-2 sm:hidden">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition ${
isActive ? 'bg-brand-600 text-white' : 'bg-white/60 text-slate-700 dark:bg-slate-900/60 dark:text-slate-300'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
)}
</header>
<main className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
<Outlet />
</div>
</main>
</div>
);
}
```
- [ ] **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(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
```
- [ ] **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<string | null>(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 (
<AuthLayout title="Inloggen">
<form onSubmit={submit} className="space-y-4">
<Field label="E-mailadres">
<input type="email" className="input-field" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" />
</Field>
<Field label="Wachtwoord">
<input type="password" className="input-field" required value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</Field>
{error && <p className="rounded-xl bg-danger-50 p-3 text-sm text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">{error}</p>}
{needsVerification && (
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
Je e-mailadres is nog niet bevestigd.
<button type="button" className="ml-2 font-semibold underline" onClick={resend}>Stuur opnieuw</button>
</div>
)}
<button type="submit" className="btn-primary w-full py-3" disabled={busy}>{busy ? 'Bezig…' : 'Inloggen'}</button>
</form>
<div className="mt-6 flex justify-between text-sm">
<Link to="/forgot-password" className="text-brand-600 hover:underline">Wachtwoord vergeten?</Link>
<Link to="/register" className="text-brand-600 hover:underline">Account aanmaken</Link>
</div>
</AuthLayout>
);
}
export function AuthLayout({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mx-auto max-w-md p-6">
<div className="surface p-8">
<h1 className="mb-6 font-display text-2xl font-bold">{title}</h1>
{children}
</div>
</div>
);
}
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block text-sm">
<span className="mb-1 block font-medium text-slate-700 dark:text-slate-200">{label}</span>
{children}
</label>
);
}
```
- [ ] **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<string | null>(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 (
<AuthLayout title="Bijna klaar 📬">
<p className="text-sm">
We hebben een bevestigingsmail gestuurd naar <strong>{email}</strong>.
Klik op de link om je account te activeren.
</p>
<Link to="/login" className="mt-6 block text-sm text-brand-600 hover:underline">Terug naar inloggen</Link>
</AuthLayout>
);
}
return (
<AuthLayout title="Registreren">
<form onSubmit={submit} className="space-y-4">
<Field label="Naam"><input className="input-field" required minLength={1} value={displayName} onChange={(e) => setDisplayName(e.target.value)} autoComplete="name" /></Field>
<Field label="E-mailadres"><input type="email" className="input-field" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" /></Field>
<Field label="Wachtwoord (min. 8 tekens)"><input type="password" className="input-field" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" /></Field>
{error && <p className="rounded-xl bg-danger-50 p-3 text-sm text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">{error}</p>}
<button type="submit" className="btn-primary w-full py-3" disabled={busy}>{busy ? 'Bezig…' : 'Account aanmaken'}</button>
<p className="text-xs text-slate-500">
De eerste registratie wordt automatisch beheerder.
</p>
</form>
<div className="mt-6 text-sm">
<Link to="/login" className="text-brand-600 hover:underline">Heb je al een account? Inloggen</Link>
</div>
</AuthLayout>
);
}
```
- [ ] **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 (
<AuthLayout title="E-mailverificatie">
{state === 'pending' && <p>Bezig met verifiëren</p>}
{state === 'ok' && (
<>
<p className="text-sm">Je e-mailadres is bevestigd </p>
<Link to="/login" className="btn-primary mt-6 inline-flex">Naar inloggen</Link>
</>
)}
{state === 'err' && (
<>
<p className="text-sm text-danger-700 dark:text-danger-400">{message}</p>
<Link to="/login" className="mt-6 inline-block text-sm text-brand-600 hover:underline">Terug naar inloggen</Link>
</>
)}
</AuthLayout>
);
}
```
- [ ] **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 (
<AuthLayout title="Check je mail 📬">
<p className="text-sm">Als <strong>{email}</strong> bekend is, hebben we een reset-link gestuurd. De link is 1 uur geldig.</p>
<Link to="/login" className="mt-6 inline-block text-sm text-brand-600 hover:underline">Terug naar inloggen</Link>
</AuthLayout>
);
}
return (
<AuthLayout title="Wachtwoord vergeten">
<form onSubmit={submit} className="space-y-4">
<Field label="E-mailadres">
<input type="email" required className="input-field" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" />
</Field>
<button type="submit" className="btn-primary w-full py-3" disabled={busy}>{busy ? 'Bezig…' : 'Stuur reset-link'}</button>
</form>
<div className="mt-6 text-sm">
<Link to="/login" className="text-brand-600 hover:underline">Terug naar inloggen</Link>
</div>
</AuthLayout>
);
}
```
- [ ] **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<string | null>(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 (
<AuthLayout title="Wachtwoord ingesteld ✅">
<p className="text-sm">Je kunt nu inloggen met je nieuwe wachtwoord.</p>
<button className="btn-primary mt-6 w-full" onClick={() => navigate('/login')}>Naar inloggen</button>
</AuthLayout>
);
}
return (
<AuthLayout title="Nieuw wachtwoord">
{!token && <p className="text-sm text-danger-700">Geen token in URL.</p>}
<form onSubmit={submit} className="space-y-4">
<Field label="Nieuw wachtwoord (min. 8 tekens)">
<input type="password" required minLength={8} className="input-field" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Field>
{error && <p className="rounded-xl bg-danger-50 p-3 text-sm text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">{error}</p>}
<button type="submit" className="btn-primary w-full py-3" disabled={busy || !token}>{busy ? 'Bezig…' : 'Opslaan'}</button>
</form>
<Link to="/login" className="mt-6 block text-sm text-brand-600 hover:underline">Terug naar inloggen</Link>
</AuthLayout>
);
}
```
- [ ] **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<string | null>(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 (
<AuthLayout title="Account aanmaken">
{!token && <p className="text-sm text-danger-700">Geen token in URL.</p>}
<form onSubmit={submit} className="space-y-4">
<Field label="Naam">
<input required className="input-field" value={displayName} onChange={(e) => setDisplayName(e.target.value)} autoComplete="name" />
</Field>
<Field label="Wachtwoord (min. 8 tekens)">
<input type="password" required minLength={8} className="input-field" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Field>
{error && <p className="rounded-xl bg-danger-50 p-3 text-sm text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">{error}</p>}
<button type="submit" className="btn-primary w-full py-3" disabled={busy || !token}>{busy ? 'Bezig…' : 'Account aanmaken'}</button>
</form>
</AuthLayout>
);
}
```
- [ ] **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<string | null>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [pwBusy, setPwBusy] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
const [pwErr, setPwErr] = useState<string | null>(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 (
<div className="mx-auto max-w-2xl space-y-6">
<header>
<h1 className="font-display text-3xl font-bold">Profiel</h1>
<p className="text-sm text-slate-500">Beheer je naam, e-mailadres en wachtwoord.</p>
</header>
<form onSubmit={saveProfile} className="surface space-y-4 p-6">
<h2 className="font-display text-lg font-bold">Gegevens</h2>
<label className="block text-sm">
<span className="mb-1 block font-medium">Naam</span>
<input className="input-field" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</label>
<label className="block text-sm">
<span className="mb-1 block font-medium">E-mailadres</span>
<input type="email" className="input-field" value={email} onChange={(e) => setEmail(e.target.value)} />
{user.pendingEmail && <span className="mt-1 block text-xs text-amber-700">In afwachting: {user.pendingEmail}</span>}
</label>
{profileMsg && <p className="text-sm">{profileMsg}</p>}
<button className="btn-primary" disabled={profileBusy}>{profileBusy ? 'Bezig…' : 'Opslaan'}</button>
</form>
<form onSubmit={changePassword} className="surface space-y-4 p-6">
<h2 className="font-display text-lg font-bold">Wachtwoord</h2>
<label className="block text-sm">
<span className="mb-1 block font-medium">Huidig wachtwoord</span>
<input type="password" className="input-field" required value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} autoComplete="current-password" />
</label>
<label className="block text-sm">
<span className="mb-1 block font-medium">Nieuw wachtwoord (min. 8 tekens)</span>
<input type="password" minLength={8} className="input-field" required value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password" />
</label>
{pwMsg && <p className="text-sm text-success-700">{pwMsg}</p>}
{pwErr && <p className="text-sm text-danger-700">{pwErr}</p>}
<button className="btn-primary" disabled={pwBusy}>{pwBusy ? 'Bezig…' : 'Wachtwoord wijzigen'}</button>
</form>
</div>
);
}
```
- [ ] **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<ListUsersResponse>({ 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<string | null>(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 (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold">👑 Gebruikersbeheer</h1>
<p className="text-sm text-slate-500">{data.total} gebruiker(s) totaal</p>
</header>
<form onSubmit={invite} className="surface flex flex-col gap-2 p-4 sm:flex-row sm:items-end">
<label className="flex-1 text-sm">
<span className="mb-1 block font-medium">Uitnodigen via e-mail</span>
<input type="email" required className="input-field" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} placeholder="naam@voorbeeld.com" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium">Rol</span>
<select className="input-field" value={inviteRole} onChange={(e) => setInviteRole(e.target.value as 'user' | 'sysadmin')}>
<option value="user">Gebruiker</option>
<option value="sysadmin">Beheerder</option>
</select>
</label>
<button className="btn-primary shrink-0">Uitnodiging sturen</button>
</form>
{inviteMsg && <p className="text-sm">{inviteMsg}</p>}
<div className="surface p-4">
<div className="mb-3 flex gap-2">
<input className="input-field flex-1" placeholder="Zoek op e-mail of naam…" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && refresh()} />
<button className="btn-ghost" onClick={refresh} disabled={busy}>Zoek</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-slate-500">
<th className="px-2 py-2">Email</th>
<th className="px-2 py-2">Naam</th>
<th className="px-2 py-2">Rol</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-brand-100/60 dark:divide-slate-800">
{data.rows.map((u) => (
<tr key={u.id} className="hover:bg-brand-50/40 dark:hover:bg-slate-800/40">
<td className="px-2 py-2">{u.email}</td>
<td className="px-2 py-2">{u.displayName}</td>
<td className="px-2 py-2">
<select className="rounded-lg border border-brand-100 bg-white px-2 py-1 text-xs dark:border-slate-800 dark:bg-slate-900" value={u.role} onChange={(e) => setRole(u, e.target.value as 'user' | 'sysadmin')}>
<option value="user">user</option>
<option value="sysadmin">sysadmin</option>
</select>
</td>
<td className="px-2 py-2">
{u.isActive ? (
<span className="rounded-full bg-success-50 px-2 py-0.5 text-xs font-semibold text-success-700 dark:bg-success-700/20 dark:text-success-400">actief</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">uit</span>
)}
{!u.emailVerifiedAt && (
<span className="ml-1 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">onbevestigd</span>
)}
</td>
<td className="px-2 py-2 text-right">
<button className="rounded-lg px-2 py-1 text-xs text-brand-700 hover:bg-brand-50 dark:text-brand-200 dark:hover:bg-brand-900/40" onClick={() => sendReset(u)}>reset-mail</button>
<button className="rounded-lg px-2 py-1 text-xs text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" onClick={() => setActive(u, !u.isActive)}>{u.isActive ? 'deactiveer' : 'activeer'}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
```
- [ ] **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: <Layout />,
children: [
// Public auth routes
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: 'verify-email', element: <VerifyEmailPage /> },
{ path: 'forgot-password', element: <ForgotPasswordPage /> },
{ path: 'reset-password', element: <ResetPasswordPage /> },
{ path: 'accept-invite', element: <AcceptInvitePage /> },
// Authenticated routes
{
element: <AuthBoundary />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'admin', element: <AdminPage /> },
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },
{ path: 'practice/:lessonId/setup', element: <PracticeSetupPage /> },
{ path: 'practice/:lessonId', element: <PracticePage /> },
{ path: 'practice/:lessonId/done', element: <PracticeDonePage /> },
{ path: 'stats', element: <StatsPage /> },
{ path: 'stats/lessons/:id', element: <StatsLessonPage /> },
{ path: 'stats/cards/:id', element: <StatsCardPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'profile', element: <ProfilePage /> },
{
element: <RoleGuard role="sysadmin" />,
children: [
{ path: 'admin/users', element: <AdminUsersPage /> },
],
},
{ path: '*', element: <Navigate to="/" replace /> },
],
},
],
},
]);
```
- [ ] **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<string> {
// 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<string> {
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 | 1621 |
| 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?**