From 8bdbe292d517ecc5264f8ac1e9428a8315531d02 Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 15:35:24 +0200 Subject: [PATCH] feat: admin user management routes --- server/src/routes/authRoutes.ts | 36 +++++++++++++++++ server/test/authRoutes.test.ts | 72 ++++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/server/src/routes/authRoutes.ts b/server/src/routes/authRoutes.ts index 0a95aae..30f384e 100644 --- a/server/src/routes/authRoutes.ts +++ b/server/src/routes/authRoutes.ts @@ -4,6 +4,7 @@ import { verifyPassword, createUser, getUserByUsername, + getUserById, createSession, getUserBySession, deleteSession, @@ -93,6 +94,8 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise { if (!user || !ok) { return reply.code(401).send({ error: 'invalid_credentials' }) } + const oldToken = request.cookies[COOKIE_NAME] + if (oldToken) deleteSession(request.server.db, oldToken) const token = createSession(request.server.db, user.id) return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) }) }) @@ -106,4 +109,37 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise { app.get('/api/me', { preHandler: [requireAuth] }, async (request) => { return { user: toPublicUser(request.user as UserRow) } }) + + app.get('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request) => { + const users = request.server.db.prepare('SELECT * FROM users ORDER BY id').all() as UserRow[] + return { users: users.map(toPublicUser) } + }) + + app.post('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { + const { username, password } = (request.body ?? {}) as { username?: string; password?: string } + const invalid = validateCredentials(username, password) + if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid }) + if (getUserByUsername(request.server.db, username as string)) { + return reply.code(409).send({ error: 'username_taken' }) + } + const user = createUser(request.server.db, username as string, await hashPassword(password as string), false) + request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id) + return reply.code(200).send(toPublicUser(user)) + }) + + app.delete( + '/api/users/:id', + { preHandler: [requireAuth, requireAdmin] }, + async (request, reply) => { + const id = Number((request.params as { id: string }).id) + if (id === (request.user as UserRow).id) { + return reply.code(400).send({ error: 'cannot_delete_self' }) + } + if (!getUserById(request.server.db, id)) { + return reply.code(404).send({ error: 'not_found' }) + } + request.server.db.prepare('DELETE FROM users WHERE id = ?').run(id) + return reply.code(200).send({ ok: true }) + } + ) } diff --git a/server/test/authRoutes.test.ts b/server/test/authRoutes.test.ts index 143b69f..e2bc3f4 100644 --- a/server/test/authRoutes.test.ts +++ b/server/test/authRoutes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildTestApp, setupAdmin, getCookie, auth } from './helpers.js' +import { buildTestApp, setupAdmin, getCookie, auth, loginAs } from './helpers.js' describe('auth routes', () => { it('setup creates admin when no users exist, then is closed', async () => { @@ -72,3 +72,73 @@ describe('auth routes', () => { await app.close() }) }) + +describe('user admin', () => { + async function adminApp() { + const app = await buildTestApp() + const cookie = await setupAdmin(app) + return { app, cookie } + } + + it('admin creates a user and lists users', async () => { + const { app, cookie } = await adminApp() + const created = await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(cookie), + payload: { username: 'bob', password: 'bobpass123' }, + }) + expect(created.statusCode).toBe(200) + expect(created.json()).toEqual({ id: 2, username: 'bob', isAdmin: false }) + + const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) }) + expect(list.json().users.map((u: { username: string }) => u.username)).toEqual(['admin', 'bob']) + await app.close() + }) + + it('non-admin cannot list or create users', async () => { + const { app, cookie } = await adminApp() + const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123') + const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(bobCookie) }) + expect(list.statusCode).toBe(403) + const create = await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(bobCookie), + payload: { username: 'eve', password: 'evepass123' }, + }) + expect(create.statusCode).toBe(403) + await app.close() + }) + + it('cannot delete self; deleting another user works and cascades settings', async () => { + const { app, cookie } = await adminApp() + await loginAs(app, cookie, 'bob', 'bobpass123') + const selfDelete = await app.inject({ method: 'DELETE', url: '/api/users/1', ...auth(cookie) }) + expect(selfDelete.statusCode).toBe(400) + + const del = await app.inject({ method: 'DELETE', url: '/api/users/2', ...auth(cookie) }) + expect(del.statusCode).toBe(200) + const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) }) + expect(list.json().users).toHaveLength(1) + await app.close() + }) + + it('rejects duplicate username', async () => { + const { app, cookie } = await adminApp() + await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(cookie), + payload: { username: 'bob', password: 'bobpass123' }, + }) + const again = await app.inject({ + method: 'POST', + url: '/api/users', + ...auth(cookie), + payload: { username: 'bob', password: 'otherpass123' }, + }) + expect(again.statusCode).toBe(409) + await app.close() + }) +})