Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment

Reviewed-on: #12
Co-authored-by: Patrick Müller <mail@pmueller.me>
Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #12.
This commit is contained in:
2026-09-06 10:41:54 +00:00
committed by Patrick Müller
parent ce9b173c71
commit bf7be65b03
39 changed files with 6551 additions and 320 deletions
@@ -0,0 +1,208 @@
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);
}
});