import express, {Request, Response} from 'express'; import * as InvitationsService from './invitations.service.js'; import * as UsersService from '../users/users.admin.service.js'; import {toPermissions} from '../admin.schema.js'; import {sendInvitationMail} from '../admin.mail.js'; import {ADMIN_APP_URL, LOG_INVITE_LINKS} from '../admin.config.js'; import {sendServerError} from '../admin.errors.js'; import logger from '../../../middleware/logger.js'; export const invitationsRouter = express.Router(); /** * The admin-facing half of invitations (create, resend, revoke). The public * half - preview and accept - lives in invitations.plugin.ts, because * redeeming an invitation has to create a user through better-auth internals. * * Mounted behind requireAppAccess('admin'). */ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** * With the mail relay off (the normal local setup) the invitation mail never * arrives, and only the token's hash is stored, so there would be no way to * walk through the accept flow. Logging the link closes that. * * Gated on an explicit opt-in rather than on NODE_ENV: the link is a live * account-creation credential, and "not production" is too weak a condition to * hang that on. See LOG_INVITE_LINKS in admin.config.ts. */ const logInviteLinkInDev = (token: string): void => { if (LOG_INVITE_LINKS) { logger.info(`Admin: invitation link ${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`); } }; /** * @swagger * /admin/invitations: * get: * summary: List open (unaccepted, unrevoked, unexpired) invitations * tags: [admin] * responses: * 200: * description: Success */ invitationsRouter.get('/', async (req: Request, res: Response) => { try { res.status(200).send(await InvitationsService.listOpenInvitations()); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /admin/invitations: * post: * summary: Invite someone and mail them an acceptance link * tags: [admin] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [email, name, permissions] * properties: * email: * type: string * name: * type: string * permissions: * type: array * description: > * One entry per (app, role). A plain array of app names is * accepted too and means the same at the `access` role. * items: * type: object * properties: * app: * type: string * role: * type: string * responses: * 201: * description: Invitation created and mailed * 400: * description: Invalid input * 409: * description: A user with this address already exists */ invitationsRouter.post('/', async (req: Request, res: Response) => { try { const email = String(req.body?.email || '').trim().toLowerCase(); const name = String(req.body?.name || '').trim(); // Same two accepted shapes as PUT /admin/users/:id/permissions. const permissions = toPermissions(req.body?.permissions ?? req.body?.apps); if (!EMAIL_PATTERN.test(email) || name.length === 0 || !permissions) { res.status(400).send({ status: 'BAD_REQUEST', message: 'E-Mail, Name und Berechtigungen sind erforderlich.' }); return; } // Inviting someone who already has an account would strand them on an // accept page that can only fail. Granting permissions on the existing // user is the operation they actually want. if (await UsersService.findUserByEmail(email)) { res.status(409).send({ status: 'CONFLICT', message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Vergib dort die Berechtigungen.' }); return; } const invitation = await InvitationsService.createInvitation( email, name, permissions, res.locals.admin.id ); const mailed = await sendInvitationMail(email, name, invitation.token, invitation.expiresAt); if (!mailed) { logger.warn('Admin: invitation created but the mail was not accepted', {email}); } logInviteLinkInDev(invitation.token); res.status(201).send({id: invitation.id, email, name, expiresAt: invitation.expiresAt, mailed}); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /admin/invitations/{invitationId}/resend: * post: * summary: Issue a new token for an open invitation and mail it again * description: The previous link stops working. * tags: [admin] * responses: * 200: * description: Resent * 404: * description: No open invitation with this id */ invitationsRouter.post('/:invitationId/resend', async (req: Request, res: Response) => { try { const invitationId = parseInt(req.params.invitationId, 10); if (Number.isNaN(invitationId)) { res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'}); return; } const resent = await InvitationsService.resendInvitation(invitationId); if (!resent) { res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'}); return; } const mailed = await sendInvitationMail(resent.email, resent.name, resent.token, resent.expiresAt); if (!mailed) { logger.warn('Admin: invitation resent but the mail was not accepted', {email: resent.email}); } logInviteLinkInDev(resent.token); res.status(200).send({id: invitationId, expiresAt: resent.expiresAt, mailed}); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /admin/invitations/{invitationId}: * delete: * summary: Revoke an open invitation * tags: [admin] * responses: * 204: * description: Revoked * 404: * description: No open invitation with this id */ invitationsRouter.delete('/:invitationId', async (req: Request, res: Response) => { try { const invitationId = parseInt(req.params.invitationId, 10); if (Number.isNaN(invitationId)) { res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'}); return; } const revoked = await InvitationsService.revokeInvitation(invitationId); if (!revoked) { res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'}); return; } res.status(204).send(); } catch (e: any) { sendServerError(res, e); } });