feat(api): deactivate/reactivate users (ban + revoke sessions) with guards
This commit is contained in:
@@ -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));
|
const [updated] = await db.select().from(user).where(eq(user.id, id));
|
||||||
return c.json(toListItem(updated));
|
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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -193,3 +193,103 @@ describe('POST /api/admin/users/:id/role', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deactivate / reactivate', () => {
|
||||||
|
async function signIn(app: Hono, email: string, password: string): Promise<number> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user