From 9ca025f12807ae4e65c94be39087971109d58b8c Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 22:58:29 +0200 Subject: [PATCH] feat(auth): admin user management service, routes, and integration tests --- packages/backend/src/routes/admin-users.ts | 81 ++++++++++++++++ packages/backend/src/services/users.ts | 64 +++++++++++++ .../src/tests/admin-users.integration.test.ts | 96 +++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 packages/backend/src/routes/admin-users.ts create mode 100644 packages/backend/src/services/users.ts create mode 100644 packages/backend/src/tests/admin-users.integration.test.ts diff --git a/packages/backend/src/routes/admin-users.ts b/packages/backend/src/routes/admin-users.ts new file mode 100644 index 0000000..c23e9c1 --- /dev/null +++ b/packages/backend/src/routes/admin-users.ts @@ -0,0 +1,81 @@ +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'; } + +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) { + 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; +} diff --git a/packages/backend/src/services/users.ts b/packages/backend/src/services/users.ts new file mode 100644 index 0000000..767f1b7 --- /dev/null +++ b/packages/backend/src/services/users.ts @@ -0,0 +1,64 @@ +import { and, asc, eq, ne, or, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.js'; +import { users } from '../db/schema.js'; +import type { Role, User } from '@flashcard/shared'; +import { ApiError } from '../lib/errors.js'; + +function rowToUser(r: typeof users.$inferSelect): User { + return { + id: r.id, email: r.email, displayName: r.displayName, role: r.role, + isActive: r.isActive, emailVerifiedAt: r.emailVerifiedAt ?? null, + pendingEmail: r.pendingEmail ?? null, createdAt: r.createdAt, updatedAt: r.updatedAt, + }; +} + +export interface ListUsersParams { q?: string; role?: Role; active?: boolean; limit?: number; offset?: number; } +export interface ListUsersResult { rows: User[]; total: number; } + +export async function listUsers(db: Db, p: ListUsersParams = {}): Promise { + const conditions = [] as ReturnType[]; + if (p.q && p.q.trim() !== '') { + const q = `%${p.q.toLowerCase()}%`; + conditions.push(or(sql`lower(${users.email}) like ${q}`, sql`lower(${users.displayName}) like ${q}`)!); + } + if (p.role) conditions.push(eq(users.role, p.role)); + if (p.active !== undefined) conditions.push(eq(users.isActive, p.active)); + const where = conditions.length ? and(...conditions) : undefined; + const limit = Math.min(200, p.limit ?? 50); + const offset = Math.max(0, p.offset ?? 0); + + const rows = db.select().from(users) + .where(where as any) + .orderBy(asc(users.email)) + .limit(limit).offset(offset).all(); + const totalRow = db.select({ c: sql`count(*)`.as('c') }).from(users).where(where as any).get(); + return { rows: rows.map(rowToUser), total: Number(totalRow?.c ?? 0) }; +} + +export async function adminUpdateUser( + db: Db, + actorId: number, + id: number, + updates: { displayName?: string; role?: Role; isActive?: boolean } +): Promise { + const target = db.select().from(users).where(eq(users.id, id)).get(); + if (!target) throw ApiError.notFound('User'); + + if (target.id === actorId && (updates.isActive === false || updates.role === 'user')) { + const otherActiveSysadmins = db.select({ c: sql`count(*)`.as('c') }) + .from(users) + .where(and(eq(users.role, 'sysadmin'), eq(users.isActive, true), ne(users.id, actorId))) + .get()?.c ?? 0; + if (Number(otherActiveSysadmins) === 0) { + throw new ApiError(409, 'LAST_SYSADMIN', 'Cannot demote or disable the last active sysadmin'); + } + } + + const [row] = db.update(users).set({ + ...(updates.displayName !== undefined && { displayName: updates.displayName }), + ...(updates.role !== undefined && { role: updates.role }), + ...(updates.isActive !== undefined && { isActive: updates.isActive }), + updatedAt: Math.floor(Date.now() / 1000), + }).where(eq(users.id, id)).returning().all(); + return rowToUser(row!); +} diff --git a/packages/backend/src/tests/admin-users.integration.test.ts b/packages/backend/src/tests/admin-users.integration.test.ts new file mode 100644 index 0000000..9ee8286 --- /dev/null +++ b/packages/backend/src/tests/admin-users.integration.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../app.js'; +import { makeTestDb, createUserDirect } from './dbHelper.js'; +import { hashPassword } from '../services/auth/passwords.js'; +import { setMailerForTests, type Mailer } from '../services/auth/email.js'; + +class CaptureMailer implements Mailer { + sent: { to: string; subject: string; text: string }[] = []; + async send(to: string, m: { subject: string; html: string; text: string }) { + this.sent.push({ to, subject: m.subject, text: m.text }); + } +} + +async function loginAs(app: ReturnType, email: string, password = 'secretpass') { + const r = await request(app).post('/api/auth/login').send({ email, password }); + if (r.status !== 200) throw new Error(`login failed: ${r.status} ${JSON.stringify(r.body)}`); + const cookies = (r.headers['set-cookie'] as unknown as string[]); + const csrf = cookies.find((c) => c.startsWith('flashcard_csrf='))!.split(';')[0]!.split('=')[1]!; + return { cookies, csrf }; +} + +async function makeAdmin(env: ReturnType, email: string) { + return createUserDirect(env.db, { + email, role: 'sysadmin', isActive: true, + passwordHash: await hashPassword('secretpass'), + emailVerifiedAt: Math.floor(Date.now() / 1000), + }); +} + +let env: ReturnType; +let mailer: CaptureMailer; +let app: ReturnType; + +beforeEach(async () => { + env = makeTestDb(); + mailer = new CaptureMailer(); + setMailerForTests(mailer); + app = createApp(env.db); +}); + +describe('admin users', () => { + it('invites a user, target accepts and can log in', async () => { + await makeAdmin(env, 'admin@example.com'); + const { cookies, csrf } = await loginAs(app, 'admin@example.com'); + + const inv = await request(app).post('/api/admin/users/invite') + .set('Cookie', cookies).set('x-csrf-token', csrf) + .send({ email: 'newbie@example.com', role: 'user' }); + expect(inv.status).toBe(201); + expect(mailer.sent).toHaveLength(1); + const link = mailer.sent[0]!.text; + const token = decodeURIComponent(link.match(/token=([^\s&"]+)/)![1]!); + + const accept = await request(app).post('/api/auth/accept-invite') + .send({ token, displayName: 'Newbie', password: 'anotherpass' }); + expect(accept.status).toBe(200); + expect(accept.body.email).toBe('newbie@example.com'); + }); + + it('lists users with filtering', async () => { + await makeAdmin(env, 'admin@example.com'); + await createUserDirect(env.db, { email: 'a@example.com', role: 'user' }); + await createUserDirect(env.db, { email: 'b@example.com', role: 'user' }); + const { cookies } = await loginAs(app, 'admin@example.com'); + const list = await request(app).get('/api/admin/users?q=a').set('Cookie', cookies); + expect(list.status).toBe(200); + expect(list.body.rows.find((u: { email: string }) => u.email === 'a@example.com')).toBeTruthy(); + }); + + it('blocks demoting the last sysadmin', async () => { + const admin = await makeAdmin(env, 'admin@example.com'); + const { cookies, csrf } = await loginAs(app, 'admin@example.com'); + const r = await request(app).patch(`/api/admin/users/${admin.id}`) + .set('Cookie', cookies).set('x-csrf-token', csrf) + .send({ role: 'user' }); + expect(r.status).toBe(409); + expect(r.body.error.code).toBe('LAST_SYSADMIN'); + }); + + it('rejects non-admin access to admin routes', async () => { + 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); + }); +});