From 2b4b633961a53ad7c3b20591a52c0981eda4b4d0 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 24 Jun 2026 22:46:59 +0200 Subject: [PATCH] feat(api): admin-users router with enriched roster + user creation --- apps/api/src/app.ts | 2 + apps/api/src/routes/admin-users.ts | 56 ++++++++++++++ apps/api/src/routes/admin.ts | 9 --- apps/api/test/admin-users.test.ts | 116 +++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/routes/admin-users.ts create mode 100644 apps/api/test/admin-users.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7d264d8..3c4d75a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import { me } from './routes/me'; import { activitiesRoutes } from './routes/activities'; import { sessionsRoutes } from './routes/sessions'; import { adminRoutes } from './routes/admin'; +import { adminUsersRoutes } from './routes/admin-users'; import { auth } from './auth'; import { env } from './env'; @@ -26,5 +27,6 @@ export function createApp(): Hono { app.route('/', activitiesRoutes); app.route('/', sessionsRoutes); app.route('/', adminRoutes); + app.route('/', adminUsersRoutes); return app; } diff --git a/apps/api/src/routes/admin-users.ts b/apps/api/src/routes/admin-users.ts new file mode 100644 index 0000000..8124733 --- /dev/null +++ b/apps/api/src/routes/admin-users.ts @@ -0,0 +1,56 @@ +import { Hono } from 'hono'; +import { asc, eq } from 'drizzle-orm'; +import { CreateUserInput, type AdminUser, type Role } from '@solelog/shared'; +import { db } from '../db/client'; +import { session, user } from '../db/schema'; +import { auth } from '../auth'; +import { adminGuard } from '../lib/require-user'; + +export const adminUsersRoutes = new Hono(); + +adminUsersRoutes.use('/api/admin/users', adminGuard); +adminUsersRoutes.use('/api/admin/users/*', adminGuard); + +type UserRow = typeof user.$inferSelect; + +// Map a user row to the public list shape. +export function toListItem(row: UserRow): AdminUser { + return { + id: row.id, + email: row.email, + name: row.name, + role: (row.role ?? 'worker') as Role, + status: row.banned ? 'inactive' : 'active', + created_at: new Date(row.createdAt).toISOString(), + }; +} + +// Enriched roster (replaces the old id/name/email version from admin.ts). +adminUsersRoutes.get('/api/admin/users', async (c) => { + const rows = await db.select().from(user).orderBy(asc(user.name)); + return c.json(rows.map(toListItem)); +}); + +// Create a user. role/password validated by zod; password hashing via better-auth. +adminUsersRoutes.post('/api/admin/users', async (c) => { + const parsed = CreateUserInput.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input' }, 400); + const d = parsed.data; + + // better-auth types role as its built-in set; our 'worker' role is valid at runtime (same cast as seed.ts). + const createUser = auth.api.createUser as (args: { + body: { email: string; password: string; name: string; role: 'worker' | 'admin' }; + }) => Promise; + + try { + await createUser({ + body: { email: d.email, password: d.password, name: d.name, role: d.role }, + }); + } catch { + // Inputs are pre-validated, so the realistic failure is a duplicate email. + return c.json({ error: 'E-mailadres bestaat al.' }, 409); + } + + const [row] = await db.select().from(user).where(eq(user.email, d.email)); + return c.json(toListItem(row)); +}); diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 8d39f2d..c037822 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -105,15 +105,6 @@ adminRoutes.get('/api/admin/sessions/active', async (c) => { ); }); -// Roster for the create-form worker picker. Direct DB read — no better-auth client dependency. -adminRoutes.get('/api/admin/users', async (c) => { - const rows = await db - .select({ id: user.id, name: user.name, email: user.email }) - .from(user) - .orderBy(asc(user.name)); - return c.json(rows); -}); - adminRoutes.get('/api/admin/report', async (c) => { const q = parseReportQuery(c); if (!q) return c.json({ error: 'Invalid query' }, 400); diff --git a/apps/api/test/admin-users.test.ts b/apps/api/test/admin-users.test.ts new file mode 100644 index 0000000..185c550 --- /dev/null +++ b/apps/api/test/admin-users.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import type { Hono } from 'hono'; +import { createApp } from '../src/app'; +import { authToken, bearer } from './helpers'; + +const PASSWORD = 'sterk-wachtwoord-123'; + +async function signInStatus(app: Hono, email: string, password: string): Promise { + const res = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + return res.status; +} + +describe('GET /api/admin/users', () => { + it('401s without a token, 403s for a worker', async () => { + const app = createApp(); + expect((await app.request('/api/admin/users')).status).toBe(401); + const workerTok = await authToken(app, 'users-list-worker@example.com'); + expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe( + 403, + ); + }); + + it('returns each user with role and status', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'users-list-admin@example.com', 'admin'); + await authToken(app, 'users-list-w@example.com'); // a worker + + const res = await app.request('/api/admin/users', { headers: bearer(adminTok) }); + expect(res.status).toBe(200); + const users = await res.json(); + const admin = users.find((u: { email: string }) => u.email === 'users-list-admin@example.com'); + const worker = users.find((u: { email: string }) => u.email === 'users-list-w@example.com'); + expect(admin.role).toBe('admin'); + expect(admin.status).toBe('active'); + expect(worker.role).toBe('worker'); + expect(worker.status).toBe('active'); + }); +}); + +describe('POST /api/admin/users', () => { + it('creates a user who can then sign in', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'users-create-admin@example.com', 'admin'); + const res = await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ + email: 'created-worker@example.com', + name: 'Nieuwe Werker', + password: PASSWORD, + role: 'worker', + }), + }); + expect(res.status).toBe(200); + const created = await res.json(); + expect(created.email).toBe('created-worker@example.com'); + expect(created.role).toBe('worker'); + expect(created.status).toBe('active'); + expect(await signInStatus(app, 'created-worker@example.com', PASSWORD)).toBe(200); + }); + + it('rejects a duplicate email with 409', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'users-dup-admin@example.com', 'admin'); + const body = JSON.stringify({ + email: 'dup@example.com', + name: 'Dup', + password: PASSWORD, + role: 'worker', + }); + expect( + (await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body })) + .status, + ).toBe(200); + expect( + (await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body })) + .status, + ).toBe(409); + }); + + it('rejects a too-short password with 400', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'users-shortpw-admin@example.com', 'admin'); + const res = await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ + email: 'short@example.com', + name: 'S', + password: 'short', + role: 'worker', + }), + }); + expect(res.status).toBe(400); + }); + + it('403s for a worker', async () => { + const app = createApp(); + const workerTok = await authToken(app, 'users-create-worker@example.com'); + const res = await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(workerTok), + body: JSON.stringify({ + email: 'x@example.com', + name: 'X', + password: PASSWORD, + role: 'worker', + }), + }); + expect(res.status).toBe(403); + }); +});