import express, {Request, Response} from 'express'; import * as UsersService from './users.admin.service.js'; import {toPermissions} from '../admin.schema.js'; import {sendServerError} from '../admin.errors.js'; export const usersAdminRouter = express.Router(); /** * User administration. Mounted behind requireAppAccess('admin'), so every * handler here can assume res.locals.admin is an admin. * * The guards below exist because this API can lock its own operators out: the * only way to grant a permission is through these routes, so an admin who * removes the last `admin` permission leaves nobody who can put it back short * of a manual SQL statement in production. */ const conflict = (res: Response, message: string): void => { res.status(409).send({status: 'CONFLICT', message}); }; const notFound = (res: Response): void => { res.status(404).send({status: 'NOT_FOUND', message: 'Benutzer nicht gefunden.'}); }; /** * @swagger * /admin/users: * get: * summary: List all users with their app permissions and status * tags: [admin] * responses: * 200: * description: Success * 401: * description: Not signed in * 403: * description: Missing the admin permission */ usersAdminRouter.get('/', async (req: Request, res: Response) => { try { res.status(200).send(await UsersService.listUsers()); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /admin/users/{userId}: * get: * summary: One user with their active sessions and passkey count * tags: [admin] * parameters: * - in: path * name: userId * required: true * schema: * type: string * responses: * 200: * description: Success * 404: * description: Unknown user */ usersAdminRouter.get('/:userId', async (req: Request, res: Response) => { try { const detail = await UsersService.getUserDetail(req.params.userId); if (!detail) { notFound(res); return; } res.status(200).send(detail); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /admin/users/{userId}/permissions: * put: * summary: Replace a user's app permissions * description: Refuses to remove the caller's own admin permission or the last remaining active admin. * tags: [admin] * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * permissions: * type: array * description: > * One entry per (app, role). `access` is the only role today. * A plain array of app names is also accepted and means the * same at the `access` role. * items: * type: object * properties: * app: * type: string * enum: [calendar, feedback, tickets, admin] * role: * type: string * enum: [access] * responses: * 200: * description: Success * 400: * description: Invalid app or role * 409: * description: Would lock the last admin out */ usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response) => { try { const userId = req.params.userId; // `permissions: [{app, role}]` is the real shape; `apps: ['tickets']` is // accepted as shorthand for the same thing at the `access` role, so a // caller that predates roles keeps working. const permissions = toPermissions(req.body?.permissions ?? req.body?.apps); if (!permissions) { res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Berechtigungsliste.'}); return; } if (!(await UsersService.userExists(userId))) { notFound(res); return; } const target = await UsersService.loadAccess(userId); const keepsAdmin = permissions.some(permission => permission.app === 'admin'); const losesAdmin = Boolean(target?.apps.includes('admin')) && !keepsAdmin; // Self-lockout is checked here because it needs the caller's identity, // which the service has no business knowing. The last-admin check is // NOT done here: it has to be inside the write transaction to survive // two admins acting at the same time (see setPermissionsGuarded). if (losesAdmin && userId === res.locals.admin.id) { conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.'); return; } const result = await UsersService.setPermissionsGuarded(userId, permissions, res.locals.admin.id); if (result === 'last-admin') { conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.'); return; } res.status(200).send(await UsersService.getUserDetail(userId)); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /admin/users/{userId}/disable: * post: * summary: Disable a user and revoke all of their sessions * tags: [admin] * responses: * 200: * description: Success * 409: * description: Would disable the caller or the last admin */ usersAdminRouter.post('/:userId/disable', async (req: Request, res: Response) => { try { const userId = req.params.userId; if (userId === res.locals.admin.id) { conflict(res, 'Du kannst dich nicht selbst deaktivieren.'); return; } const target = await UsersService.loadAccess(userId); if (!target) { notFound(res); return; } const result = await UsersService.disableUserGuarded(userId); if (result === 'last-admin') { conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.'); return; } res.status(200).send(await UsersService.getUserDetail(userId)); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /admin/users/{userId}/enable: * post: * summary: Re-enable a disabled user * description: Does not restore sessions - the user signs in again. * tags: [admin] * responses: * 200: * description: Success */ usersAdminRouter.post('/:userId/enable', async (req: Request, res: Response) => { try { if (!(await UsersService.userExists(req.params.userId))) { notFound(res); return; } await UsersService.enableUser(req.params.userId); res.status(200).send(await UsersService.getUserDetail(req.params.userId)); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /admin/users/{userId}/sessions/{sessionId}: * delete: * summary: Revoke one session of a user * tags: [admin] * responses: * 204: * description: Revoked * 404: * description: Unknown session for this user */ usersAdminRouter.delete('/:userId/sessions/:sessionId', async (req: Request, res: Response) => { try { const revoked = await UsersService.revokeSession(req.params.userId, req.params.sessionId); if (!revoked) { res.status(404).send({status: 'NOT_FOUND', message: 'Sitzung nicht gefunden.'}); return; } res.status(204).send(); } catch (e: any) { sendServerError(res, e); } });