3439 lines
125 KiB
Markdown
3439 lines
125 KiB
Markdown
# Auth & Roles Implementation Plan (Sub-project A)
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Put the existing flashcard app behind email+password authentication with self-service registration, admin invites, password reset, profile management, and a sysadmin role for user management.
|
||
|
||
**Architecture:** Server-side sessions in SQLite (Drizzle ORM) backed by HttpOnly cookies, with double-submit CSRF tokens for mutations. Email delivery via nodemailer over SMTP (Mailpit in dev, Amazon SES in prod) with a stub-mailer fallback that logs to console when SMTP is unreachable. New backend modules under `src/services/auth/`, `src/middleware/`, and route files for `/api/auth/*` and `/api/admin/users/*`. New React pages for login/register/verify/forgot/reset/accept-invite/profile/admin-users, gated by an `AuthBoundary` + `RoleGuard`.
|
||
|
||
**Tech Stack:** bcryptjs, nodemailer, cookie-parser, express-rate-limit, Drizzle ORM, Zod, React + Zustand, Vitest + supertest, Playwright + Mailpit HTTP API.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-20-auth-and-roles-design.md`
|
||
|
||
**Pre-implementation:** The current dev DB at `data/flashcard.db` will be dropped and re-migrated. No production data exists yet.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
flashcard/
|
||
├── docker-compose.yml NEW (mailpit)
|
||
├── .env.example NEW
|
||
├── packages/
|
||
│ ├── shared/src/
|
||
│ │ ├── types.ts MODIFIED (+ User, AuthSession, Role)
|
||
│ │ └── schemas.ts MODIFIED (+ auth zod schemas)
|
||
│ ├── backend/src/
|
||
│ │ ├── db/
|
||
│ │ │ ├── schema.ts MODIFIED (+ users, sessions_auth, auth_tokens)
|
||
│ │ │ └── seed.ts MODIFIED (creates demo user + lessons)
|
||
│ │ ├── services/
|
||
│ │ │ ├── auth/
|
||
│ │ │ │ ├── passwords.ts NEW
|
||
│ │ │ │ ├── passwords.test.ts NEW
|
||
│ │ │ │ ├── tokens.ts NEW
|
||
│ │ │ │ ├── tokens.test.ts NEW
|
||
│ │ │ │ ├── sessions.ts NEW
|
||
│ │ │ │ ├── sessions.test.ts NEW
|
||
│ │ │ │ ├── email.ts NEW
|
||
│ │ │ │ └── templates.ts NEW
|
||
│ │ │ └── users.ts NEW (admin user management)
|
||
│ │ ├── middleware/
|
||
│ │ │ ├── auth.ts NEW (requireAuth, requireRole, currentUserOrNull)
|
||
│ │ │ ├── csrf.ts NEW
|
||
│ │ │ └── rate-limit.ts NEW
|
||
│ │ ├── lib/
|
||
│ │ │ └── cookies.ts NEW
|
||
│ │ ├── routes/
|
||
│ │ │ ├── auth.ts NEW
|
||
│ │ │ ├── admin-users.ts NEW
|
||
│ │ │ ├── cards.ts MODIFIED (no functional change beyond mounting)
|
||
│ │ │ ├── lessons.ts (unchanged)
|
||
│ │ │ ├── sessions.ts (unchanged)
|
||
│ │ │ └── stats.ts (unchanged)
|
||
│ │ ├── tests/
|
||
│ │ │ ├── dbHelper.ts MODIFIED (helper to seed a user + return session cookie)
|
||
│ │ │ ├── auth.integration.test.ts NEW
|
||
│ │ │ └── admin-users.integration.test.ts NEW
|
||
│ │ ├── app.ts MODIFIED (mount auth, csrf, rate limit, protect all)
|
||
│ │ └── index.ts MODIFIED (env validation)
|
||
│ └── frontend/src/
|
||
│ ├── api/
|
||
│ │ ├── client.ts MODIFIED (CSRF header + 401 handling)
|
||
│ │ ├── auth.ts NEW
|
||
│ │ └── admin-users.ts NEW
|
||
│ ├── stores/
|
||
│ │ └── authStore.ts NEW
|
||
│ ├── components/
|
||
│ │ ├── AuthBoundary.tsx NEW
|
||
│ │ ├── RoleGuard.tsx NEW
|
||
│ │ ├── UserMenu.tsx NEW
|
||
│ │ └── Layout.tsx MODIFIED (UserMenu + sysadmin link)
|
||
│ ├── pages/
|
||
│ │ ├── auth/
|
||
│ │ │ ├── Login.tsx NEW
|
||
│ │ │ ├── Register.tsx NEW
|
||
│ │ │ ├── VerifyEmail.tsx NEW
|
||
│ │ │ ├── ForgotPassword.tsx NEW
|
||
│ │ │ ├── ResetPassword.tsx NEW
|
||
│ │ │ └── AcceptInvite.tsx NEW
|
||
│ │ ├── Profile.tsx NEW
|
||
│ │ └── AdminUsers.tsx NEW
|
||
│ └── router.tsx MODIFIED (auth routes + boundaries)
|
||
├── e2e/
|
||
│ ├── smoke.spec.ts MODIFIED (registers + logs in first)
|
||
│ └── auth.spec.ts NEW (full mailpit-based auth flow)
|
||
└── README.md MODIFIED (auth setup section)
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Database schema for auth
|
||
|
||
**Files:**
|
||
- Modify: `packages/backend/src/db/schema.ts`
|
||
- Generate: `packages/backend/drizzle/0001_*.sql`
|
||
- Modify: `packages/backend/src/db/seed.ts`
|
||
|
||
- [ ] **Step 1: Drop existing dev DB**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/flashcard
|
||
rm -f data/flashcard.db data/flashcard.db-* packages/backend/data/*.db packages/backend/data/*.db-*
|
||
```
|
||
|
||
- [ ] **Step 2: Append new tables to `schema.ts`**
|
||
|
||
Add to `packages/backend/src/db/schema.ts` (after the existing `attempts` table, before the type exports):
|
||
|
||
```ts
|
||
export const users = sqliteTable(
|
||
'users',
|
||
{
|
||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||
email: text('email').notNull().unique(),
|
||
displayName: text('display_name').notNull(),
|
||
passwordHash: text('password_hash'),
|
||
role: text('role', { enum: ['user', 'sysadmin'] }).notNull().default('user'),
|
||
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||
emailVerifiedAt: integer('email_verified_at'),
|
||
pendingEmail: text('pending_email'),
|
||
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
|
||
updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
|
||
},
|
||
(t) => ({ emailIdx: index('users_email_idx').on(t.email) })
|
||
);
|
||
|
||
export const sessionsAuth = sqliteTable(
|
||
'sessions_auth',
|
||
{
|
||
id: text('id').primaryKey(),
|
||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
|
||
expiresAt: integer('expires_at').notNull(),
|
||
lastUsedAt: integer('last_used_at').notNull().default(sql`(unixepoch())`),
|
||
userAgent: text('user_agent'),
|
||
ip: text('ip'),
|
||
},
|
||
(t) => ({
|
||
userIdx: index('sessions_auth_user_idx').on(t.userId),
|
||
expIdx: index('sessions_auth_expires_idx').on(t.expiresAt),
|
||
})
|
||
);
|
||
|
||
export const authTokens = sqliteTable(
|
||
'auth_tokens',
|
||
{
|
||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||
tokenHash: text('token_hash').notNull(),
|
||
purpose: text('purpose', { enum: ['verify_email', 'password_reset', 'invite', 'change_email'] }).notNull(),
|
||
payload: text('payload'),
|
||
expiresAt: integer('expires_at').notNull(),
|
||
usedAt: integer('used_at'),
|
||
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
|
||
},
|
||
(t) => ({
|
||
hashIdx: index('auth_tokens_hash_idx').on(t.tokenHash),
|
||
userPurposeIdx: index('auth_tokens_user_purpose_idx').on(t.userId, t.purpose),
|
||
})
|
||
);
|
||
|
||
export type UserRow = typeof users.$inferSelect;
|
||
export type SessionAuthRow = typeof sessionsAuth.$inferSelect;
|
||
export type AuthTokenRow = typeof authTokens.$inferSelect;
|
||
```
|
||
|
||
- [ ] **Step 3: Generate migration**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend run db:generate
|
||
```
|
||
|
||
Expected: a new file `packages/backend/drizzle/0001_*.sql` is created.
|
||
|
||
- [ ] **Step 4: Run migrations on a fresh DB**
|
||
|
||
```bash
|
||
DB_PATH=./data/flashcard.db npm -w @flashcard/backend run db:migrate
|
||
```
|
||
|
||
Expected: `Migrations applied.` and `data/flashcard.db` exists.
|
||
|
||
- [ ] **Step 5: Update `seed.ts` to be safe with the new tables**
|
||
|
||
Replace the contents of `packages/backend/src/db/seed.ts` with:
|
||
|
||
```ts
|
||
import { createDb } from './client.js';
|
||
import { cards, lessons } from './schema.js';
|
||
|
||
const { db, sqlite } = createDb();
|
||
// Seed only demo lessons + cards. Users are created via /api/auth/register in app.
|
||
const [root] = db.insert(lessons).values({ name: 'Demo: Spaans', position: 0 }).returning().all();
|
||
const [sub] = db.insert(lessons).values({ name: 'Begroetingen', parentId: root!.id, position: 0 }).returning().all();
|
||
db.insert(cards).values([
|
||
{ lessonId: sub!.id, question: 'Hallo', answer: 'Hola', position: 0 },
|
||
{ lessonId: sub!.id, question: 'Goedemorgen', answer: 'Buenos días', position: 1 },
|
||
{ lessonId: sub!.id, question: 'Tot ziens', answer: 'Adiós', position: 2 },
|
||
]).run();
|
||
sqlite.close();
|
||
console.log('Seed inserted.');
|
||
```
|
||
|
||
(No change to logic; only confirming that seed continues to work after the new tables exist.)
|
||
|
||
- [ ] **Step 6: Typecheck and commit**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend run typecheck
|
||
git add packages/backend/src/db/schema.ts packages/backend/drizzle packages/backend/src/db/seed.ts
|
||
git -c commit.gpgsign=false commit -m "feat(db): add users, sessions_auth, auth_tokens tables"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Shared auth types & Zod schemas
|
||
|
||
**Files:**
|
||
- Modify: `packages/shared/src/types.ts`
|
||
- Modify: `packages/shared/src/schemas.ts`
|
||
|
||
- [ ] **Step 1: Append types to `types.ts`**
|
||
|
||
Append these to `packages/shared/src/types.ts`:
|
||
|
||
```ts
|
||
export type Role = 'user' | 'sysadmin';
|
||
|
||
export interface User {
|
||
id: number;
|
||
email: string;
|
||
displayName: string;
|
||
role: Role;
|
||
isActive: boolean;
|
||
emailVerifiedAt: number | null;
|
||
pendingEmail: string | null;
|
||
createdAt: number;
|
||
updatedAt: number;
|
||
}
|
||
|
||
export interface PublicUser {
|
||
id: number;
|
||
email: string;
|
||
displayName: string;
|
||
role: Role;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Append Zod schemas to `schemas.ts`**
|
||
|
||
Append to `packages/shared/src/schemas.ts`:
|
||
|
||
```ts
|
||
export const emailSchema = z.string().email().max(320).transform((v) => v.trim().toLowerCase());
|
||
|
||
export const registerSchema = z.object({
|
||
email: emailSchema,
|
||
displayName: z.string().trim().min(1).max(120),
|
||
password: z.string().min(8).max(200),
|
||
});
|
||
|
||
export const loginSchema = z.object({
|
||
email: emailSchema,
|
||
password: z.string().min(1).max(200),
|
||
});
|
||
|
||
export const forgotPasswordSchema = z.object({ email: emailSchema });
|
||
|
||
export const resetPasswordSchema = z.object({
|
||
token: z.string().min(20).max(200),
|
||
password: z.string().min(8).max(200),
|
||
});
|
||
|
||
export const verifyEmailSchema = z.object({
|
||
token: z.string().min(20).max(200),
|
||
});
|
||
|
||
export const resendVerificationSchema = z.object({ email: emailSchema });
|
||
|
||
export const profileUpdateSchema = z.object({
|
||
displayName: z.string().trim().min(1).max(120).optional(),
|
||
email: emailSchema.optional(),
|
||
});
|
||
|
||
export const changePasswordSchema = z.object({
|
||
currentPassword: z.string().min(1).max(200),
|
||
newPassword: z.string().min(8).max(200),
|
||
});
|
||
|
||
export const acceptInviteSchema = z.object({
|
||
token: z.string().min(20).max(200),
|
||
displayName: z.string().trim().min(1).max(120),
|
||
password: z.string().min(8).max(200),
|
||
});
|
||
|
||
export const inviteUserSchema = z.object({
|
||
email: emailSchema,
|
||
role: z.enum(['user', 'sysadmin']).default('user'),
|
||
});
|
||
|
||
export const adminUserUpdateSchema = z.object({
|
||
displayName: z.string().trim().min(1).max(120).optional(),
|
||
role: z.enum(['user', 'sysadmin']).optional(),
|
||
isActive: z.boolean().optional(),
|
||
});
|
||
|
||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||
export type LoginInput = z.infer<typeof loginSchema>;
|
||
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
|
||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;
|
||
export type ResendVerificationInput = z.infer<typeof resendVerificationSchema>;
|
||
export type ProfileUpdateInput = z.infer<typeof profileUpdateSchema>;
|
||
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|
||
export type AcceptInviteInput = z.infer<typeof acceptInviteSchema>;
|
||
export type InviteUserInput = z.infer<typeof inviteUserSchema>;
|
||
export type AdminUserUpdateInput = z.infer<typeof adminUserUpdateSchema>;
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck and commit**
|
||
|
||
```bash
|
||
npm -w @flashcard/shared run typecheck
|
||
git add packages/shared/src/types.ts packages/shared/src/schemas.ts
|
||
git -c commit.gpgsign=false commit -m "feat(shared): add auth types and zod schemas"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Password service (TDD)
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/services/auth/passwords.ts`
|
||
- Create: `packages/backend/src/services/auth/passwords.test.ts`
|
||
|
||
- [ ] **Step 1: Install bcryptjs**
|
||
|
||
```bash
|
||
cd /Users/berthausmans/Documents/Development/flashcard
|
||
npm i -w @flashcard/backend bcryptjs
|
||
npm i -D -w @flashcard/backend @types/bcryptjs
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Create `packages/backend/src/services/auth/passwords.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { hashPassword, verifyPassword } from './passwords.js';
|
||
|
||
describe('passwords', () => {
|
||
it('hashes a password and verifies it', async () => {
|
||
const hash = await hashPassword('correcthorse');
|
||
expect(hash).toMatch(/^\$2[aby]\$/);
|
||
expect(await verifyPassword('correcthorse', hash)).toBe(true);
|
||
});
|
||
|
||
it('rejects a wrong password', async () => {
|
||
const hash = await hashPassword('correcthorse');
|
||
expect(await verifyPassword('wrong', hash)).toBe(false);
|
||
});
|
||
|
||
it('returns false on malformed hash', async () => {
|
||
expect(await verifyPassword('x', 'not-a-bcrypt-hash')).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run — fail**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend test
|
||
```
|
||
|
||
Expected: failure (module not found).
|
||
|
||
- [ ] **Step 4: Implement `passwords.ts`**
|
||
|
||
```ts
|
||
import bcrypt from 'bcryptjs';
|
||
|
||
const COST = 12;
|
||
|
||
export async function hashPassword(plain: string): Promise<string> {
|
||
return bcrypt.hash(plain, COST);
|
||
}
|
||
|
||
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||
try {
|
||
return await bcrypt.compare(plain, hash);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run — pass**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend test
|
||
```
|
||
|
||
Expected: 3 password tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/package.json package-lock.json packages/backend/src/services/auth/
|
||
git -c commit.gpgsign=false commit -m "feat(auth): password hashing service"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Token service (TDD)
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/services/auth/tokens.ts`
|
||
- Create: `packages/backend/src/services/auth/tokens.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { makeTestDb } from '../../tests/dbHelper.js';
|
||
import { createUserDirect } from '../../tests/dbHelper.js';
|
||
import { generateToken, createAuthToken, consumeAuthToken, findValidAuthToken } from './tokens.js';
|
||
|
||
let env: ReturnType<typeof makeTestDb>;
|
||
beforeEach(() => { env = makeTestDb(); });
|
||
|
||
describe('tokens', () => {
|
||
it('generates a URL-safe random token of the expected length', () => {
|
||
const t = generateToken();
|
||
expect(t).toMatch(/^[A-Za-z0-9_-]+$/);
|
||
expect(t.length).toBeGreaterThanOrEqual(32);
|
||
});
|
||
|
||
it('stores a hashed token and finds it by plaintext', async () => {
|
||
const user = await createUserDirect(env.db, { email: 'a@example.com' });
|
||
const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600);
|
||
const found = await findValidAuthToken(env.db, plaintext, 'verify_email');
|
||
expect(found?.userId).toBe(user.id);
|
||
});
|
||
|
||
it('rejects an expired token', async () => {
|
||
const user = await createUserDirect(env.db, { email: 'b@example.com' });
|
||
const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', -1);
|
||
expect(await findValidAuthToken(env.db, plaintext, 'verify_email')).toBeNull();
|
||
});
|
||
|
||
it('rejects a wrong purpose', async () => {
|
||
const user = await createUserDirect(env.db, { email: 'c@example.com' });
|
||
const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600);
|
||
expect(await findValidAuthToken(env.db, plaintext, 'password_reset')).toBeNull();
|
||
});
|
||
|
||
it('consumes a token once', async () => {
|
||
const user = await createUserDirect(env.db, { email: 'd@example.com' });
|
||
const { plaintext } = await createAuthToken(env.db, user.id, 'verify_email', 3600);
|
||
const first = await consumeAuthToken(env.db, plaintext, 'verify_email');
|
||
expect(first?.userId).toBe(user.id);
|
||
const second = await consumeAuthToken(env.db, plaintext, 'verify_email');
|
||
expect(second).toBeNull();
|
||
});
|
||
|
||
it('stores and returns the payload', async () => {
|
||
const user = await createUserDirect(env.db, { email: 'e@example.com' });
|
||
const { plaintext } = await createAuthToken(env.db, user.id, 'change_email', 3600, 'new@example.com');
|
||
const found = await findValidAuthToken(env.db, plaintext, 'change_email');
|
||
expect(found?.payload).toBe('new@example.com');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Add helper to dbHelper.ts**
|
||
|
||
Modify `packages/backend/src/tests/dbHelper.ts` to also export `createUserDirect`:
|
||
|
||
```ts
|
||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||
import { resolve } from 'node:path';
|
||
import { createDb, type Db } from '../db/client.js';
|
||
import { users } from '../db/schema.js';
|
||
import type { UserRow } from '../db/schema.js';
|
||
|
||
export function makeTestDb(): { db: Db; close: () => void } {
|
||
const { db, sqlite } = createDb(':memory:');
|
||
migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
|
||
return { db, close: () => sqlite.close() };
|
||
}
|
||
|
||
export async function createUserDirect(
|
||
db: Db,
|
||
init: { email: string; displayName?: string; role?: 'user' | 'sysadmin'; passwordHash?: string | null; emailVerifiedAt?: number | null; isActive?: boolean }
|
||
): Promise<UserRow> {
|
||
const [row] = db.insert(users).values({
|
||
email: init.email,
|
||
displayName: init.displayName ?? 'Test User',
|
||
role: init.role ?? 'user',
|
||
passwordHash: init.passwordHash ?? null,
|
||
emailVerifiedAt: init.emailVerifiedAt ?? null,
|
||
isActive: init.isActive ?? true,
|
||
}).returning().all();
|
||
return row!;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run — fail**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend test
|
||
```
|
||
|
||
Expected: tokens module not found.
|
||
|
||
- [ ] **Step 4: Implement `tokens.ts`**
|
||
|
||
```ts
|
||
import { randomBytes, createHash } from 'node:crypto';
|
||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||
import type { Db } from '../../db/client.js';
|
||
import { authTokens } from '../../db/schema.js';
|
||
|
||
const TOKEN_BYTES = 32;
|
||
export type Purpose = 'verify_email' | 'password_reset' | 'invite' | 'change_email';
|
||
|
||
export function generateToken(): string {
|
||
return randomBytes(TOKEN_BYTES).toString('base64url');
|
||
}
|
||
|
||
function hashToken(plaintext: string): string {
|
||
return createHash('sha256').update(plaintext).digest('hex');
|
||
}
|
||
|
||
export interface CreatedToken {
|
||
plaintext: string;
|
||
hash: string;
|
||
expiresAt: number;
|
||
}
|
||
|
||
export async function createAuthToken(
|
||
db: Db,
|
||
userId: number,
|
||
purpose: Purpose,
|
||
ttlSeconds: number,
|
||
payload?: string | null
|
||
): Promise<CreatedToken> {
|
||
const plaintext = generateToken();
|
||
const hash = hashToken(plaintext);
|
||
const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds;
|
||
db.insert(authTokens).values({
|
||
userId,
|
||
tokenHash: hash,
|
||
purpose,
|
||
payload: payload ?? null,
|
||
expiresAt,
|
||
}).run();
|
||
return { plaintext, hash, expiresAt };
|
||
}
|
||
|
||
export async function findValidAuthToken(
|
||
db: Db,
|
||
plaintext: string,
|
||
purpose: Purpose
|
||
): Promise<{ id: number; userId: number; payload: string | null } | null> {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const hash = hashToken(plaintext);
|
||
const row = db.select().from(authTokens).where(
|
||
and(
|
||
eq(authTokens.tokenHash, hash),
|
||
eq(authTokens.purpose, purpose),
|
||
isNull(authTokens.usedAt),
|
||
sql`${authTokens.expiresAt} > ${now}`
|
||
)
|
||
).get();
|
||
if (!row) return null;
|
||
return { id: row.id, userId: row.userId, payload: row.payload ?? null };
|
||
}
|
||
|
||
export async function consumeAuthToken(
|
||
db: Db,
|
||
plaintext: string,
|
||
purpose: Purpose
|
||
): Promise<{ userId: number; payload: string | null } | null> {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const row = await findValidAuthToken(db, plaintext, purpose);
|
||
if (!row) return null;
|
||
const r = db.update(authTokens).set({ usedAt: now })
|
||
.where(and(eq(authTokens.id, row.id), isNull(authTokens.usedAt)))
|
||
.run();
|
||
if (r.changes === 0) return null;
|
||
return { userId: row.userId, payload: row.payload };
|
||
}
|
||
|
||
export async function invalidateTokensForUser(db: Db, userId: number, purpose: Purpose): Promise<void> {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
db.update(authTokens).set({ usedAt: now })
|
||
.where(and(eq(authTokens.userId, userId), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt)))
|
||
.run();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run — pass**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend test
|
||
```
|
||
|
||
Expected: 6 token tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/services/auth/tokens.ts packages/backend/src/services/auth/tokens.test.ts packages/backend/src/tests/dbHelper.ts
|
||
git -c commit.gpgsign=false commit -m "feat(auth): token service with single-use, hashed storage"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Auth session service (TDD)
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/services/auth/sessions.ts`
|
||
- Create: `packages/backend/src/services/auth/sessions.test.ts`
|
||
|
||
Note: this file is for **auth sessions**, not the existing practice sessions. Filename `sessions.ts` inside `services/auth/` keeps them disambiguated.
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { makeTestDb, createUserDirect } from '../../tests/dbHelper.js';
|
||
import { createAuthSession, validateAuthSession, invalidateAuthSession, invalidateAllForUser } from './sessions.js';
|
||
|
||
let env: ReturnType<typeof makeTestDb>;
|
||
beforeEach(() => { env = makeTestDb(); });
|
||
|
||
describe('auth sessions', () => {
|
||
it('creates a session and validates it', async () => {
|
||
const u = await createUserDirect(env.db, { email: 'a@example.com' });
|
||
const s = await createAuthSession(env.db, u.id, { userAgent: 'jest', ip: '127.0.0.1' });
|
||
const v = await validateAuthSession(env.db, s.id);
|
||
expect(v?.userId).toBe(u.id);
|
||
});
|
||
|
||
it('rejects an unknown session id', async () => {
|
||
expect(await validateAuthSession(env.db, 'nope')).toBeNull();
|
||
});
|
||
|
||
it('rejects an expired session', async () => {
|
||
const u = await createUserDirect(env.db, { email: 'b@example.com' });
|
||
const s = await createAuthSession(env.db, u.id, { ttlSeconds: -1 });
|
||
expect(await validateAuthSession(env.db, s.id)).toBeNull();
|
||
});
|
||
|
||
it('invalidates one session', async () => {
|
||
const u = await createUserDirect(env.db, { email: 'c@example.com' });
|
||
const s = await createAuthSession(env.db, u.id);
|
||
await invalidateAuthSession(env.db, s.id);
|
||
expect(await validateAuthSession(env.db, s.id)).toBeNull();
|
||
});
|
||
|
||
it('invalidates all sessions for a user', async () => {
|
||
const u = await createUserDirect(env.db, { email: 'd@example.com' });
|
||
const a = await createAuthSession(env.db, u.id);
|
||
const b = await createAuthSession(env.db, u.id);
|
||
await invalidateAllForUser(env.db, u.id);
|
||
expect(await validateAuthSession(env.db, a.id)).toBeNull();
|
||
expect(await validateAuthSession(env.db, b.id)).toBeNull();
|
||
});
|
||
|
||
it('supports excluding one session when invalidating (for change-password keep-current)', async () => {
|
||
const u = await createUserDirect(env.db, { email: 'e@example.com' });
|
||
const a = await createAuthSession(env.db, u.id);
|
||
const b = await createAuthSession(env.db, u.id);
|
||
await invalidateAllForUser(env.db, u.id, { except: b.id });
|
||
expect(await validateAuthSession(env.db, a.id)).toBeNull();
|
||
expect(await validateAuthSession(env.db, b.id)?.then?.((v) => v?.userId)).resolves !== undefined; // structural; assertion below
|
||
const v = await validateAuthSession(env.db, b.id);
|
||
expect(v?.userId).toBe(u.id);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run — fail**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend test
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `sessions.ts`**
|
||
|
||
```ts
|
||
import { randomBytes } from 'node:crypto';
|
||
import { and, eq, ne, sql } from 'drizzle-orm';
|
||
import type { Db } from '../../db/client.js';
|
||
import { sessionsAuth } from '../../db/schema.js';
|
||
|
||
const DEFAULT_TTL = 30 * 24 * 60 * 60; // 30 days
|
||
const REFRESH_THRESHOLD = 24 * 60 * 60; // refresh expires_at when within 1 day of expiry
|
||
|
||
function nowSec() { return Math.floor(Date.now() / 1000); }
|
||
function genId(): string { return randomBytes(32).toString('hex'); }
|
||
|
||
export interface CreatedSession {
|
||
id: string;
|
||
expiresAt: number;
|
||
}
|
||
|
||
export async function createAuthSession(
|
||
db: Db,
|
||
userId: number,
|
||
opts: { ttlSeconds?: number; userAgent?: string; ip?: string } = {}
|
||
): Promise<CreatedSession> {
|
||
const id = genId();
|
||
const ttl = opts.ttlSeconds ?? DEFAULT_TTL;
|
||
const expiresAt = nowSec() + ttl;
|
||
db.insert(sessionsAuth).values({
|
||
id, userId, expiresAt, lastUsedAt: nowSec(),
|
||
userAgent: opts.userAgent ?? null, ip: opts.ip ?? null,
|
||
}).run();
|
||
return { id, expiresAt };
|
||
}
|
||
|
||
export async function validateAuthSession(
|
||
db: Db,
|
||
id: string
|
||
): Promise<{ userId: number; expiresAt: number } | null> {
|
||
const row = db.select().from(sessionsAuth).where(eq(sessionsAuth.id, id)).get();
|
||
if (!row) return null;
|
||
const now = nowSec();
|
||
if (row.expiresAt <= now) return null;
|
||
// rolling refresh
|
||
if (row.expiresAt - now < REFRESH_THRESHOLD) {
|
||
db.update(sessionsAuth)
|
||
.set({ expiresAt: now + DEFAULT_TTL, lastUsedAt: now })
|
||
.where(eq(sessionsAuth.id, id))
|
||
.run();
|
||
} else {
|
||
db.update(sessionsAuth).set({ lastUsedAt: now }).where(eq(sessionsAuth.id, id)).run();
|
||
}
|
||
return { userId: row.userId, expiresAt: row.expiresAt };
|
||
}
|
||
|
||
export async function invalidateAuthSession(db: Db, id: string): Promise<void> {
|
||
db.delete(sessionsAuth).where(eq(sessionsAuth.id, id)).run();
|
||
}
|
||
|
||
export async function invalidateAllForUser(
|
||
db: Db,
|
||
userId: number,
|
||
opts: { except?: string } = {}
|
||
): Promise<void> {
|
||
if (opts.except) {
|
||
db.delete(sessionsAuth).where(and(eq(sessionsAuth.userId, userId), ne(sessionsAuth.id, opts.except))).run();
|
||
} else {
|
||
db.delete(sessionsAuth).where(eq(sessionsAuth.userId, userId)).run();
|
||
}
|
||
}
|
||
|
||
export async function purgeExpiredSessions(db: Db): Promise<void> {
|
||
const now = nowSec();
|
||
db.delete(sessionsAuth).where(sql`${sessionsAuth.expiresAt} <= ${now}`).run();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run — pass**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend test
|
||
```
|
||
|
||
Expected: 6 session tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/services/auth/sessions.ts packages/backend/src/services/auth/sessions.test.ts
|
||
git -c commit.gpgsign=false commit -m "feat(auth): server-side auth sessions with rolling expiry"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Email service + templates
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/services/auth/templates.ts`
|
||
- Create: `packages/backend/src/services/auth/email.ts`
|
||
|
||
- [ ] **Step 1: Install nodemailer**
|
||
|
||
```bash
|
||
npm i -w @flashcard/backend nodemailer
|
||
npm i -D -w @flashcard/backend @types/nodemailer
|
||
```
|
||
|
||
- [ ] **Step 2: Create `templates.ts`**
|
||
|
||
```ts
|
||
function layout(title: string, body: string): { html: string; text: string } {
|
||
const html = `<!doctype html>
|
||
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; background:#FAF5FF; padding:24px;">
|
||
<div style="max-width:520px; margin:0 auto; background:white; border-radius:24px; padding:32px; box-shadow:0 8px 32px rgba(124,58,237,0.12);">
|
||
<h1 style="margin:0 0 16px; font-size:22px; color:#6D28D9;">${title}</h1>
|
||
${body}
|
||
<hr style="border:none; border-top:1px solid #EFE7FC; margin:24px 0;" />
|
||
<p style="font-size:12px; color:#94A3B8;">Flashcard — leer slimmer met spaced repetition</p>
|
||
</div>
|
||
</body></html>`;
|
||
return { html, text: stripHtml(body) };
|
||
}
|
||
|
||
function stripHtml(s: string): string {
|
||
return s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
export function verifyEmailTemplate(appUrl: string, token: string, displayName: string) {
|
||
const link = `${appUrl}/verify-email?token=${encodeURIComponent(token)}`;
|
||
return {
|
||
subject: 'Bevestig je e-mailadres',
|
||
...layout('Welkom bij Flashcard 👋', `
|
||
<p>Hi ${escapeHtml(displayName)},</p>
|
||
<p>Klik op de knop hieronder om je e-mailadres te bevestigen. De link is 24 uur geldig.</p>
|
||
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Bevestig e-mail</a></p>
|
||
<p style="font-size:13px; color:#64748B;">Werkt de knop niet? Kopieer deze link in je browser:<br/><span style="word-break:break-all;">${link}</span></p>
|
||
`),
|
||
};
|
||
}
|
||
|
||
export function passwordResetTemplate(appUrl: string, token: string, displayName: string) {
|
||
const link = `${appUrl}/reset-password?token=${encodeURIComponent(token)}`;
|
||
return {
|
||
subject: 'Reset je wachtwoord',
|
||
...layout('Wachtwoord resetten', `
|
||
<p>Hi ${escapeHtml(displayName)},</p>
|
||
<p>Iemand vroeg een wachtwoord-reset aan voor je account. Was jij dat niet? Negeer dan deze e-mail.</p>
|
||
<p>De link is 1 uur geldig.</p>
|
||
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Reset wachtwoord</a></p>
|
||
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
|
||
`),
|
||
};
|
||
}
|
||
|
||
export function inviteTemplate(appUrl: string, token: string, inviterName: string) {
|
||
const link = `${appUrl}/accept-invite?token=${encodeURIComponent(token)}`;
|
||
return {
|
||
subject: 'Je bent uitgenodigd voor Flashcard',
|
||
...layout('Je bent uitgenodigd ✨', `
|
||
<p>${escapeHtml(inviterName)} heeft je uitgenodigd voor Flashcard.</p>
|
||
<p>Maak je account aan via onderstaande knop. De link is 24 uur geldig.</p>
|
||
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Account aanmaken</a></p>
|
||
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
|
||
`),
|
||
};
|
||
}
|
||
|
||
export function changeEmailTemplate(appUrl: string, token: string, displayName: string, newEmail: string) {
|
||
const link = `${appUrl}/verify-email?token=${encodeURIComponent(token)}`;
|
||
return {
|
||
subject: 'Bevestig je nieuwe e-mailadres',
|
||
...layout('Nieuw e-mailadres bevestigen', `
|
||
<p>Hi ${escapeHtml(displayName)},</p>
|
||
<p>Bevestig <strong>${escapeHtml(newEmail)}</strong> als je nieuwe e-mailadres.</p>
|
||
<p style="margin:24px 0;"><a href="${link}" style="background:#7C3AED; color:white; padding:12px 20px; border-radius:14px; text-decoration:none; font-weight:600;">Bevestig adres</a></p>
|
||
<p style="font-size:13px; color:#64748B; word-break:break-all;">${link}</p>
|
||
`),
|
||
};
|
||
}
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `email.ts`**
|
||
|
||
```ts
|
||
import nodemailer, { type Transporter } from 'nodemailer';
|
||
|
||
export interface Mailer {
|
||
send(to: string, msg: { subject: string; html: string; text: string }): Promise<void>;
|
||
}
|
||
|
||
class SmtpMailer implements Mailer {
|
||
constructor(private transporter: Transporter, private from: string) {}
|
||
async send(to: string, msg: { subject: string; html: string; text: string }): Promise<void> {
|
||
await this.transporter.sendMail({ from: this.from, to, ...msg });
|
||
}
|
||
}
|
||
|
||
class StubMailer implements Mailer {
|
||
async send(to: string, msg: { subject: string; html: string; text: string }): Promise<void> {
|
||
console.log('\n=== EMAIL (stub) ===');
|
||
console.log(`TO: ${to}`);
|
||
console.log(`SUBJECT: ${msg.subject}`);
|
||
console.log('---');
|
||
console.log(msg.text);
|
||
console.log('====================\n');
|
||
}
|
||
}
|
||
|
||
let cached: Mailer | null = null;
|
||
|
||
export function getMailer(): Mailer {
|
||
if (cached) return cached;
|
||
const host = process.env.SMTP_HOST;
|
||
const from = process.env.SMTP_FROM ?? 'Flashcard <noreply@example.com>';
|
||
if (!host) {
|
||
cached = new StubMailer();
|
||
return cached;
|
||
}
|
||
const transporter = nodemailer.createTransport({
|
||
host,
|
||
port: Number(process.env.SMTP_PORT ?? 587),
|
||
secure: process.env.SMTP_SECURE === 'true',
|
||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS ?? '' } : undefined,
|
||
});
|
||
cached = new SmtpMailer(transporter, from);
|
||
return cached;
|
||
}
|
||
|
||
export function setMailerForTests(m: Mailer | null): void {
|
||
cached = m;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Typecheck and commit**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend run typecheck
|
||
git add packages/backend/package.json package-lock.json packages/backend/src/services/auth/templates.ts packages/backend/src/services/auth/email.ts
|
||
git -c commit.gpgsign=false commit -m "feat(auth): email service with stub fallback + html templates"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Cookies & CSRF middleware
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/lib/cookies.ts`
|
||
- Create: `packages/backend/src/middleware/csrf.ts`
|
||
|
||
- [ ] **Step 1: Install cookie-parser**
|
||
|
||
```bash
|
||
npm i -w @flashcard/backend cookie-parser
|
||
npm i -D -w @flashcard/backend @types/cookie-parser
|
||
```
|
||
|
||
- [ ] **Step 2: Create `lib/cookies.ts`**
|
||
|
||
```ts
|
||
import type { CookieOptions } from 'express';
|
||
|
||
export const SID_COOKIE = 'flashcard_sid';
|
||
export const CSRF_COOKIE = 'flashcard_csrf';
|
||
export const CSRF_HEADER = 'x-csrf-token';
|
||
|
||
export function sidCookieOptions(expiresAtSec: number): CookieOptions {
|
||
return {
|
||
httpOnly: true,
|
||
sameSite: 'lax',
|
||
secure: process.env.COOKIE_SECURE === 'true',
|
||
path: '/',
|
||
expires: new Date(expiresAtSec * 1000),
|
||
};
|
||
}
|
||
|
||
export function csrfCookieOptions(expiresAtSec: number): CookieOptions {
|
||
return {
|
||
httpOnly: false,
|
||
sameSite: 'lax',
|
||
secure: process.env.COOKIE_SECURE === 'true',
|
||
path: '/',
|
||
expires: new Date(expiresAtSec * 1000),
|
||
};
|
||
}
|
||
|
||
export function clearCookieOptions(): CookieOptions {
|
||
return {
|
||
httpOnly: true,
|
||
sameSite: 'lax',
|
||
secure: process.env.COOKIE_SECURE === 'true',
|
||
path: '/',
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `middleware/csrf.ts`**
|
||
|
||
```ts
|
||
import { randomBytes } from 'node:crypto';
|
||
import type { Request, Response, NextFunction } from 'express';
|
||
import { CSRF_COOKIE, CSRF_HEADER, csrfCookieOptions } from '../lib/cookies.js';
|
||
import { ApiError } from '../lib/errors.js';
|
||
|
||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||
|
||
export function ensureCsrfToken(req: Request, res: Response, next: NextFunction): void {
|
||
if (!req.cookies?.[CSRF_COOKIE]) {
|
||
const token = randomBytes(24).toString('base64url');
|
||
const expires = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
|
||
res.cookie(CSRF_COOKIE, token, csrfCookieOptions(expires));
|
||
}
|
||
next();
|
||
}
|
||
|
||
export function verifyCsrf(req: Request, _res: Response, next: NextFunction): void {
|
||
if (SAFE_METHODS.has(req.method)) return next();
|
||
const cookieToken = req.cookies?.[CSRF_COOKIE];
|
||
const headerToken = req.headers[CSRF_HEADER];
|
||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||
return next(new ApiError(403, 'CSRF_MISMATCH', 'CSRF token mismatch'));
|
||
}
|
||
next();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Typecheck and commit**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend run typecheck
|
||
git add packages/backend/package.json package-lock.json packages/backend/src/lib/cookies.ts packages/backend/src/middleware/
|
||
git -c commit.gpgsign=false commit -m "feat(auth): cookies helpers and CSRF middleware"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Rate limit middleware
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/middleware/rate-limit.ts`
|
||
|
||
- [ ] **Step 1: Install express-rate-limit**
|
||
|
||
```bash
|
||
npm i -w @flashcard/backend express-rate-limit
|
||
```
|
||
|
||
- [ ] **Step 2: Create `rate-limit.ts`**
|
||
|
||
```ts
|
||
import rateLimit from 'express-rate-limit';
|
||
|
||
const fifteenMin = 15 * 60 * 1000;
|
||
|
||
function makeLimiter(max: number, codeMessage = 'Too many attempts, please try again later') {
|
||
return rateLimit({
|
||
windowMs: fifteenMin,
|
||
limit: max,
|
||
standardHeaders: 'draft-7',
|
||
legacyHeaders: false,
|
||
skip: () => process.env.NODE_ENV === 'test',
|
||
handler: (_req, res) => {
|
||
res.status(429).json({ error: { code: 'RATE_LIMITED', message: codeMessage } });
|
||
},
|
||
});
|
||
}
|
||
|
||
export const loginLimiter = makeLimiter(10);
|
||
export const registerLimiter = makeLimiter(5);
|
||
export const forgotPasswordLimiter = makeLimiter(5);
|
||
export const tokenLimiter = makeLimiter(20);
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/package.json package-lock.json packages/backend/src/middleware/rate-limit.ts
|
||
git -c commit.gpgsign=false commit -m "feat(auth): named rate limiters (skip in tests)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Auth middleware
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/middleware/auth.ts`
|
||
|
||
- [ ] **Step 1: Create `auth.ts`**
|
||
|
||
```ts
|
||
import type { Request, Response, NextFunction } from 'express';
|
||
import { eq } from 'drizzle-orm';
|
||
import type { Db } from '../db/client.js';
|
||
import { users } from '../db/schema.js';
|
||
import { validateAuthSession } from '../services/auth/sessions.js';
|
||
import { ApiError } from '../lib/errors.js';
|
||
import { SID_COOKIE } from '../lib/cookies.js';
|
||
import type { Role, User } from '@flashcard/shared';
|
||
|
||
declare module 'express-serve-static-core' {
|
||
interface Request {
|
||
user?: User;
|
||
sessionId?: string;
|
||
}
|
||
}
|
||
|
||
function rowToUser(r: typeof users.$inferSelect): User {
|
||
return {
|
||
id: r.id,
|
||
email: r.email,
|
||
displayName: r.displayName,
|
||
role: r.role,
|
||
isActive: r.isActive,
|
||
emailVerifiedAt: r.emailVerifiedAt ?? null,
|
||
pendingEmail: r.pendingEmail ?? null,
|
||
createdAt: r.createdAt,
|
||
updatedAt: r.updatedAt,
|
||
};
|
||
}
|
||
|
||
export function currentUserOrNull(db: Db) {
|
||
return async (req: Request, _res: Response, next: NextFunction) => {
|
||
const sid = req.cookies?.[SID_COOKIE];
|
||
if (!sid) return next();
|
||
const session = await validateAuthSession(db, sid);
|
||
if (!session) return next();
|
||
const row = db.select().from(users).where(eq(users.id, session.userId)).get();
|
||
if (!row || !row.isActive) return next();
|
||
req.user = rowToUser(row);
|
||
req.sessionId = sid;
|
||
next();
|
||
};
|
||
}
|
||
|
||
export function requireAuth(req: Request, _res: Response, next: NextFunction): void {
|
||
if (!req.user) return next(new ApiError(401, 'UNAUTHENTICATED', 'Authentication required'));
|
||
next();
|
||
}
|
||
|
||
export function requireRole(role: Role) {
|
||
return (req: Request, _res: Response, next: NextFunction): void => {
|
||
if (!req.user) return next(new ApiError(401, 'UNAUTHENTICATED', 'Authentication required'));
|
||
if (req.user.role !== role) return next(new ApiError(403, 'FORBIDDEN', 'Insufficient role'));
|
||
next();
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend run typecheck
|
||
```
|
||
|
||
Expected: passes.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/middleware/auth.ts
|
||
git -c commit.gpgsign=false commit -m "feat(auth): currentUser, requireAuth, requireRole middleware"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Auth routes — register, login, logout, me
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/routes/auth.ts`
|
||
- Create: `packages/backend/src/tests/auth.integration.test.ts`
|
||
- Modify: `packages/backend/src/tests/dbHelper.ts` (add `makeTestApp`)
|
||
|
||
- [ ] **Step 1: Extend `tests/dbHelper.ts` with `makeTestApp` helper**
|
||
|
||
Append to `packages/backend/src/tests/dbHelper.ts`:
|
||
|
||
```ts
|
||
import type { Express } from 'express';
|
||
export interface TestApp {
|
||
app: Express;
|
||
db: Db;
|
||
close: () => void;
|
||
}
|
||
```
|
||
|
||
(Actual `makeTestApp` is added once `createApp(db)` is wired in Task 14. For now, expose `makeTestDb` and `createUserDirect` only.)
|
||
|
||
- [ ] **Step 2: Write integration tests covering register → verify → login → me → logout**
|
||
|
||
Create `packages/backend/src/tests/auth.integration.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import request from 'supertest';
|
||
import { createApp } from '../app.js';
|
||
import { makeTestDb, createUserDirect } from './dbHelper.js';
|
||
import { setMailerForTests, type Mailer } from '../services/auth/email.js';
|
||
|
||
class CaptureMailer implements Mailer {
|
||
sent: { to: string; subject: string; text: string; html: string }[] = [];
|
||
async send(to: string, m: { subject: string; html: string; text: string }) {
|
||
this.sent.push({ to, ...m });
|
||
}
|
||
}
|
||
|
||
let env: ReturnType<typeof makeTestDb>;
|
||
let mailer: CaptureMailer;
|
||
let app: ReturnType<typeof createApp>;
|
||
|
||
beforeEach(() => {
|
||
env = makeTestDb();
|
||
mailer = new CaptureMailer();
|
||
setMailerForTests(mailer);
|
||
app = createApp(env.db);
|
||
});
|
||
|
||
function tokenFromMail(text: string): string {
|
||
const m = text.match(/token=([^\s&"]+)/);
|
||
if (!m) throw new Error('no token in mail');
|
||
return decodeURIComponent(m[1]!);
|
||
}
|
||
|
||
describe('auth: register → verify → login → me → logout', () => {
|
||
it('registers, blocks unverified login, verifies, logs in, returns me, logs out', async () => {
|
||
// 1. Register
|
||
const reg = await request(app).post('/api/auth/register').send({
|
||
email: 'alice@example.com', displayName: 'Alice', password: 'secretpass',
|
||
});
|
||
expect(reg.status).toBe(201);
|
||
expect(mailer.sent).toHaveLength(1);
|
||
const verifyToken = tokenFromMail(mailer.sent[0]!.text);
|
||
|
||
// 2. Login unverified -> 403
|
||
const bad = await request(app).post('/api/auth/login').send({ email: 'alice@example.com', password: 'secretpass' });
|
||
expect(bad.status).toBe(403);
|
||
expect(bad.body.error.code).toBe('EMAIL_NOT_VERIFIED');
|
||
|
||
// 3. Verify
|
||
const ver = await request(app).post('/api/auth/verify-email').send({ token: verifyToken });
|
||
expect(ver.status).toBe(200);
|
||
|
||
// 4. Login -> 200 + cookies
|
||
const ok = await request(app).post('/api/auth/login').send({ email: 'alice@example.com', password: 'secretpass' });
|
||
expect(ok.status).toBe(200);
|
||
const cookies = ok.headers['set-cookie'] as unknown as string[];
|
||
expect(cookies.find((c) => c.startsWith('flashcard_sid='))).toBeDefined();
|
||
expect(cookies.find((c) => c.startsWith('flashcard_csrf='))).toBeDefined();
|
||
|
||
// 5. /me works
|
||
const me = await request(app).get('/api/auth/me').set('Cookie', cookies);
|
||
expect(me.status).toBe(200);
|
||
expect(me.body.email).toBe('alice@example.com');
|
||
|
||
// 6. Logout
|
||
const logout = await request(app).post('/api/auth/logout').set('Cookie', cookies)
|
||
.set('x-csrf-token', extractCookieValue(cookies, 'flashcard_csrf'));
|
||
expect(logout.status).toBe(204);
|
||
|
||
// 7. /me unauth after logout
|
||
const me2 = await request(app).get('/api/auth/me').set('Cookie', cookies);
|
||
expect(me2.status).toBe(401);
|
||
});
|
||
|
||
it('marks first registered user as sysadmin', async () => {
|
||
const r = await request(app).post('/api/auth/register').send({
|
||
email: 'first@example.com', displayName: 'First', password: 'secretpass',
|
||
});
|
||
expect(r.status).toBe(201);
|
||
const token = tokenFromMail(mailer.sent[0]!.text);
|
||
await request(app).post('/api/auth/verify-email').send({ token });
|
||
|
||
const login = await request(app).post('/api/auth/login').send({ email: 'first@example.com', password: 'secretpass' });
|
||
const cookies = login.headers['set-cookie'] as unknown as string[];
|
||
const me = await request(app).get('/api/auth/me').set('Cookie', cookies);
|
||
expect(me.body.role).toBe('sysadmin');
|
||
});
|
||
|
||
it('rejects duplicate email', async () => {
|
||
await request(app).post('/api/auth/register').send({ email: 'a@example.com', displayName: 'A', password: 'secretpass' });
|
||
const dup = await request(app).post('/api/auth/register').send({ email: 'A@example.com', displayName: 'A2', password: 'secretpass' });
|
||
expect(dup.status).toBe(409);
|
||
expect(dup.body.error.code).toBe('EMAIL_TAKEN');
|
||
});
|
||
});
|
||
|
||
function extractCookieValue(cookies: string[], name: string): string {
|
||
for (const c of cookies) {
|
||
if (c.startsWith(`${name}=`)) {
|
||
const v = c.split(';')[0]!.slice(name.length + 1);
|
||
return decodeURIComponent(v);
|
||
}
|
||
}
|
||
throw new Error(`cookie ${name} not found`);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run — tests fail (routes not implemented)**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend test
|
||
```
|
||
|
||
Expected: tests fail.
|
||
|
||
- [ ] **Step 4: Implement `routes/auth.ts` (first slice — register/verify/login/logout/me)**
|
||
|
||
```ts
|
||
import { Router } from 'express';
|
||
import { eq, sql } from 'drizzle-orm';
|
||
import {
|
||
registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema,
|
||
forgotPasswordSchema, resetPasswordSchema, profileUpdateSchema, changePasswordSchema,
|
||
acceptInviteSchema, type PublicUser,
|
||
} from '@flashcard/shared';
|
||
import type { Db } from '../db/client.js';
|
||
import { users } from '../db/schema.js';
|
||
import { hashPassword, verifyPassword } from '../services/auth/passwords.js';
|
||
import { createAuthToken, consumeAuthToken, invalidateTokensForUser } from '../services/auth/tokens.js';
|
||
import { createAuthSession, invalidateAuthSession, invalidateAllForUser } from '../services/auth/sessions.js';
|
||
import { getMailer } from '../services/auth/email.js';
|
||
import {
|
||
verifyEmailTemplate, passwordResetTemplate, inviteTemplate, changeEmailTemplate,
|
||
} from '../services/auth/templates.js';
|
||
import { SID_COOKIE, CSRF_COOKIE, sidCookieOptions, csrfCookieOptions, clearCookieOptions } from '../lib/cookies.js';
|
||
import { ApiError } from '../lib/errors.js';
|
||
import { requireAuth } from '../middleware/auth.js';
|
||
import { loginLimiter, registerLimiter, forgotPasswordLimiter, tokenLimiter } from '../middleware/rate-limit.js';
|
||
import { randomBytes } from 'node:crypto';
|
||
|
||
const VERIFY_TTL = 24 * 60 * 60;
|
||
const INVITE_TTL = 24 * 60 * 60;
|
||
const RESET_TTL = 60 * 60;
|
||
const CHANGE_EMAIL_TTL = 24 * 60 * 60;
|
||
|
||
function nowSec() { return Math.floor(Date.now() / 1000); }
|
||
function appUrl() { return process.env.APP_URL ?? 'http://localhost:5173'; }
|
||
|
||
function toPublicUser(r: typeof users.$inferSelect): PublicUser {
|
||
return { id: r.id, email: r.email, displayName: r.displayName, role: r.role };
|
||
}
|
||
|
||
export function authRouter(db: Db): Router {
|
||
const r = Router();
|
||
|
||
r.get('/me', (req, res) => {
|
||
if (!req.user) {
|
||
res.status(401).json({ error: { code: 'UNAUTHENTICATED', message: 'Not signed in' } });
|
||
return;
|
||
}
|
||
res.json(req.user);
|
||
});
|
||
|
||
r.post('/register', registerLimiter, async (req, res, next) => {
|
||
try {
|
||
const input = registerSchema.parse(req.body);
|
||
const existing = db.select().from(users).where(eq(users.email, input.email)).get();
|
||
if (existing) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use');
|
||
|
||
const totalUsers = db.select({ c: sql<number>`count(*)`.as('c') }).from(users).get()?.c ?? 0;
|
||
const role: 'user' | 'sysadmin' = Number(totalUsers) === 0 ? 'sysadmin' : 'user';
|
||
const passwordHash = await hashPassword(input.password);
|
||
const [user] = db.insert(users).values({
|
||
email: input.email,
|
||
displayName: input.displayName,
|
||
passwordHash,
|
||
role,
|
||
}).returning().all();
|
||
|
||
const { plaintext } = await createAuthToken(db, user!.id, 'verify_email', VERIFY_TTL);
|
||
const tpl = verifyEmailTemplate(appUrl(), plaintext, user!.displayName);
|
||
await getMailer().send(user!.email, tpl);
|
||
|
||
res.status(201).json({ id: user!.id, email: user!.email, displayName: user!.displayName, role: user!.role });
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/verify-email', tokenLimiter, async (req, res, next) => {
|
||
try {
|
||
const { token } = verifyEmailSchema.parse(req.body);
|
||
// Try verify_email first; if not found, try change_email
|
||
let consumed = await consumeAuthToken(db, token, 'verify_email');
|
||
let purpose: 'verify_email' | 'change_email' = 'verify_email';
|
||
if (!consumed) {
|
||
consumed = await consumeAuthToken(db, token, 'change_email');
|
||
purpose = 'change_email';
|
||
}
|
||
if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token');
|
||
|
||
if (purpose === 'verify_email') {
|
||
db.update(users).set({ emailVerifiedAt: nowSec(), updatedAt: nowSec() })
|
||
.where(eq(users.id, consumed.userId)).run();
|
||
} else {
|
||
const newEmail = consumed.payload;
|
||
if (!newEmail) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid token payload');
|
||
const taken = db.select().from(users).where(eq(users.email, newEmail)).get();
|
||
if (taken && taken.id !== consumed.userId) {
|
||
throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use');
|
||
}
|
||
db.update(users).set({ email: newEmail, pendingEmail: null, updatedAt: nowSec() })
|
||
.where(eq(users.id, consumed.userId)).run();
|
||
}
|
||
res.status(200).json({ ok: true });
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/resend-verification', tokenLimiter, async (req, res, next) => {
|
||
try {
|
||
const { email } = resendVerificationSchema.parse(req.body);
|
||
const u = db.select().from(users).where(eq(users.email, email)).get();
|
||
// Generic 200 response (no enumeration)
|
||
if (u && !u.emailVerifiedAt && u.isActive) {
|
||
await invalidateTokensForUser(db, u.id, 'verify_email');
|
||
const { plaintext } = await createAuthToken(db, u.id, 'verify_email', VERIFY_TTL);
|
||
const tpl = verifyEmailTemplate(appUrl(), plaintext, u.displayName);
|
||
await getMailer().send(u.email, tpl);
|
||
}
|
||
res.status(200).json({ ok: true });
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/login', loginLimiter, async (req, res, next) => {
|
||
try {
|
||
const input = loginSchema.parse(req.body);
|
||
const u = db.select().from(users).where(eq(users.email, input.email)).get();
|
||
if (!u || !u.passwordHash || !(await verifyPassword(input.password, u.passwordHash))) {
|
||
throw new ApiError(401, 'INVALID_CREDENTIALS', 'Invalid email or password');
|
||
}
|
||
if (!u.isActive) throw new ApiError(403, 'ACCOUNT_DISABLED', 'Account is disabled');
|
||
if (!u.emailVerifiedAt) throw new ApiError(403, 'EMAIL_NOT_VERIFIED', 'Email not verified');
|
||
|
||
const s = await createAuthSession(db, u.id, {
|
||
userAgent: req.headers['user-agent'] ?? null,
|
||
ip: req.ip ?? null,
|
||
});
|
||
const csrf = randomBytes(24).toString('base64url');
|
||
res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt));
|
||
res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt));
|
||
res.json(toPublicUser(u));
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/logout', requireAuth, async (req, res, next) => {
|
||
try {
|
||
if (req.sessionId) await invalidateAuthSession(db, req.sessionId);
|
||
res.clearCookie(SID_COOKIE, clearCookieOptions());
|
||
res.clearCookie(CSRF_COOKIE, { ...clearCookieOptions(), httpOnly: false });
|
||
res.status(204).end();
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) => {
|
||
try {
|
||
const { email } = forgotPasswordSchema.parse(req.body);
|
||
const u = db.select().from(users).where(eq(users.email, email)).get();
|
||
if (u && u.isActive && u.emailVerifiedAt) {
|
||
await invalidateTokensForUser(db, u.id, 'password_reset');
|
||
const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL);
|
||
const tpl = passwordResetTemplate(appUrl(), plaintext, u.displayName);
|
||
await getMailer().send(u.email, tpl);
|
||
}
|
||
res.status(200).json({ ok: true });
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/reset-password', tokenLimiter, async (req, res, next) => {
|
||
try {
|
||
const { token, password } = resetPasswordSchema.parse(req.body);
|
||
const consumed = await consumeAuthToken(db, token, 'password_reset');
|
||
if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token');
|
||
const passwordHash = await hashPassword(password);
|
||
db.update(users).set({ passwordHash, updatedAt: nowSec() })
|
||
.where(eq(users.id, consumed.userId)).run();
|
||
await invalidateAllForUser(db, consumed.userId);
|
||
res.status(200).json({ ok: true });
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/accept-invite', tokenLimiter, async (req, res, next) => {
|
||
try {
|
||
const input = acceptInviteSchema.parse(req.body);
|
||
const consumed = await consumeAuthToken(db, input.token, 'invite');
|
||
if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token');
|
||
const passwordHash = await hashPassword(input.password);
|
||
const [updated] = db.update(users).set({
|
||
passwordHash, displayName: input.displayName,
|
||
emailVerifiedAt: nowSec(), updatedAt: nowSec(),
|
||
}).where(eq(users.id, consumed.userId)).returning().all();
|
||
if (!updated) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid invitation');
|
||
const s = await createAuthSession(db, updated.id, {
|
||
userAgent: req.headers['user-agent'] ?? null, ip: req.ip ?? null,
|
||
});
|
||
const csrf = randomBytes(24).toString('base64url');
|
||
res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt));
|
||
res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt));
|
||
res.status(200).json(toPublicUser(updated));
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.patch('/profile', requireAuth, async (req, res, next) => {
|
||
try {
|
||
const input = profileUpdateSchema.parse(req.body);
|
||
const user = req.user!;
|
||
const updates: Record<string, unknown> = { updatedAt: nowSec() };
|
||
if (input.displayName !== undefined) updates.displayName = input.displayName;
|
||
let sendEmailChange: string | null = null;
|
||
if (input.email !== undefined && input.email !== user.email) {
|
||
const taken = db.select().from(users).where(eq(users.email, input.email)).get();
|
||
if (taken) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use');
|
||
updates.pendingEmail = input.email;
|
||
sendEmailChange = input.email;
|
||
}
|
||
const [updated] = db.update(users).set(updates).where(eq(users.id, user.id)).returning().all();
|
||
if (sendEmailChange && updated) {
|
||
const { plaintext } = await createAuthToken(db, updated.id, 'change_email', CHANGE_EMAIL_TTL, sendEmailChange);
|
||
const tpl = changeEmailTemplate(appUrl(), plaintext, updated.displayName, sendEmailChange);
|
||
await getMailer().send(sendEmailChange, tpl);
|
||
}
|
||
res.json(toPublicUser(updated!));
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/change-password', requireAuth, async (req, res, next) => {
|
||
try {
|
||
const input = changePasswordSchema.parse(req.body);
|
||
const user = req.user!;
|
||
const row = db.select().from(users).where(eq(users.id, user.id)).get();
|
||
if (!row?.passwordHash || !(await verifyPassword(input.currentPassword, row.passwordHash))) {
|
||
throw new ApiError(401, 'INVALID_CREDENTIALS', 'Current password is incorrect');
|
||
}
|
||
const passwordHash = await hashPassword(input.newPassword);
|
||
db.update(users).set({ passwordHash, updatedAt: nowSec() }).where(eq(users.id, user.id)).run();
|
||
if (req.sessionId) await invalidateAllForUser(db, user.id, { except: req.sessionId });
|
||
res.status(204).end();
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
return r;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Install supertest if not already**
|
||
|
||
```bash
|
||
# Already installed per Task 3 backend bootstrap (supertest is in devDeps).
|
||
# Verify:
|
||
node -p "require('./packages/backend/node_modules/supertest/package.json').version" || npm i -D -w @flashcard/backend supertest @types/supertest
|
||
```
|
||
|
||
- [ ] **Step 6: Stop here — full integration test runs after app wiring (Task 14)**
|
||
|
||
Don't run the integration test yet. It needs `createApp` to mount the new router. We'll un-skip it after Task 14.
|
||
|
||
For now, just typecheck:
|
||
|
||
```bash
|
||
npm -w @flashcard/backend run typecheck
|
||
```
|
||
|
||
Expected: passes.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/routes/auth.ts packages/backend/src/tests/auth.integration.test.ts packages/backend/src/tests/dbHelper.ts
|
||
git -c commit.gpgsign=false commit -m "feat(auth): /api/auth routes (register, verify, login, logout, me, forgot, reset, profile, change-password, accept-invite)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Users service & admin routes
|
||
|
||
**Files:**
|
||
- Create: `packages/backend/src/services/users.ts`
|
||
- Create: `packages/backend/src/routes/admin-users.ts`
|
||
- Create: `packages/backend/src/tests/admin-users.integration.test.ts`
|
||
|
||
- [ ] **Step 1: Implement `services/users.ts`**
|
||
|
||
```ts
|
||
import { and, asc, desc, eq, like, ne, or, sql } from 'drizzle-orm';
|
||
import type { Db } from '../db/client.js';
|
||
import { users } from '../db/schema.js';
|
||
import type { Role, User } from '@flashcard/shared';
|
||
import { ApiError } from '../lib/errors.js';
|
||
|
||
function rowToUser(r: typeof users.$inferSelect): User {
|
||
return {
|
||
id: r.id, email: r.email, displayName: r.displayName, role: r.role,
|
||
isActive: r.isActive, emailVerifiedAt: r.emailVerifiedAt ?? null,
|
||
pendingEmail: r.pendingEmail ?? null, createdAt: r.createdAt, updatedAt: r.updatedAt,
|
||
};
|
||
}
|
||
|
||
export interface ListUsersParams { q?: string; role?: Role; active?: boolean; limit?: number; offset?: number; }
|
||
export interface ListUsersResult { rows: User[]; total: number; }
|
||
|
||
export async function listUsers(db: Db, p: ListUsersParams = {}): Promise<ListUsersResult> {
|
||
const conditions = [] as ReturnType<typeof eq>[];
|
||
if (p.q && p.q.trim() !== '') {
|
||
const q = `%${p.q.toLowerCase()}%`;
|
||
conditions.push(or(like(sql`lower(${users.email})`, q), like(sql`lower(${users.displayName})`, q))!);
|
||
}
|
||
if (p.role) conditions.push(eq(users.role, p.role));
|
||
if (p.active !== undefined) conditions.push(eq(users.isActive, p.active));
|
||
const where = conditions.length ? and(...conditions) : undefined;
|
||
const limit = Math.min(200, p.limit ?? 50);
|
||
const offset = Math.max(0, p.offset ?? 0);
|
||
|
||
const rows = db.select().from(users)
|
||
.where(where as any)
|
||
.orderBy(asc(users.email))
|
||
.limit(limit).offset(offset).all();
|
||
const totalRow = db.select({ c: sql<number>`count(*)`.as('c') }).from(users).where(where as any).get();
|
||
return { rows: rows.map(rowToUser), total: Number(totalRow?.c ?? 0) };
|
||
}
|
||
|
||
export async function adminUpdateUser(
|
||
db: Db,
|
||
actorId: number,
|
||
id: number,
|
||
updates: { displayName?: string; role?: Role; isActive?: boolean }
|
||
): Promise<User> {
|
||
const target = db.select().from(users).where(eq(users.id, id)).get();
|
||
if (!target) throw ApiError.notFound('User');
|
||
|
||
// Last-sysadmin guard
|
||
if (target.id === actorId && (updates.isActive === false || updates.role === 'user')) {
|
||
const otherActiveSysadmins = db.select({ c: sql<number>`count(*)`.as('c') })
|
||
.from(users)
|
||
.where(and(eq(users.role, 'sysadmin'), eq(users.isActive, true), ne(users.id, actorId)))
|
||
.get()?.c ?? 0;
|
||
if (Number(otherActiveSysadmins) === 0) {
|
||
throw new ApiError(409, 'LAST_SYSADMIN', 'Cannot demote or disable the last active sysadmin');
|
||
}
|
||
}
|
||
|
||
const [row] = db.update(users).set({
|
||
...(updates.displayName !== undefined && { displayName: updates.displayName }),
|
||
...(updates.role !== undefined && { role: updates.role }),
|
||
...(updates.isActive !== undefined && { isActive: updates.isActive }),
|
||
updatedAt: Math.floor(Date.now() / 1000),
|
||
}).where(eq(users.id, id)).returning().all();
|
||
return rowToUser(row!);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement `routes/admin-users.ts`**
|
||
|
||
```ts
|
||
import { Router } from 'express';
|
||
import { eq } from 'drizzle-orm';
|
||
import {
|
||
adminUserUpdateSchema, inviteUserSchema,
|
||
} from '@flashcard/shared';
|
||
import type { Db } from '../db/client.js';
|
||
import { users } from '../db/schema.js';
|
||
import { listUsers, adminUpdateUser } from '../services/users.js';
|
||
import { createAuthToken, invalidateTokensForUser } from '../services/auth/tokens.js';
|
||
import { getMailer } from '../services/auth/email.js';
|
||
import { inviteTemplate, passwordResetTemplate } from '../services/auth/templates.js';
|
||
import { ApiError } from '../lib/errors.js';
|
||
|
||
const INVITE_TTL = 24 * 60 * 60;
|
||
const RESET_TTL = 60 * 60;
|
||
function appUrl() { return process.env.APP_URL ?? 'http://localhost:5173'; }
|
||
function nowSec() { return Math.floor(Date.now() / 1000); }
|
||
|
||
export function adminUsersRouter(db: Db): Router {
|
||
const r = Router();
|
||
|
||
r.get('/', async (req, res, next) => {
|
||
try {
|
||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||
const role = req.query.role === 'user' || req.query.role === 'sysadmin' ? req.query.role : undefined;
|
||
const active = req.query.active === 'true' ? true : req.query.active === 'false' ? false : undefined;
|
||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||
const offset = req.query.offset ? Number(req.query.offset) : undefined;
|
||
res.json(await listUsers(db, { q, role, active, limit, offset }));
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/invite', async (req, res, next) => {
|
||
try {
|
||
const input = inviteUserSchema.parse(req.body);
|
||
const existing = db.select().from(users).where(eq(users.email, input.email)).get();
|
||
if (existing) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use');
|
||
const [u] = db.insert(users).values({
|
||
email: input.email,
|
||
displayName: input.email.split('@')[0]!,
|
||
role: input.role,
|
||
passwordHash: null,
|
||
emailVerifiedAt: null,
|
||
isActive: true,
|
||
}).returning().all();
|
||
const { plaintext } = await createAuthToken(db, u!.id, 'invite', INVITE_TTL);
|
||
const inviter = req.user!;
|
||
const tpl = inviteTemplate(appUrl(), plaintext, inviter.displayName);
|
||
await getMailer().send(u!.email, tpl);
|
||
res.status(201).json({ id: u!.id, email: u!.email, role: u!.role });
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.patch('/:id', async (req, res, next) => {
|
||
try {
|
||
const input = adminUserUpdateSchema.parse(req.body);
|
||
const actor = req.user!;
|
||
const updated = await adminUpdateUser(db, actor.id, Number(req.params.id), input);
|
||
res.json(updated);
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
r.post('/:id/send-reset', async (req, res, next) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
const u = db.select().from(users).where(eq(users.id, id)).get();
|
||
if (!u) throw ApiError.notFound('User');
|
||
if (!u.emailVerifiedAt) {
|
||
// resend invite instead
|
||
await invalidateTokensForUser(db, u.id, 'invite');
|
||
const { plaintext } = await createAuthToken(db, u.id, 'invite', INVITE_TTL);
|
||
await getMailer().send(u.email, inviteTemplate(appUrl(), plaintext, req.user!.displayName));
|
||
} else {
|
||
await invalidateTokensForUser(db, u.id, 'password_reset');
|
||
const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL);
|
||
await getMailer().send(u.email, passwordResetTemplate(appUrl(), plaintext, u.displayName));
|
||
}
|
||
res.status(204).end();
|
||
} catch (e) { next(e); }
|
||
});
|
||
|
||
return r;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Write integration tests**
|
||
|
||
Create `packages/backend/src/tests/admin-users.integration.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import request from 'supertest';
|
||
import { eq } from 'drizzle-orm';
|
||
import { createApp } from '../app.js';
|
||
import { users } from '../db/schema.js';
|
||
import { makeTestDb, createUserDirect } from './dbHelper.js';
|
||
import { hashPassword } from '../services/auth/passwords.js';
|
||
import { setMailerForTests, type Mailer } from '../services/auth/email.js';
|
||
|
||
class CaptureMailer implements Mailer {
|
||
sent: { to: string; subject: string; text: string }[] = [];
|
||
async send(to: string, m: { subject: string; html: string; text: string }) {
|
||
this.sent.push({ to, subject: m.subject, text: m.text });
|
||
}
|
||
}
|
||
|
||
async function loginAs(app: ReturnType<typeof createApp>, email: string, password = 'secretpass') {
|
||
const r = await request(app).post('/api/auth/login').send({ email, password });
|
||
if (r.status !== 200) throw new Error(`login failed: ${r.status} ${JSON.stringify(r.body)}`);
|
||
const cookies = (r.headers['set-cookie'] as unknown as string[]);
|
||
const csrf = cookies.find((c) => c.startsWith('flashcard_csrf='))!.split(';')[0]!.split('=')[1]!;
|
||
return { cookies, csrf };
|
||
}
|
||
|
||
async function makeAdmin(env: ReturnType<typeof makeTestDb>, email: string) {
|
||
return createUserDirect(env.db, {
|
||
email, role: 'sysadmin', isActive: true,
|
||
passwordHash: await hashPassword('secretpass'),
|
||
emailVerifiedAt: Math.floor(Date.now() / 1000),
|
||
});
|
||
}
|
||
|
||
let env: ReturnType<typeof makeTestDb>;
|
||
let mailer: CaptureMailer;
|
||
let app: ReturnType<typeof createApp>;
|
||
|
||
beforeEach(async () => {
|
||
env = makeTestDb();
|
||
mailer = new CaptureMailer();
|
||
setMailerForTests(mailer);
|
||
app = createApp(env.db);
|
||
});
|
||
|
||
describe('admin users', () => {
|
||
it('invites a user, target accepts and can log in', async () => {
|
||
await makeAdmin(env, 'admin@example.com');
|
||
const { cookies, csrf } = await loginAs(app, 'admin@example.com');
|
||
|
||
const inv = await request(app).post('/api/admin/users/invite')
|
||
.set('Cookie', cookies).set('x-csrf-token', csrf)
|
||
.send({ email: 'newbie@example.com', role: 'user' });
|
||
expect(inv.status).toBe(201);
|
||
expect(mailer.sent).toHaveLength(1);
|
||
const link = mailer.sent[0]!.text;
|
||
const token = decodeURIComponent(link.match(/token=([^\s&"]+)/)![1]!);
|
||
|
||
const accept = await request(app).post('/api/auth/accept-invite')
|
||
.send({ token, displayName: 'Newbie', password: 'anotherpass' });
|
||
expect(accept.status).toBe(200);
|
||
expect(accept.body.email).toBe('newbie@example.com');
|
||
});
|
||
|
||
it('lists users with filtering', async () => {
|
||
await makeAdmin(env, 'admin@example.com');
|
||
await createUserDirect(env.db, { email: 'a@example.com', role: 'user' });
|
||
await createUserDirect(env.db, { email: 'b@example.com', role: 'user' });
|
||
const { cookies } = await loginAs(app, 'admin@example.com');
|
||
const list = await request(app).get('/api/admin/users?q=a').set('Cookie', cookies);
|
||
expect(list.status).toBe(200);
|
||
expect(list.body.rows.find((u: { email: string }) => u.email === 'a@example.com')).toBeTruthy();
|
||
});
|
||
|
||
it('blocks demoting the last sysadmin', async () => {
|
||
const admin = await makeAdmin(env, 'admin@example.com');
|
||
const { cookies, csrf } = await loginAs(app, 'admin@example.com');
|
||
const r = await request(app).patch(`/api/admin/users/${admin.id}`)
|
||
.set('Cookie', cookies).set('x-csrf-token', csrf)
|
||
.send({ role: 'user' });
|
||
expect(r.status).toBe(409);
|
||
expect(r.body.error.code).toBe('LAST_SYSADMIN');
|
||
});
|
||
|
||
it('rejects non-admin access to admin routes', async () => {
|
||
// Create a normal verified user
|
||
await createUserDirect(env.db, {
|
||
email: 'plain@example.com', role: 'user', isActive: true,
|
||
passwordHash: await hashPassword('secretpass'),
|
||
emailVerifiedAt: Math.floor(Date.now() / 1000),
|
||
});
|
||
const { cookies } = await loginAs(app, 'plain@example.com');
|
||
const r = await request(app).get('/api/admin/users').set('Cookie', cookies);
|
||
expect(r.status).toBe(403);
|
||
});
|
||
|
||
it('blocks unauthenticated access to admin routes', async () => {
|
||
const r = await request(app).get('/api/admin/users');
|
||
expect(r.status).toBe(401);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Typecheck**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend run typecheck
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/services/users.ts packages/backend/src/routes/admin-users.ts packages/backend/src/tests/admin-users.integration.test.ts
|
||
git -c commit.gpgsign=false commit -m "feat(auth): admin user management service, routes, and integration tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Wire auth into app.ts + protect existing endpoints
|
||
|
||
**Files:**
|
||
- Modify: `packages/backend/src/app.ts`
|
||
- Modify: `packages/backend/src/index.ts`
|
||
|
||
- [ ] **Step 1: Rewrite `app.ts`**
|
||
|
||
Replace `packages/backend/src/app.ts` with:
|
||
|
||
```ts
|
||
import express, { type Express, type NextFunction, type Request, type Response } from 'express';
|
||
import cookieParser from 'cookie-parser';
|
||
import { existsSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { ZodError } from 'zod';
|
||
import type { Db } from './db/client.js';
|
||
import { ApiError } from './lib/errors.js';
|
||
import { currentUserOrNull, requireAuth, requireRole } from './middleware/auth.js';
|
||
import { ensureCsrfToken, verifyCsrf } from './middleware/csrf.js';
|
||
import { authRouter } from './routes/auth.js';
|
||
import { adminUsersRouter } from './routes/admin-users.js';
|
||
import { lessonsRouter } from './routes/lessons.js';
|
||
import { cardsRouter } from './routes/cards.js';
|
||
import { sessionsRouter } from './routes/sessions.js';
|
||
import { statsRouter } from './routes/stats.js';
|
||
|
||
export function createApp(db: Db): Express {
|
||
const app = express();
|
||
app.set('trust proxy', 1);
|
||
app.use(cookieParser());
|
||
app.use(express.json({ limit: '5mb' }));
|
||
app.use(ensureCsrfToken);
|
||
app.use(currentUserOrNull(db));
|
||
|
||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||
|
||
// Public auth endpoints — verifyCsrf applied INSIDE the router for mutations that don't need it
|
||
// (register/login/forgot/reset/verify/accept-invite/resend-verification are public mutations, so CSRF is not relevant).
|
||
// However, /api/auth/logout, /api/auth/profile, /api/auth/change-password DO need CSRF.
|
||
app.use('/api/auth', authRouter(db));
|
||
|
||
// Protected app routes — auth + csrf on mutations
|
||
app.use('/api/lessons', requireAuth, verifyCsrf, lessonsRouter(db));
|
||
app.use('/api', requireAuth, verifyCsrf, cardsRouter(db));
|
||
app.use('/api/sessions', requireAuth, verifyCsrf, sessionsRouter(db));
|
||
app.use('/api/stats', requireAuth, statsRouter(db)); // GET only — no CSRF needed
|
||
app.use('/api/admin/users', requireAuth, requireRole('sysadmin'), verifyCsrf, adminUsersRouter(db));
|
||
|
||
// Static frontend in production
|
||
const frontendDist = resolve(import.meta.dirname, '../../frontend/dist');
|
||
if (existsSync(frontendDist)) {
|
||
app.use(express.static(frontendDist));
|
||
app.get('*', (_req, res) => res.sendFile(resolve(frontendDist, 'index.html')));
|
||
}
|
||
|
||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||
if (err instanceof ZodError) {
|
||
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() } });
|
||
return;
|
||
}
|
||
if (err instanceof ApiError) {
|
||
res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } });
|
||
return;
|
||
}
|
||
console.error(err);
|
||
res.status(500).json({ error: { code: 'INTERNAL', message: 'Internal server error' } });
|
||
});
|
||
|
||
return app;
|
||
}
|
||
```
|
||
|
||
Note: `verifyCsrf` is not applied to the entire `/api/auth` router because login/register/forgot/reset don't have a session yet — they bootstrap the cookie set. Mutations that DO have a session (logout, profile, change-password) get CSRF protection added inline in Task 13.
|
||
|
||
- [ ] **Step 2: Add CSRF to authenticated auth mutations**
|
||
|
||
Modify `packages/backend/src/routes/auth.ts` — wrap the three authenticated mutation routes with `verifyCsrf`:
|
||
|
||
```ts
|
||
import { verifyCsrf } from '../middleware/csrf.js';
|
||
// ...
|
||
r.post('/logout', requireAuth, verifyCsrf, async (req, res, next) => { /* unchanged */ });
|
||
r.patch('/profile', requireAuth, verifyCsrf, async (req, res, next) => { /* unchanged */ });
|
||
r.post('/change-password', requireAuth, verifyCsrf, async (req, res, next) => { /* unchanged */ });
|
||
```
|
||
|
||
(The other auth endpoints remain unprotected — they're public mutations.)
|
||
|
||
- [ ] **Step 3: Run all backend tests**
|
||
|
||
```bash
|
||
npm -w @flashcard/backend test
|
||
```
|
||
|
||
Expected: all auth + admin-users integration tests pass, plus existing unit tests. Existing lesson/card/session/stats tests do NOT go through HTTP, so they're unaffected by the new requireAuth middleware.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add packages/backend/src/app.ts packages/backend/src/routes/auth.ts
|
||
git -c commit.gpgsign=false commit -m "feat(auth): wire auth middleware in app, protect all /api endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Dev tooling — docker-compose for Mailpit + .env.example + README
|
||
|
||
**Files:**
|
||
- Create: `docker-compose.yml`
|
||
- Create: `.env.example`
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Create `docker-compose.yml`**
|
||
|
||
```yaml
|
||
services:
|
||
mailpit:
|
||
image: axllent/mailpit:latest
|
||
container_name: flashcard-mailpit
|
||
ports:
|
||
- "1025:1025" # SMTP
|
||
- "8025:8025" # Web UI
|
||
environment:
|
||
MP_MAX_MESSAGES: 5000
|
||
restart: unless-stopped
|
||
```
|
||
|
||
- [ ] **Step 2: Create `.env.example`**
|
||
|
||
```
|
||
# Backend
|
||
PORT=3000
|
||
DB_PATH=./data/flashcard.db
|
||
APP_URL=http://localhost:5173
|
||
|
||
# Cookies
|
||
COOKIE_SECURE=false
|
||
|
||
# SMTP (Mailpit dev defaults; override in production with SES SMTP)
|
||
SMTP_HOST=localhost
|
||
SMTP_PORT=1025
|
||
SMTP_SECURE=false
|
||
SMTP_USER=
|
||
SMTP_PASS=
|
||
SMTP_FROM="Flashcard <noreply@flashcard.local>"
|
||
```
|
||
|
||
- [ ] **Step 3: Append README section**
|
||
|
||
Append to `README.md`:
|
||
|
||
```markdown
|
||
## Auth & e-mail
|
||
|
||
De applicatie zit achter een login. Eerste registratie (POST /api/auth/register via /register pagina) wordt automatisch sysadmin.
|
||
|
||
### Lokaal e-mail (Mailpit)
|
||
|
||
```bash
|
||
docker compose up -d mailpit
|
||
# Web UI: http://localhost:8025
|
||
# SMTP: localhost:1025
|
||
```
|
||
|
||
Kopieer `.env.example` → `.env` in repo-root, of zet de waarden inline.
|
||
|
||
### Productie (Amazon SES)
|
||
|
||
In productie:
|
||
|
||
```
|
||
SMTP_HOST=email-smtp.eu-west-1.amazonaws.com
|
||
SMTP_PORT=587
|
||
SMTP_USER=<SES SMTP username>
|
||
SMTP_PASS=<SES SMTP password>
|
||
SMTP_FROM="Flashcard <noreply@yourdomain.com>"
|
||
COOKIE_SECURE=true
|
||
APP_URL=https://yourdomain.com
|
||
```
|
||
|
||
### Fallback (geen SMTP)
|
||
|
||
Als `SMTP_HOST` ontbreekt, schrijft het systeem de e-mails (incl. links) naar de server-log.
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docker-compose.yml .env.example README.md
|
||
git -c commit.gpgsign=false commit -m "chore: docker-compose mailpit, env.example, README auth section"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Frontend API client — CSRF + auth modules
|
||
|
||
**Files:**
|
||
- Modify: `packages/frontend/src/api/client.ts`
|
||
- Create: `packages/frontend/src/api/auth.ts`
|
||
- Create: `packages/frontend/src/api/admin-users.ts`
|
||
|
||
- [ ] **Step 1: Rewrite `client.ts` with CSRF header + 401 handling**
|
||
|
||
```ts
|
||
const CSRF_COOKIE = 'flashcard_csrf';
|
||
|
||
function readCookie(name: string): string | null {
|
||
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]+)`));
|
||
return m ? decodeURIComponent(m[1]!) : null;
|
||
}
|
||
|
||
export class ApiClientError extends Error {
|
||
constructor(public status: number, public code: string, message: string, public details?: unknown) {
|
||
super(message);
|
||
}
|
||
}
|
||
|
||
const listeners = new Set<() => void>();
|
||
export function onUnauthorized(cb: () => void): () => void {
|
||
listeners.add(cb);
|
||
return () => listeners.delete(cb);
|
||
}
|
||
|
||
async function request<T>(
|
||
method: string,
|
||
path: string,
|
||
body?: unknown,
|
||
opts?: { isFormData?: boolean }
|
||
): Promise<T> {
|
||
const headers: Record<string, string> = {};
|
||
let payload: BodyInit | undefined;
|
||
if (opts?.isFormData) {
|
||
payload = body as FormData;
|
||
} else if (body !== undefined) {
|
||
headers['Content-Type'] = 'application/json';
|
||
payload = JSON.stringify(body);
|
||
}
|
||
if (method !== 'GET' && method !== 'HEAD') {
|
||
const csrf = readCookie(CSRF_COOKIE);
|
||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||
}
|
||
const res = await fetch(`/api${path}`, {
|
||
method,
|
||
headers,
|
||
body: payload,
|
||
credentials: 'same-origin',
|
||
});
|
||
if (res.status === 204) return undefined as T;
|
||
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||
const data = isJson ? await res.json() : await res.blob();
|
||
if (!res.ok) {
|
||
const e = (data as { error?: { code: string; message: string; details?: unknown } }).error;
|
||
if (res.status === 401) listeners.forEach((l) => l());
|
||
throw new ApiClientError(res.status, e?.code ?? 'UNKNOWN', e?.message ?? 'Request failed', e?.details);
|
||
}
|
||
return data as T;
|
||
}
|
||
|
||
export const api = {
|
||
get: <T>(path: string) => request<T>('GET', path),
|
||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||
postForm: <T>(path: string, form: FormData) => request<T>('POST', path, form, { isFormData: true }),
|
||
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
|
||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||
getBlob: async (path: string): Promise<Blob> => {
|
||
const res = await fetch(`/api${path}`, { credentials: 'same-origin' });
|
||
if (!res.ok) throw new ApiClientError(res.status, 'UNKNOWN', 'Request failed');
|
||
return res.blob();
|
||
},
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Create `api/auth.ts`**
|
||
|
||
```ts
|
||
import type {
|
||
PublicUser, User,
|
||
LoginInput, RegisterInput, VerifyEmailInput, ResendVerificationInput,
|
||
ForgotPasswordInput, ResetPasswordInput, AcceptInviteInput,
|
||
ProfileUpdateInput, ChangePasswordInput,
|
||
} from '@flashcard/shared';
|
||
import { api } from './client.js';
|
||
|
||
export const authApi = {
|
||
me: () => api.get<User>('/auth/me'),
|
||
register: (input: RegisterInput) => api.post<PublicUser>('/auth/register', input),
|
||
verifyEmail: (input: VerifyEmailInput) => api.post<{ ok: true }>('/auth/verify-email', input),
|
||
resendVerification: (input: ResendVerificationInput) => api.post<{ ok: true }>('/auth/resend-verification', input),
|
||
login: (input: LoginInput) => api.post<PublicUser>('/auth/login', input),
|
||
logout: () => api.post<void>('/auth/logout'),
|
||
forgotPassword: (input: ForgotPasswordInput) => api.post<{ ok: true }>('/auth/forgot-password', input),
|
||
resetPassword: (input: ResetPasswordInput) => api.post<{ ok: true }>('/auth/reset-password', input),
|
||
acceptInvite: (input: AcceptInviteInput) => api.post<PublicUser>('/auth/accept-invite', input),
|
||
updateProfile: (input: ProfileUpdateInput) => api.patch<PublicUser>('/auth/profile', input),
|
||
changePassword: (input: ChangePasswordInput) => api.post<void>('/auth/change-password', input),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: Create `api/admin-users.ts`**
|
||
|
||
```ts
|
||
import type { User, AdminUserUpdateInput, InviteUserInput, Role } from '@flashcard/shared';
|
||
import { api } from './client.js';
|
||
|
||
export interface ListUsersResponse { rows: User[]; total: number; }
|
||
|
||
export const adminUsersApi = {
|
||
list: (params: { q?: string; role?: Role; active?: boolean; limit?: number; offset?: number } = {}) => {
|
||
const qs = new URLSearchParams();
|
||
if (params.q) qs.set('q', params.q);
|
||
if (params.role) qs.set('role', params.role);
|
||
if (params.active !== undefined) qs.set('active', String(params.active));
|
||
if (params.limit !== undefined) qs.set('limit', String(params.limit));
|
||
if (params.offset !== undefined) qs.set('offset', String(params.offset));
|
||
const s = qs.toString();
|
||
return api.get<ListUsersResponse>(`/admin/users${s ? '?' + s : ''}`);
|
||
},
|
||
invite: (input: InviteUserInput) => api.post<{ id: number; email: string; role: Role }>('/admin/users/invite', input),
|
||
update: (id: number, input: AdminUserUpdateInput) => api.patch<User>(`/admin/users/${id}`, input),
|
||
sendReset: (id: number) => api.post<void>(`/admin/users/${id}/send-reset`),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Typecheck and commit**
|
||
|
||
```bash
|
||
npm -w @flashcard/frontend run typecheck
|
||
git add packages/frontend/src/api/
|
||
git -c commit.gpgsign=false commit -m "feat(frontend): API client CSRF support + auth and admin-users API modules"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Frontend authStore
|
||
|
||
**Files:**
|
||
- Create: `packages/frontend/src/stores/authStore.ts`
|
||
|
||
- [ ] **Step 1: Create `authStore.ts`**
|
||
|
||
```ts
|
||
import { create } from 'zustand';
|
||
import type { User } from '@flashcard/shared';
|
||
import { authApi } from '../api/auth.js';
|
||
import { ApiClientError } from '../api/client.js';
|
||
|
||
interface AuthState {
|
||
user: User | null;
|
||
loading: boolean;
|
||
ready: boolean;
|
||
hydrate: () => Promise<void>;
|
||
refreshMe: () => Promise<void>;
|
||
login: (email: string, password: string) => Promise<void>;
|
||
logout: () => Promise<void>;
|
||
setUserFromAuthResponse: (publicUser: { id: number; email: string; displayName: string; role: 'user' | 'sysadmin' }) => void;
|
||
}
|
||
|
||
export const useAuth = create<AuthState>((set, get) => ({
|
||
user: null,
|
||
loading: false,
|
||
ready: false,
|
||
|
||
hydrate: async () => {
|
||
set({ loading: true });
|
||
try {
|
||
const user = await authApi.me();
|
||
set({ user, ready: true });
|
||
} catch (e) {
|
||
if (e instanceof ApiClientError && e.status === 401) {
|
||
set({ user: null, ready: true });
|
||
return;
|
||
}
|
||
set({ user: null, ready: true });
|
||
} finally {
|
||
set({ loading: false });
|
||
}
|
||
},
|
||
|
||
refreshMe: async () => {
|
||
try { set({ user: await authApi.me() }); } catch { set({ user: null }); }
|
||
},
|
||
|
||
login: async (email, password) => {
|
||
await authApi.login({ email, password });
|
||
await get().refreshMe();
|
||
},
|
||
|
||
logout: async () => {
|
||
await authApi.logout();
|
||
set({ user: null });
|
||
},
|
||
|
||
setUserFromAuthResponse: (pu) => {
|
||
// Optimistic set after register/accept-invite where backend returns PublicUser
|
||
set({
|
||
user: {
|
||
id: pu.id, email: pu.email, displayName: pu.displayName, role: pu.role,
|
||
isActive: true, emailVerifiedAt: Math.floor(Date.now() / 1000),
|
||
pendingEmail: null, createdAt: 0, updatedAt: 0,
|
||
},
|
||
});
|
||
},
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add packages/frontend/src/stores/authStore.ts
|
||
git -c commit.gpgsign=false commit -m "feat(frontend): authStore (Zustand)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: AuthBoundary, RoleGuard, UserMenu + Layout integration
|
||
|
||
**Files:**
|
||
- Create: `packages/frontend/src/components/AuthBoundary.tsx`
|
||
- Create: `packages/frontend/src/components/RoleGuard.tsx`
|
||
- Create: `packages/frontend/src/components/UserMenu.tsx`
|
||
- Modify: `packages/frontend/src/components/Layout.tsx`
|
||
- Modify: `packages/frontend/src/main.tsx`
|
||
|
||
- [ ] **Step 1: Create `AuthBoundary.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect } from 'react';
|
||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||
import { useAuth } from '../stores/authStore.js';
|
||
|
||
export function AuthBoundary() {
|
||
const { user, ready, hydrate } = useAuth();
|
||
const location = useLocation();
|
||
|
||
useEffect(() => {
|
||
if (!ready) hydrate();
|
||
}, [ready, hydrate]);
|
||
|
||
if (!ready) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center">
|
||
<div className="h-2 w-32 animate-shimmer rounded-full bg-gradient-to-r from-brand-100 via-brand-200 to-brand-100 bg-[length:1000px_100%]" />
|
||
</div>
|
||
);
|
||
}
|
||
if (!user) {
|
||
const next = encodeURIComponent(location.pathname + location.search);
|
||
return <Navigate to={`/login?next=${next}`} replace />;
|
||
}
|
||
return <Outlet />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `RoleGuard.tsx`**
|
||
|
||
```tsx
|
||
import { Navigate, Outlet } from 'react-router-dom';
|
||
import type { Role } from '@flashcard/shared';
|
||
import { useAuth } from '../stores/authStore.js';
|
||
|
||
export function RoleGuard({ role }: { role: Role }) {
|
||
const user = useAuth((s) => s.user);
|
||
if (!user) return <Navigate to="/login" replace />;
|
||
if (user.role !== role) {
|
||
return (
|
||
<div className="mx-auto max-w-md p-12 text-center">
|
||
<h1 className="font-display text-2xl font-bold">Geen toegang</h1>
|
||
<p className="mt-2 text-sm text-slate-500">Deze pagina is alleen voor beheerders.</p>
|
||
</div>
|
||
);
|
||
}
|
||
return <Outlet />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `UserMenu.tsx`**
|
||
|
||
```tsx
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import { Link, useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../stores/authStore.js';
|
||
|
||
export function UserMenu() {
|
||
const { user, logout } = useAuth();
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const navigate = useNavigate();
|
||
|
||
useEffect(() => {
|
||
function onClick(e: MouseEvent) {
|
||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||
}
|
||
document.addEventListener('mousedown', onClick);
|
||
return () => document.removeEventListener('mousedown', onClick);
|
||
}, []);
|
||
|
||
if (!user) return null;
|
||
|
||
const initials = user.displayName.trim().split(/\s+/).map((p) => p[0]).slice(0, 2).join('').toUpperCase();
|
||
|
||
async function handleLogout() {
|
||
await logout();
|
||
navigate('/login');
|
||
}
|
||
|
||
return (
|
||
<div className="relative" ref={ref}>
|
||
<button
|
||
onClick={() => setOpen((o) => !o)}
|
||
className="grid h-9 w-9 place-items-center rounded-full bg-brand-gradient text-sm font-bold text-white shadow-glow hover:brightness-110"
|
||
aria-label="Account menu"
|
||
>
|
||
{initials || '?'}
|
||
</button>
|
||
{open && (
|
||
<div className="absolute right-0 top-11 z-30 w-56 rounded-2xl border border-white/60 bg-white/95 p-2 shadow-soft backdrop-blur dark:border-slate-800 dark:bg-slate-900/95">
|
||
<div className="px-3 py-2">
|
||
<div className="truncate text-sm font-semibold">{user.displayName}</div>
|
||
<div className="truncate text-xs text-slate-500">{user.email}</div>
|
||
</div>
|
||
<hr className="my-1 border-brand-100 dark:border-slate-800" />
|
||
<Link to="/profile" className="block rounded-xl px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-slate-800" onClick={() => setOpen(false)}>Profiel</Link>
|
||
{user.role === 'sysadmin' && (
|
||
<Link to="/admin/users" className="block rounded-xl px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-slate-800" onClick={() => setOpen(false)}>👑 Systeembeheer</Link>
|
||
)}
|
||
<button onClick={handleLogout} className="block w-full rounded-xl px-3 py-2 text-left text-sm text-danger-600 hover:bg-danger-50 dark:hover:bg-danger-400/10">
|
||
Uitloggen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Replace `Layout.tsx`**
|
||
|
||
```tsx
|
||
import { NavLink, Outlet } from 'react-router-dom';
|
||
import { useSettings } from '../stores/settingsStore.js';
|
||
import { useAuth } from '../stores/authStore.js';
|
||
import { UserMenu } from './UserMenu.js';
|
||
|
||
const navItems = [
|
||
{ to: '/', label: 'Dashboard', end: true },
|
||
{ to: '/admin', label: 'Lessen' },
|
||
{ to: '/stats', label: 'Stats' },
|
||
];
|
||
|
||
export function Layout() {
|
||
const { theme, toggleTheme } = useSettings();
|
||
const user = useAuth((s) => s.user);
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<header className="sticky top-0 z-20 border-b border-white/40 bg-white/70 backdrop-blur-xl dark:border-slate-800/60 dark:bg-slate-950/70">
|
||
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 sm:px-6">
|
||
<NavLink to="/" className="flex items-center gap-2 font-display text-lg font-bold">
|
||
<span className="grid h-8 w-8 place-items-center rounded-xl bg-brand-gradient text-white shadow-glow">⚡</span>
|
||
<span className="bg-brand-gradient bg-clip-text text-transparent">Flashcards</span>
|
||
</NavLink>
|
||
{user && (
|
||
<nav className="ml-4 hidden gap-1 sm:flex">
|
||
{navItems.map((item) => (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.end}
|
||
className={({ isActive }) =>
|
||
`rounded-xl px-3 py-1.5 text-sm font-medium transition ${
|
||
isActive
|
||
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
|
||
: 'text-slate-600 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-900/60'
|
||
}`
|
||
}
|
||
>
|
||
{item.label}
|
||
</NavLink>
|
||
))}
|
||
</nav>
|
||
)}
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<button
|
||
onClick={toggleTheme}
|
||
className="grid h-9 w-9 place-items-center rounded-xl border border-white/60 bg-white/70 text-base shadow-sm transition hover:scale-105 dark:border-slate-800 dark:bg-slate-900/70"
|
||
aria-label="Toggle dark mode"
|
||
>
|
||
{theme === 'dark' ? '☀️' : '🌙'}
|
||
</button>
|
||
<UserMenu />
|
||
</div>
|
||
</div>
|
||
{user && (
|
||
<nav className="flex gap-1 overflow-x-auto px-4 pb-2 sm:hidden">
|
||
{navItems.map((item) => (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.end}
|
||
className={({ isActive }) =>
|
||
`whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition ${
|
||
isActive ? 'bg-brand-600 text-white' : 'bg-white/60 text-slate-700 dark:bg-slate-900/60 dark:text-slate-300'
|
||
}`
|
||
}
|
||
>
|
||
{item.label}
|
||
</NavLink>
|
||
))}
|
||
</nav>
|
||
)}
|
||
</header>
|
||
<main className="flex-1 overflow-auto">
|
||
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
||
<Outlet />
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Hook the 401-handler in `main.tsx` to bounce to /login**
|
||
|
||
Replace `packages/frontend/src/main.tsx` with:
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import { createRoot } from 'react-dom/client';
|
||
import { RouterProvider } from 'react-router-dom';
|
||
import { router } from './router.js';
|
||
import { useSettings } from './stores/settingsStore.js';
|
||
import { useAuth } from './stores/authStore.js';
|
||
import { onUnauthorized } from './api/client.js';
|
||
import './styles.css';
|
||
|
||
useSettings.getState().hydrate();
|
||
|
||
onUnauthorized(() => {
|
||
useAuth.setState({ user: null, ready: true });
|
||
});
|
||
|
||
const root = createRoot(document.getElementById('root')!);
|
||
root.render(
|
||
<React.StrictMode>
|
||
<RouterProvider router={router} />
|
||
</React.StrictMode>
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck + commit**
|
||
|
||
```bash
|
||
npm -w @flashcard/frontend run typecheck
|
||
git add packages/frontend/src/components/ packages/frontend/src/main.tsx
|
||
git -c commit.gpgsign=false commit -m "feat(frontend): AuthBoundary, RoleGuard, UserMenu + Layout integration"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Auth pages — Login + Register
|
||
|
||
**Files:**
|
||
- Create: `packages/frontend/src/pages/auth/Login.tsx`
|
||
- Create: `packages/frontend/src/pages/auth/Register.tsx`
|
||
|
||
- [ ] **Step 1: Create `Login.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { authApi } from '../../api/auth.js';
|
||
import { ApiClientError } from '../../api/client.js';
|
||
import { useAuth } from '../../stores/authStore.js';
|
||
|
||
export function LoginPage() {
|
||
const [searchParams] = useSearchParams();
|
||
const next = searchParams.get('next') ?? '/';
|
||
const navigate = useNavigate();
|
||
const login = useAuth((s) => s.login);
|
||
const [email, setEmail] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [needsVerification, setNeedsVerification] = useState(false);
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true); setError(null); setNeedsVerification(false);
|
||
try {
|
||
await login(email, password);
|
||
navigate(next, { replace: true });
|
||
} catch (err) {
|
||
if (err instanceof ApiClientError) {
|
||
if (err.code === 'EMAIL_NOT_VERIFIED') setNeedsVerification(true);
|
||
else setError(err.message);
|
||
} else setError('Onbekende fout');
|
||
} finally { setBusy(false); }
|
||
}
|
||
|
||
async function resend() {
|
||
try { await authApi.resendVerification({ email }); setError('Bevestigingsmail opnieuw verstuurd.'); }
|
||
catch { setError('Kon mail niet versturen.'); }
|
||
}
|
||
|
||
return (
|
||
<AuthLayout title="Inloggen">
|
||
<form onSubmit={submit} className="space-y-4">
|
||
<Field label="E-mailadres">
|
||
<input type="email" className="input-field" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" />
|
||
</Field>
|
||
<Field label="Wachtwoord">
|
||
<input type="password" className="input-field" required value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
||
</Field>
|
||
{error && <p className="rounded-xl bg-danger-50 p-3 text-sm text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">{error}</p>}
|
||
{needsVerification && (
|
||
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
|
||
Je e-mailadres is nog niet bevestigd.
|
||
<button type="button" className="ml-2 font-semibold underline" onClick={resend}>Stuur opnieuw</button>
|
||
</div>
|
||
)}
|
||
<button type="submit" className="btn-primary w-full py-3" disabled={busy}>{busy ? 'Bezig…' : 'Inloggen'}</button>
|
||
</form>
|
||
<div className="mt-6 flex justify-between text-sm">
|
||
<Link to="/forgot-password" className="text-brand-600 hover:underline">Wachtwoord vergeten?</Link>
|
||
<Link to="/register" className="text-brand-600 hover:underline">Account aanmaken</Link>
|
||
</div>
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
|
||
export function AuthLayout({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="mx-auto max-w-md p-6">
|
||
<div className="surface p-8">
|
||
<h1 className="mb-6 font-display text-2xl font-bold">{title}</h1>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block font-medium text-slate-700 dark:text-slate-200">{label}</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `Register.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { Link, useNavigate } from 'react-router-dom';
|
||
import { authApi } from '../../api/auth.js';
|
||
import { ApiClientError } from '../../api/client.js';
|
||
import { AuthLayout, Field } from './Login.js';
|
||
|
||
export function RegisterPage() {
|
||
const navigate = useNavigate();
|
||
const [email, setEmail] = useState('');
|
||
const [displayName, setDisplayName] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [done, setDone] = useState(false);
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true); setError(null);
|
||
try {
|
||
await authApi.register({ email, displayName, password });
|
||
setDone(true);
|
||
} catch (err) {
|
||
if (err instanceof ApiClientError) setError(err.message);
|
||
else setError('Onbekende fout');
|
||
} finally { setBusy(false); }
|
||
}
|
||
|
||
if (done) {
|
||
return (
|
||
<AuthLayout title="Bijna klaar 📬">
|
||
<p className="text-sm">
|
||
We hebben een bevestigingsmail gestuurd naar <strong>{email}</strong>.
|
||
Klik op de link om je account te activeren.
|
||
</p>
|
||
<Link to="/login" className="mt-6 block text-sm text-brand-600 hover:underline">Terug naar inloggen</Link>
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<AuthLayout title="Registreren">
|
||
<form onSubmit={submit} className="space-y-4">
|
||
<Field label="Naam"><input className="input-field" required minLength={1} value={displayName} onChange={(e) => setDisplayName(e.target.value)} autoComplete="name" /></Field>
|
||
<Field label="E-mailadres"><input type="email" className="input-field" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" /></Field>
|
||
<Field label="Wachtwoord (min. 8 tekens)"><input type="password" className="input-field" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" /></Field>
|
||
{error && <p className="rounded-xl bg-danger-50 p-3 text-sm text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">{error}</p>}
|
||
<button type="submit" className="btn-primary w-full py-3" disabled={busy}>{busy ? 'Bezig…' : 'Account aanmaken'}</button>
|
||
<p className="text-xs text-slate-500">
|
||
De eerste registratie wordt automatisch beheerder.
|
||
</p>
|
||
</form>
|
||
<div className="mt-6 text-sm">
|
||
<Link to="/login" className="text-brand-600 hover:underline">Heb je al een account? Inloggen</Link>
|
||
</div>
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck and commit**
|
||
|
||
```bash
|
||
npm -w @flashcard/frontend run typecheck
|
||
git add packages/frontend/src/pages/auth/
|
||
git -c commit.gpgsign=false commit -m "feat(frontend): Login + Register pages"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Auth pages — VerifyEmail, ForgotPassword, ResetPassword, AcceptInvite
|
||
|
||
**Files:**
|
||
- Create: `packages/frontend/src/pages/auth/VerifyEmail.tsx`
|
||
- Create: `packages/frontend/src/pages/auth/ForgotPassword.tsx`
|
||
- Create: `packages/frontend/src/pages/auth/ResetPassword.tsx`
|
||
- Create: `packages/frontend/src/pages/auth/AcceptInvite.tsx`
|
||
|
||
- [ ] **Step 1: Create `VerifyEmail.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import { Link, useSearchParams } from 'react-router-dom';
|
||
import { authApi } from '../../api/auth.js';
|
||
import { ApiClientError } from '../../api/client.js';
|
||
import { AuthLayout } from './Login.js';
|
||
|
||
export function VerifyEmailPage() {
|
||
const [params] = useSearchParams();
|
||
const token = params.get('token');
|
||
const [state, setState] = useState<'pending' | 'ok' | 'err'>('pending');
|
||
const [message, setMessage] = useState('');
|
||
|
||
useEffect(() => {
|
||
if (!token) { setState('err'); setMessage('Token ontbreekt.'); return; }
|
||
(async () => {
|
||
try {
|
||
await authApi.verifyEmail({ token });
|
||
setState('ok');
|
||
} catch (e) {
|
||
setState('err');
|
||
setMessage(e instanceof ApiClientError ? e.message : 'Verificatie mislukt.');
|
||
}
|
||
})();
|
||
}, [token]);
|
||
|
||
return (
|
||
<AuthLayout title="E-mailverificatie">
|
||
{state === 'pending' && <p>Bezig met verifiëren…</p>}
|
||
{state === 'ok' && (
|
||
<>
|
||
<p className="text-sm">Je e-mailadres is bevestigd ✅</p>
|
||
<Link to="/login" className="btn-primary mt-6 inline-flex">Naar inloggen</Link>
|
||
</>
|
||
)}
|
||
{state === 'err' && (
|
||
<>
|
||
<p className="text-sm text-danger-700 dark:text-danger-400">{message}</p>
|
||
<Link to="/login" className="mt-6 inline-block text-sm text-brand-600 hover:underline">Terug naar inloggen</Link>
|
||
</>
|
||
)}
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `ForgotPassword.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { authApi } from '../../api/auth.js';
|
||
import { AuthLayout, Field } from './Login.js';
|
||
|
||
export function ForgotPasswordPage() {
|
||
const [email, setEmail] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [sent, setSent] = useState(false);
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
try { await authApi.forgotPassword({ email }); }
|
||
finally { setBusy(false); setSent(true); }
|
||
}
|
||
|
||
if (sent) {
|
||
return (
|
||
<AuthLayout title="Check je mail 📬">
|
||
<p className="text-sm">Als <strong>{email}</strong> bekend is, hebben we een reset-link gestuurd. De link is 1 uur geldig.</p>
|
||
<Link to="/login" className="mt-6 inline-block text-sm text-brand-600 hover:underline">Terug naar inloggen</Link>
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<AuthLayout title="Wachtwoord vergeten">
|
||
<form onSubmit={submit} className="space-y-4">
|
||
<Field label="E-mailadres">
|
||
<input type="email" required className="input-field" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" />
|
||
</Field>
|
||
<button type="submit" className="btn-primary w-full py-3" disabled={busy}>{busy ? 'Bezig…' : 'Stuur reset-link'}</button>
|
||
</form>
|
||
<div className="mt-6 text-sm">
|
||
<Link to="/login" className="text-brand-600 hover:underline">Terug naar inloggen</Link>
|
||
</div>
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `ResetPassword.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { authApi } from '../../api/auth.js';
|
||
import { ApiClientError } from '../../api/client.js';
|
||
import { AuthLayout, Field } from './Login.js';
|
||
|
||
export function ResetPasswordPage() {
|
||
const [params] = useSearchParams();
|
||
const token = params.get('token') ?? '';
|
||
const navigate = useNavigate();
|
||
const [password, setPassword] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [done, setDone] = useState(false);
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true); setError(null);
|
||
try { await authApi.resetPassword({ token, password }); setDone(true); }
|
||
catch (err) { setError(err instanceof ApiClientError ? err.message : 'Reset mislukt.'); }
|
||
finally { setBusy(false); }
|
||
}
|
||
|
||
if (done) {
|
||
return (
|
||
<AuthLayout title="Wachtwoord ingesteld ✅">
|
||
<p className="text-sm">Je kunt nu inloggen met je nieuwe wachtwoord.</p>
|
||
<button className="btn-primary mt-6 w-full" onClick={() => navigate('/login')}>Naar inloggen</button>
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<AuthLayout title="Nieuw wachtwoord">
|
||
{!token && <p className="text-sm text-danger-700">Geen token in URL.</p>}
|
||
<form onSubmit={submit} className="space-y-4">
|
||
<Field label="Nieuw wachtwoord (min. 8 tekens)">
|
||
<input type="password" required minLength={8} className="input-field" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||
</Field>
|
||
{error && <p className="rounded-xl bg-danger-50 p-3 text-sm text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">{error}</p>}
|
||
<button type="submit" className="btn-primary w-full py-3" disabled={busy || !token}>{busy ? 'Bezig…' : 'Opslaan'}</button>
|
||
</form>
|
||
<Link to="/login" className="mt-6 block text-sm text-brand-600 hover:underline">Terug naar inloggen</Link>
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `AcceptInvite.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { authApi } from '../../api/auth.js';
|
||
import { ApiClientError } from '../../api/client.js';
|
||
import { useAuth } from '../../stores/authStore.js';
|
||
import { AuthLayout, Field } from './Login.js';
|
||
|
||
export function AcceptInvitePage() {
|
||
const [params] = useSearchParams();
|
||
const token = params.get('token') ?? '';
|
||
const navigate = useNavigate();
|
||
const refreshMe = useAuth((s) => s.refreshMe);
|
||
const [displayName, setDisplayName] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true); setError(null);
|
||
try {
|
||
await authApi.acceptInvite({ token, displayName, password });
|
||
await refreshMe();
|
||
navigate('/', { replace: true });
|
||
} catch (err) {
|
||
setError(err instanceof ApiClientError ? err.message : 'Accepteren mislukt.');
|
||
} finally { setBusy(false); }
|
||
}
|
||
|
||
return (
|
||
<AuthLayout title="Account aanmaken">
|
||
{!token && <p className="text-sm text-danger-700">Geen token in URL.</p>}
|
||
<form onSubmit={submit} className="space-y-4">
|
||
<Field label="Naam">
|
||
<input required className="input-field" value={displayName} onChange={(e) => setDisplayName(e.target.value)} autoComplete="name" />
|
||
</Field>
|
||
<Field label="Wachtwoord (min. 8 tekens)">
|
||
<input type="password" required minLength={8} className="input-field" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||
</Field>
|
||
{error && <p className="rounded-xl bg-danger-50 p-3 text-sm text-danger-700 dark:bg-danger-400/10 dark:text-danger-400">{error}</p>}
|
||
<button type="submit" className="btn-primary w-full py-3" disabled={busy || !token}>{busy ? 'Bezig…' : 'Account aanmaken'}</button>
|
||
</form>
|
||
</AuthLayout>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Typecheck + commit**
|
||
|
||
```bash
|
||
npm -w @flashcard/frontend run typecheck
|
||
git add packages/frontend/src/pages/auth/
|
||
git -c commit.gpgsign=false commit -m "feat(frontend): VerifyEmail + ForgotPassword + ResetPassword + AcceptInvite pages"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Profile page
|
||
|
||
**Files:**
|
||
- Create: `packages/frontend/src/pages/Profile.tsx`
|
||
|
||
- [ ] **Step 1: Create `Profile.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { authApi } from '../api/auth.js';
|
||
import { ApiClientError } from '../api/client.js';
|
||
import { useAuth } from '../stores/authStore.js';
|
||
|
||
export function ProfilePage() {
|
||
const { user, refreshMe } = useAuth();
|
||
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||
const [email, setEmail] = useState(user?.email ?? '');
|
||
const [profileBusy, setProfileBusy] = useState(false);
|
||
const [profileMsg, setProfileMsg] = useState<string | null>(null);
|
||
|
||
const [currentPassword, setCurrentPassword] = useState('');
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [pwBusy, setPwBusy] = useState(false);
|
||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||
const [pwErr, setPwErr] = useState<string | null>(null);
|
||
|
||
async function saveProfile(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setProfileBusy(true); setProfileMsg(null);
|
||
try {
|
||
const updates: { displayName?: string; email?: string } = {};
|
||
if (displayName !== user?.displayName) updates.displayName = displayName;
|
||
if (email !== user?.email) updates.email = email;
|
||
if (Object.keys(updates).length === 0) { setProfileMsg('Geen wijzigingen.'); return; }
|
||
await authApi.updateProfile(updates);
|
||
await refreshMe();
|
||
setProfileMsg(updates.email ? 'Profiel opgeslagen. Bevestig je nieuwe e-mailadres via de link in de mail.' : 'Profiel opgeslagen.');
|
||
} catch (err) {
|
||
setProfileMsg(err instanceof ApiClientError ? err.message : 'Opslaan mislukt.');
|
||
} finally { setProfileBusy(false); }
|
||
}
|
||
|
||
async function changePassword(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setPwBusy(true); setPwMsg(null); setPwErr(null);
|
||
try {
|
||
await authApi.changePassword({ currentPassword, newPassword });
|
||
setPwMsg('Wachtwoord gewijzigd.');
|
||
setCurrentPassword(''); setNewPassword('');
|
||
} catch (err) {
|
||
setPwErr(err instanceof ApiClientError ? err.message : 'Wijzigen mislukt.');
|
||
} finally { setPwBusy(false); }
|
||
}
|
||
|
||
if (!user) return null;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-2xl space-y-6">
|
||
<header>
|
||
<h1 className="font-display text-3xl font-bold">Profiel</h1>
|
||
<p className="text-sm text-slate-500">Beheer je naam, e-mailadres en wachtwoord.</p>
|
||
</header>
|
||
|
||
<form onSubmit={saveProfile} className="surface space-y-4 p-6">
|
||
<h2 className="font-display text-lg font-bold">Gegevens</h2>
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block font-medium">Naam</span>
|
||
<input className="input-field" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||
</label>
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block font-medium">E-mailadres</span>
|
||
<input type="email" className="input-field" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||
{user.pendingEmail && <span className="mt-1 block text-xs text-amber-700">In afwachting: {user.pendingEmail}</span>}
|
||
</label>
|
||
{profileMsg && <p className="text-sm">{profileMsg}</p>}
|
||
<button className="btn-primary" disabled={profileBusy}>{profileBusy ? 'Bezig…' : 'Opslaan'}</button>
|
||
</form>
|
||
|
||
<form onSubmit={changePassword} className="surface space-y-4 p-6">
|
||
<h2 className="font-display text-lg font-bold">Wachtwoord</h2>
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block font-medium">Huidig wachtwoord</span>
|
||
<input type="password" className="input-field" required value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} autoComplete="current-password" />
|
||
</label>
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block font-medium">Nieuw wachtwoord (min. 8 tekens)</span>
|
||
<input type="password" minLength={8} className="input-field" required value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password" />
|
||
</label>
|
||
{pwMsg && <p className="text-sm text-success-700">{pwMsg}</p>}
|
||
{pwErr && <p className="text-sm text-danger-700">{pwErr}</p>}
|
||
<button className="btn-primary" disabled={pwBusy}>{pwBusy ? 'Bezig…' : 'Wachtwoord wijzigen'}</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add packages/frontend/src/pages/Profile.tsx
|
||
git -c commit.gpgsign=false commit -m "feat(frontend): profile page (display name, email, change password)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: AdminUsers page
|
||
|
||
**Files:**
|
||
- Create: `packages/frontend/src/pages/AdminUsers.tsx`
|
||
|
||
- [ ] **Step 1: Create `AdminUsers.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import { adminUsersApi, type ListUsersResponse } from '../api/admin-users.js';
|
||
import type { User } from '@flashcard/shared';
|
||
import { ApiClientError } from '../api/client.js';
|
||
|
||
export function AdminUsersPage() {
|
||
const [data, setData] = useState<ListUsersResponse>({ rows: [], total: 0 });
|
||
const [q, setQ] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const [inviteEmail, setInviteEmail] = useState('');
|
||
const [inviteRole, setInviteRole] = useState<'user' | 'sysadmin'>('user');
|
||
const [inviteMsg, setInviteMsg] = useState<string | null>(null);
|
||
|
||
async function refresh() {
|
||
setBusy(true);
|
||
try { setData(await adminUsersApi.list({ q: q.trim() || undefined })); }
|
||
finally { setBusy(false); }
|
||
}
|
||
useEffect(() => { refresh(); }, []);
|
||
|
||
async function invite(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setInviteMsg(null);
|
||
try {
|
||
await adminUsersApi.invite({ email: inviteEmail, role: inviteRole });
|
||
setInviteMsg('Uitnodiging verstuurd.');
|
||
setInviteEmail('');
|
||
await refresh();
|
||
} catch (err) {
|
||
setInviteMsg(err instanceof ApiClientError ? err.message : 'Uitnodigen mislukt.');
|
||
}
|
||
}
|
||
|
||
async function setActive(u: User, isActive: boolean) {
|
||
try { await adminUsersApi.update(u.id, { isActive }); await refresh(); }
|
||
catch (err) { alert(err instanceof ApiClientError ? err.message : 'Bijwerken mislukt.'); }
|
||
}
|
||
|
||
async function setRole(u: User, role: 'user' | 'sysadmin') {
|
||
try { await adminUsersApi.update(u.id, { role }); await refresh(); }
|
||
catch (err) { alert(err instanceof ApiClientError ? err.message : 'Bijwerken mislukt.'); }
|
||
}
|
||
|
||
async function sendReset(u: User) {
|
||
if (!confirm(`Reset/uitnodigingsmail sturen naar ${u.email}?`)) return;
|
||
try { await adminUsersApi.sendReset(u.id); alert('Mail verstuurd.'); }
|
||
catch (err) { alert(err instanceof ApiClientError ? err.message : 'Versturen mislukt.'); }
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<header>
|
||
<h1 className="font-display text-3xl font-bold">👑 Gebruikersbeheer</h1>
|
||
<p className="text-sm text-slate-500">{data.total} gebruiker(s) totaal</p>
|
||
</header>
|
||
|
||
<form onSubmit={invite} className="surface flex flex-col gap-2 p-4 sm:flex-row sm:items-end">
|
||
<label className="flex-1 text-sm">
|
||
<span className="mb-1 block font-medium">Uitnodigen via e-mail</span>
|
||
<input type="email" required className="input-field" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} placeholder="naam@voorbeeld.com" />
|
||
</label>
|
||
<label className="text-sm">
|
||
<span className="mb-1 block font-medium">Rol</span>
|
||
<select className="input-field" value={inviteRole} onChange={(e) => setInviteRole(e.target.value as 'user' | 'sysadmin')}>
|
||
<option value="user">Gebruiker</option>
|
||
<option value="sysadmin">Beheerder</option>
|
||
</select>
|
||
</label>
|
||
<button className="btn-primary shrink-0">Uitnodiging sturen</button>
|
||
</form>
|
||
{inviteMsg && <p className="text-sm">{inviteMsg}</p>}
|
||
|
||
<div className="surface p-4">
|
||
<div className="mb-3 flex gap-2">
|
||
<input className="input-field flex-1" placeholder="Zoek op e-mail of naam…" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && refresh()} />
|
||
<button className="btn-ghost" onClick={refresh} disabled={busy}>Zoek</button>
|
||
</div>
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-xs uppercase tracking-wider text-slate-500">
|
||
<th className="px-2 py-2">Email</th>
|
||
<th className="px-2 py-2">Naam</th>
|
||
<th className="px-2 py-2">Rol</th>
|
||
<th className="px-2 py-2">Status</th>
|
||
<th className="px-2 py-2"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-brand-100/60 dark:divide-slate-800">
|
||
{data.rows.map((u) => (
|
||
<tr key={u.id} className="hover:bg-brand-50/40 dark:hover:bg-slate-800/40">
|
||
<td className="px-2 py-2">{u.email}</td>
|
||
<td className="px-2 py-2">{u.displayName}</td>
|
||
<td className="px-2 py-2">
|
||
<select className="rounded-lg border border-brand-100 bg-white px-2 py-1 text-xs dark:border-slate-800 dark:bg-slate-900" value={u.role} onChange={(e) => setRole(u, e.target.value as 'user' | 'sysadmin')}>
|
||
<option value="user">user</option>
|
||
<option value="sysadmin">sysadmin</option>
|
||
</select>
|
||
</td>
|
||
<td className="px-2 py-2">
|
||
{u.isActive ? (
|
||
<span className="rounded-full bg-success-50 px-2 py-0.5 text-xs font-semibold text-success-700 dark:bg-success-700/20 dark:text-success-400">actief</span>
|
||
) : (
|
||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">uit</span>
|
||
)}
|
||
{!u.emailVerifiedAt && (
|
||
<span className="ml-1 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">onbevestigd</span>
|
||
)}
|
||
</td>
|
||
<td className="px-2 py-2 text-right">
|
||
<button className="rounded-lg px-2 py-1 text-xs text-brand-700 hover:bg-brand-50 dark:text-brand-200 dark:hover:bg-brand-900/40" onClick={() => sendReset(u)}>reset-mail</button>
|
||
<button className="rounded-lg px-2 py-1 text-xs text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" onClick={() => setActive(u, !u.isActive)}>{u.isActive ? 'deactiveer' : 'activeer'}</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add packages/frontend/src/pages/AdminUsers.tsx
|
||
git -c commit.gpgsign=false commit -m "feat(frontend): admin users page (invite, role, activate, send-reset)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: Router — wire all auth routes + protect existing routes
|
||
|
||
**Files:**
|
||
- Modify: `packages/frontend/src/router.tsx`
|
||
|
||
- [ ] **Step 1: Replace `router.tsx`**
|
||
|
||
```tsx
|
||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||
import { Layout } from './components/Layout.js';
|
||
import { AuthBoundary } from './components/AuthBoundary.js';
|
||
import { RoleGuard } from './components/RoleGuard.js';
|
||
import { DashboardPage } from './pages/Dashboard.js';
|
||
import { AdminPage } from './pages/Admin.js';
|
||
import { AdminLessonPage } from './pages/AdminLesson.js';
|
||
import { PracticeSetupPage } from './pages/PracticeSetup.js';
|
||
import { PracticePage } from './pages/Practice.js';
|
||
import { PracticeDonePage } from './pages/PracticeDone.js';
|
||
import { StatsPage } from './pages/Stats.js';
|
||
import { StatsLessonPage } from './pages/StatsLesson.js';
|
||
import { StatsCardPage } from './pages/StatsCard.js';
|
||
import { SettingsPage } from './pages/Settings.js';
|
||
import { LoginPage } from './pages/auth/Login.js';
|
||
import { RegisterPage } from './pages/auth/Register.js';
|
||
import { VerifyEmailPage } from './pages/auth/VerifyEmail.js';
|
||
import { ForgotPasswordPage } from './pages/auth/ForgotPassword.js';
|
||
import { ResetPasswordPage } from './pages/auth/ResetPassword.js';
|
||
import { AcceptInvitePage } from './pages/auth/AcceptInvite.js';
|
||
import { ProfilePage } from './pages/Profile.js';
|
||
import { AdminUsersPage } from './pages/AdminUsers.js';
|
||
|
||
export const router = createBrowserRouter([
|
||
{
|
||
path: '/',
|
||
element: <Layout />,
|
||
children: [
|
||
// Public auth routes
|
||
{ path: 'login', element: <LoginPage /> },
|
||
{ path: 'register', element: <RegisterPage /> },
|
||
{ path: 'verify-email', element: <VerifyEmailPage /> },
|
||
{ path: 'forgot-password', element: <ForgotPasswordPage /> },
|
||
{ path: 'reset-password', element: <ResetPasswordPage /> },
|
||
{ path: 'accept-invite', element: <AcceptInvitePage /> },
|
||
|
||
// Authenticated routes
|
||
{
|
||
element: <AuthBoundary />,
|
||
children: [
|
||
{ index: true, element: <DashboardPage /> },
|
||
{ path: 'admin', element: <AdminPage /> },
|
||
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },
|
||
{ path: 'practice/:lessonId/setup', element: <PracticeSetupPage /> },
|
||
{ path: 'practice/:lessonId', element: <PracticePage /> },
|
||
{ path: 'practice/:lessonId/done', element: <PracticeDonePage /> },
|
||
{ path: 'stats', element: <StatsPage /> },
|
||
{ path: 'stats/lessons/:id', element: <StatsLessonPage /> },
|
||
{ path: 'stats/cards/:id', element: <StatsCardPage /> },
|
||
{ path: 'settings', element: <SettingsPage /> },
|
||
{ path: 'profile', element: <ProfilePage /> },
|
||
{
|
||
element: <RoleGuard role="sysadmin" />,
|
||
children: [
|
||
{ path: 'admin/users', element: <AdminUsersPage /> },
|
||
],
|
||
},
|
||
{ path: '*', element: <Navigate to="/" replace /> },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
]);
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck + frontend build**
|
||
|
||
```bash
|
||
npm -w @flashcard/frontend run typecheck
|
||
npm -w @flashcard/frontend run build
|
||
```
|
||
|
||
Expected: both succeed.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add packages/frontend/src/router.tsx
|
||
git -c commit.gpgsign=false commit -m "feat(frontend): router with auth boundary, role guard, and all auth pages"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22: Update existing E2E smoke + add new auth E2E (Mailpit-based)
|
||
|
||
**Files:**
|
||
- Modify: `e2e/smoke.spec.ts`
|
||
- Create: `e2e/auth.spec.ts`
|
||
- Modify: `packages/frontend/playwright.config.ts`
|
||
|
||
- [ ] **Step 1: Update playwright config to use mailpit and a unique DB per run**
|
||
|
||
```ts
|
||
import { defineConfig } from '@playwright/test';
|
||
|
||
export default defineConfig({
|
||
testDir: '../../e2e',
|
||
webServer: [
|
||
{
|
||
command:
|
||
'rm -f packages/backend/data/e2e.db data/e2e.db && DB_PATH=./data/e2e.db SMTP_HOST=localhost SMTP_PORT=1025 APP_URL=http://localhost:5173 npm -w @flashcard/backend run db:migrate && DB_PATH=./data/e2e.db SMTP_HOST=localhost SMTP_PORT=1025 APP_URL=http://localhost:5173 npm -w @flashcard/backend run dev',
|
||
cwd: '../..',
|
||
port: 3000,
|
||
reuseExistingServer: false,
|
||
timeout: 60_000,
|
||
},
|
||
{
|
||
command: 'npm -w @flashcard/frontend run dev',
|
||
cwd: '../..',
|
||
port: 5173,
|
||
reuseExistingServer: false,
|
||
timeout: 60_000,
|
||
},
|
||
],
|
||
use: { baseURL: 'http://localhost:5173' },
|
||
timeout: 30_000,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Replace existing `e2e/smoke.spec.ts` with a flow that first registers/logs in**
|
||
|
||
```ts
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
async function fetchVerifyLink(email: string): Promise<string> {
|
||
// Mailpit HTTP API: list latest message and pull link from text body
|
||
const res = await fetch('http://localhost:8025/api/v1/messages?limit=10');
|
||
const data = await res.json() as { messages: { ID: string; To: { Address: string }[] }[] };
|
||
const msg = data.messages.find((m) => m.To.some((t) => t.Address === email));
|
||
if (!msg) throw new Error('no message for ' + email);
|
||
const body = await fetch(`http://localhost:8025/api/v1/message/${msg.ID}`);
|
||
const full = await body.json() as { Text: string };
|
||
const match = full.Text.match(/https?:\/\/[^\s]+verify-email\?token=[^\s]+/);
|
||
if (!match) throw new Error('no verify link');
|
||
return match[0];
|
||
}
|
||
|
||
test('register → verify → login → create lesson → add card → practice once', async ({ page, request }) => {
|
||
const email = `user+${Date.now()}@example.com`;
|
||
const password = 'secretpass';
|
||
await page.goto('/register');
|
||
await page.getByLabel(/Naam/).fill('E2E User');
|
||
await page.getByLabel(/E-mailadres/).fill(email);
|
||
await page.getByLabel(/Wachtwoord/).fill(password);
|
||
await page.getByRole('button', { name: /Account aanmaken/ }).click();
|
||
await expect(page.getByText(/bevestigingsmail/i)).toBeVisible({ timeout: 10_000 });
|
||
|
||
const link = await fetchVerifyLink(email);
|
||
await page.goto(link);
|
||
await expect(page.getByText(/bevestigd/i)).toBeVisible();
|
||
|
||
await page.goto('/login');
|
||
await page.getByLabel(/E-mailadres/).fill(email);
|
||
await page.getByLabel(/Wachtwoord/).fill(password);
|
||
await page.getByRole('button', { name: 'Inloggen' }).click();
|
||
|
||
await page.goto('/admin');
|
||
await page.getByPlaceholder(/Nieuwe wortel-les/).fill('E2E les');
|
||
await page.getByRole('button', { name: /Toevoegen/ }).first().click();
|
||
await page.getByRole('link', { name: /E2E les/ }).first().click();
|
||
await page.getByPlaceholder('Nieuwe vraag').fill('q1');
|
||
await page.getByPlaceholder('Antwoord').fill('a1');
|
||
await page.getByRole('button', { name: 'Kaart toevoegen' }).click();
|
||
await page.getByRole('link', { name: /Start oefenen/ }).click();
|
||
await page.getByRole('button', { name: /Start sessie/ }).click();
|
||
await page.getByRole('button', { name: 'Toon antwoord' }).click();
|
||
await page.getByRole('button', { name: /Goed/ }).click();
|
||
await expect(page.getByText(/Sessie klaar/)).toBeVisible({ timeout: 8_000 });
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Create `e2e/auth.spec.ts` — admin invites a user**
|
||
|
||
```ts
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
async function fetchLink(email: string, kind: 'invite' | 'verify-email' | 'reset-password'): Promise<string> {
|
||
for (let i = 0; i < 20; i++) {
|
||
const res = await fetch('http://localhost:8025/api/v1/messages?limit=20');
|
||
const data = await res.json() as { messages: { ID: string; To: { Address: string }[] }[] };
|
||
const msg = data.messages.find((m) => m.To.some((t) => t.Address === email));
|
||
if (msg) {
|
||
const full = await (await fetch(`http://localhost:8025/api/v1/message/${msg.ID}`)).json() as { Text: string };
|
||
const pattern = new RegExp(`https?:\\/\\/[^\\s]+${kind}\\?token=[^\\s]+`);
|
||
const m = full.Text.match(pattern);
|
||
if (m) return m[0];
|
||
}
|
||
await new Promise((r) => setTimeout(r, 250));
|
||
}
|
||
throw new Error(`no ${kind} link for ${email}`);
|
||
}
|
||
|
||
test('admin invites user; user accepts and logs in', async ({ page }) => {
|
||
// Make admin (first registration)
|
||
const adminEmail = `admin+${Date.now()}@example.com`;
|
||
const adminPw = 'secretpass';
|
||
await page.goto('/register');
|
||
await page.getByLabel(/Naam/).fill('Admin');
|
||
await page.getByLabel(/E-mailadres/).fill(adminEmail);
|
||
await page.getByLabel(/Wachtwoord/).fill(adminPw);
|
||
await page.getByRole('button', { name: /Account aanmaken/ }).click();
|
||
await expect(page.getByText(/bevestigingsmail/i)).toBeVisible();
|
||
await page.goto(await fetchLink(adminEmail, 'verify-email'));
|
||
|
||
await page.goto('/login');
|
||
await page.getByLabel(/E-mailadres/).fill(adminEmail);
|
||
await page.getByLabel(/Wachtwoord/).fill(adminPw);
|
||
await page.getByRole('button', { name: 'Inloggen' }).click();
|
||
|
||
// Invite
|
||
await page.goto('/admin/users');
|
||
const inviteeEmail = `invitee+${Date.now()}@example.com`;
|
||
await page.getByPlaceholder(/naam@voorbeeld/).fill(inviteeEmail);
|
||
await page.getByRole('button', { name: /Uitnodiging sturen/ }).click();
|
||
await expect(page.getByText(/Uitnodiging verstuurd/)).toBeVisible();
|
||
|
||
// Accept
|
||
const inviteLink = await fetchLink(inviteeEmail, 'accept-invite');
|
||
// logout admin first via UI
|
||
await page.getByRole('button', { name: 'Account menu' }).click();
|
||
await page.getByRole('button', { name: 'Uitloggen' }).click();
|
||
|
||
await page.goto(inviteLink);
|
||
await page.getByLabel(/Naam/).fill('Newbie');
|
||
await page.getByLabel(/Wachtwoord/).fill('newuserpass');
|
||
await page.getByRole('button', { name: /Account aanmaken/ }).click();
|
||
await expect(page).toHaveURL(/\/$/);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run mailpit before running E2E**
|
||
|
||
```bash
|
||
docker compose up -d mailpit
|
||
```
|
||
|
||
- [ ] **Step 5: Run E2E**
|
||
|
||
```bash
|
||
lsof -ti tcp:3000 2>/dev/null | xargs kill -9 2>/dev/null
|
||
lsof -ti tcp:5173 2>/dev/null | xargs kill -9 2>/dev/null
|
||
rm -f packages/backend/data/e2e.db data/e2e.db
|
||
sleep 2
|
||
npm run e2e
|
||
```
|
||
|
||
Expected: both tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add e2e/ packages/frontend/playwright.config.ts
|
||
git -c commit.gpgsign=false commit -m "test(e2e): register+verify smoke and admin invite flow via Mailpit"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
**Spec coverage:**
|
||
|
||
| Spec section | Implemented in task |
|
||
|---|---|
|
||
| 3.1 Self-service register + first-user becomes sysadmin | 10 |
|
||
| 3.2 Email verification (24h, single-use) | 4, 10 |
|
||
| 3.3 Login / logout with cookie session | 5, 7, 10, 12 |
|
||
| 3.4 Forgot / reset (1h, all sessions invalidated) | 10 |
|
||
| 3.5 Profile + email-change via pending_email + change-password keep-current | 10 |
|
||
| 3.6 Invite + accept-invite | 11 (invite) + 10 (accept) |
|
||
| 3.7 Admin user list / role / active / send-reset | 11 |
|
||
| 3.8 Rate limiting | 8 |
|
||
| 3.9 CSRF double-submit + headers | 7, 12, 14 |
|
||
| Data model (users, sessions_auth, auth_tokens) | 1 |
|
||
| Bcrypt cost 12 | 3 |
|
||
| Token TTLs and purposes | 4, 10 |
|
||
| Last-sysadmin guard | 11 |
|
||
| Email templates (4 types) | 6 |
|
||
| StubMailer fallback when SMTP missing | 6 |
|
||
| Mailpit docker-compose | 13 |
|
||
| Env.example | 13 |
|
||
| Frontend auth pages + AuthBoundary + RoleGuard + UserMenu | 16–21 |
|
||
| E2E mailpit-based smoke | 22 |
|
||
|
||
All spec sections covered.
|
||
|
||
**Placeholders:** none — every step has concrete code or commands.
|
||
|
||
**Type consistency:** PublicUser/User shapes match between shared types, server responses, and authStore. CSRF cookie/header naming uses `CSRF_COOKIE` and `X-CSRF-Token` consistently.
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
**Plan complete and saved to `docs/superpowers/plans/2026-05-20-auth-and-roles.md`. Two execution options:**
|
||
|
||
**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration
|
||
|
||
**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints
|
||
|
||
**Which approach?**
|