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

125 KiB
Raw Blame History

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.ts to 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 (add makeTestApp)

  • Step 1: Extend tests/dbHelper.ts with makeTestApp helper

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.ts with 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.tsx to 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.ts with 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 1621
E2E mailpit-based smoke 22

All spec sections covered.

Placeholders: none — every step has concrete code or commands.

Type consistency: PublicUser/User shapes match between shared types, server responses, and authStore. CSRF cookie/header naming uses CSRF_COOKIE and X-CSRF-Token consistently.


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-20-auth-and-roles.md. Two execution options:

1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration

2. Inline Execution — execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?