diff --git a/apps/api/src/routes/admin-users.ts b/apps/api/src/routes/admin-users.ts index f6886ae..fe440e9 100644 --- a/apps/api/src/routes/admin-users.ts +++ b/apps/api/src/routes/admin-users.ts @@ -87,3 +87,36 @@ adminUsersRoutes.post('/api/admin/users/:id/role', async (c) => { const [updated] = await db.select().from(user).where(eq(user.id, id)); return c.json(toListItem(updated)); }); + +adminUsersRoutes.post('/api/admin/users/:id/deactivate', async (c) => { + const id = c.req.param('id'); + const caller = await getSessionUser(c); + const [target] = await db.select().from(user).where(eq(user.id, id)); + if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404); + + if (id === caller?.id) return c.json({ error: 'Je kunt jezelf niet deactiveren.' }, 400); + if (target.role === 'admin' && (await activeAdminsExcluding(id)) === 0) { + return c.json({ error: 'Er moet minstens één actieve beheerder blijven.' }, 400); + } + + await db + .update(user) + .set({ banned: true, banReason: null, banExpires: null }) + .where(eq(user.id, id)); + await db.delete(session).where(eq(session.userId, id)); // kill any live token + const [updated] = await db.select().from(user).where(eq(user.id, id)); + return c.json(toListItem(updated)); +}); + +adminUsersRoutes.post('/api/admin/users/:id/reactivate', async (c) => { + const id = c.req.param('id'); + const [target] = await db.select().from(user).where(eq(user.id, id)); + if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404); + + await db + .update(user) + .set({ banned: false, banReason: null, banExpires: null }) + .where(eq(user.id, id)); + const [updated] = await db.select().from(user).where(eq(user.id, id)); + return c.json(toListItem(updated)); +}); diff --git a/apps/api/test/admin-users.test.ts b/apps/api/test/admin-users.test.ts index 721e9a7..9a81752 100644 --- a/apps/api/test/admin-users.test.ts +++ b/apps/api/test/admin-users.test.ts @@ -193,3 +193,103 @@ describe('POST /api/admin/users/:id/role', () => { expect(res.status).toBe(200); }); }); + +describe('deactivate / reactivate', () => { + async function signIn(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; + } + + it('blocks sign-in after deactivate and restores it after reactivate', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'deact-admin@example.com', 'admin'); + // create a worker we control the password of + await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ + email: 'deact-target@example.com', + name: 'Deact', + password: 'sterk-wachtwoord-123', + role: 'worker', + }), + }); + const id = await userIdByEmail('deact-target@example.com'); + expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).toBe(200); + + const off = await app.request(`/api/admin/users/${id}/deactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect(off.status).toBe(200); + expect((await off.json()).status).toBe('inactive'); + expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).not.toBe(200); + + const on = await app.request(`/api/admin/users/${id}/reactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect((await on.json()).status).toBe('active'); + expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).toBe(200); + }); + + it('refuses self-deactivation', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'deact-self@example.com', 'admin'); + await authToken(app, 'deact-self-other@example.com', 'admin'); // a second admin + const id = await userIdByEmail('deact-self@example.com'); + const res = await app.request(`/api/admin/users/${id}/deactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('deactiveren'); + }); + + it('refuses deactivating the last active admin', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'deact-last-admin@example.com', 'admin'); + // promote a peer, deactivate caller is self-blocked; deactivate the peer is allowed (caller remains). + await authToken(app, 'deact-peer@example.com', 'admin'); + const peerId = await userIdByEmail('deact-peer@example.com'); + const res = await app.request(`/api/admin/users/${peerId}/deactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect(res.status).toBe(200); // caller still admin → allowed + // Now the peer is inactive; the caller is the only active admin. Deactivating self is blocked: + const callerId = await userIdByEmail('deact-last-admin@example.com'); + const selfRes = await app.request(`/api/admin/users/${callerId}/deactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect(selfRes.status).toBe(400); // self-guard (which also preserves the last admin) + }); + + it('404s on an unknown id and 403s for a worker', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'deact-404-admin@example.com', 'admin'); + expect( + ( + await app.request('/api/admin/users/nope/deactivate', { + method: 'POST', + headers: bearer(adminTok), + }) + ).status, + ).toBe(404); + const workerTok = await authToken(app, 'deact-worker@example.com'); + const id = await userIdByEmail('deact-worker@example.com'); + expect( + ( + await app.request(`/api/admin/users/${id}/deactivate`, { + method: 'POST', + headers: bearer(workerTok), + }) + ).status, + ).toBe(403); + }); +});