125 KiB
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
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):
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
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
DB_PATH=./data/flashcard.db npm -w @flashcard/backend run db:migrate
Expected: Migrations applied. and data/flashcard.db exists.
- Step 5: Update
seed.tsto be safe with the new tables
Replace the contents of packages/backend/src/db/seed.ts with:
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
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:
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:
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
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
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:
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
npm -w @flashcard/backend test
Expected: failure (module not found).
- Step 4: Implement
passwords.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
npm -w @flashcard/backend test
Expected: 3 password tests pass.
- Step 6: Commit
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
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:
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
npm -w @flashcard/backend test
Expected: tokens module not found.
- Step 4: Implement
tokens.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
npm -w @flashcard/backend test
Expected: 6 token tests pass.
- Step 6: Commit
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
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
npm -w @flashcard/backend test
- Step 3: Implement
sessions.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
npm -w @flashcard/backend test
Expected: 6 session tests pass.
- Step 5: Commit
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
npm i -w @flashcard/backend nodemailer
npm i -D -w @flashcard/backend @types/nodemailer
- Step 2: Create
templates.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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
}
- Step 3: Create
email.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
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
npm i -w @flashcard/backend cookie-parser
npm i -D -w @flashcard/backend @types/cookie-parser
- Step 2: Create
lib/cookies.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
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
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
npm i -w @flashcard/backend express-rate-limit
- Step 2: Create
rate-limit.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
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
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
npm -w @flashcard/backend run typecheck
Expected: passes.
- Step 3: Commit
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(addmakeTestApp) -
Step 1: Extend
tests/dbHelper.tswithmakeTestApphelper
Append to packages/backend/src/tests/dbHelper.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:
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)
npm -w @flashcard/backend test
Expected: tests fail.
- Step 4: Implement
routes/auth.ts(first slice — register/verify/login/logout/me)
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
# 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:
npm -w @flashcard/backend run typecheck
Expected: passes.
- Step 7: Commit
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
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
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:
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
npm -w @flashcard/backend run typecheck
- Step 5: Commit
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:
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:
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
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
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
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:
## 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.tswith CSRF header + 401 handling
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
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
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
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
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
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
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
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
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
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.tsxto bounce to /login
Replace packages/frontend/src/main.tsx with:
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
npm -w @flashcard/frontend run typecheck
npm -w @flashcard/frontend run build
Expected: both succeed.
- Step 3: Commit
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
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.tswith a flow that first registers/logs in
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
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
docker compose up -d mailpit
- Step 5: Run E2E
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
git add e2e/ packages/frontend/playwright.config.ts
git -c commit.gpgsign=false commit -m "test(e2e): register+verify smoke and admin invite flow via Mailpit"
Self-review
Spec coverage:
| Spec section | Implemented in task |
|---|---|
| 3.1 Self-service register + first-user becomes sysadmin | 10 |
| 3.2 Email verification (24h, single-use) | 4, 10 |
| 3.3 Login / logout with cookie session | 5, 7, 10, 12 |
| 3.4 Forgot / reset (1h, all sessions invalidated) | 10 |
| 3.5 Profile + email-change via pending_email + change-password keep-current | 10 |
| 3.6 Invite + accept-invite | 11 (invite) + 10 (accept) |
| 3.7 Admin user list / role / active / send-reset | 11 |
| 3.8 Rate limiting | 8 |
| 3.9 CSRF double-submit + headers | 7, 12, 14 |
| Data model (users, sessions_auth, auth_tokens) | 1 |
| Bcrypt cost 12 | 3 |
| Token TTLs and purposes | 4, 10 |
| Last-sysadmin guard | 11 |
| Email templates (4 types) | 6 |
| StubMailer fallback when SMTP missing | 6 |
| Mailpit docker-compose | 13 |
| Env.example | 13 |
| Frontend auth pages + AuthBoundary + RoleGuard + UserMenu | 16–21 |
| E2E mailpit-based smoke | 22 |
All spec sections covered.
Placeholders: none — every step has concrete code or commands.
Type consistency: PublicUser/User shapes match between shared types, server responses, and authStore. CSRF cookie/header naming uses CSRF_COOKIE and X-CSRF-Token consistently.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-05-20-auth-and-roles.md. Two execution options:
1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration
2. Inline Execution — execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?