Add admin identity module: better-auth, per-app permissions, invitations
Introduces src/models/admin/, a dedicated identity and permissions module on its own nachklang_admin database, and the shared authenticator that feedback and tickets will move onto in the cutover step. Nothing swaps over yet: feedback.auth.ts and tickets.auth.ts still authenticate against the legacy calendar sessions, so production behaviour is unchanged. - better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies scoped to .nachklang.art so one sign-in covers every *.nachklang.art app. - Accounts are invite-only: public sign-up is disabled, and the invitations plugin is the only code that creates users. Tokens are stored as SHA-256 hashes and travel in the request body, never in a URL. - Per-app permissions in user_app_permissions; requireAppAccess(app) queries the database on every request (no cookie cache) so disabling a user or revoking a session takes effect immediately. - ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently and without crashing the API if the database is unreachable at boot. - Guards prevent an admin from removing their own admin permission, disabling themselves, or stripping the last active admin. The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's MysqlDialect drives the pool with callbacks, and the promise wrapper ignores them, so every query hangs silently. Only the integration tests caught this. Schema in sql/admin/001_init.sql, derived from getAuthTables() on the installed better-auth rather than the published CLI, which lags the library and omits account.issuer. app.ts is split into src/app.factory.ts so the integration tests drive the real middleware order rather than a copy of it. Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB started by test/integration/setup.ts (docker or podman). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import express from 'express';
|
||||
import * as dotenv from 'dotenv';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import swaggerJSDoc from 'swagger-jsdoc';
|
||||
import cors from 'cors';
|
||||
import {toNodeHandler} from 'better-auth/node';
|
||||
|
||||
// Router imports
|
||||
import {calendarRouter} from './models/calendar/Calendar.router.js';
|
||||
import {feedbackRouter} from './models/feedback/Feedback.router.js';
|
||||
import {ticketsRouter} from './models/tickets/Tickets.router.js';
|
||||
import {adminRouter} from './models/admin/Admin.router.js';
|
||||
import {auth} from './models/admin/admin.auth.js';
|
||||
import {ADMIN_ALLOWED_ORIGINS} from './models/admin/admin.config.js';
|
||||
|
||||
|
||||
dotenv.config();
|
||||
|
||||
|
||||
/**
|
||||
* Builds the Express app with every router and middleware in place.
|
||||
*
|
||||
* Separate from app.ts so the integration tests can drive the *real* wiring
|
||||
* with supertest instead of a hand-rolled copy of it. The order below is not
|
||||
* cosmetic - CORS has to precede the better-auth handler so preflights get
|
||||
* their headers, and the better-auth handler has to precede express.json()
|
||||
* because it reads the raw body stream itself.
|
||||
*/
|
||||
export const createApp = (): express.Application => {
|
||||
const app: express.Application = express();
|
||||
|
||||
// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header.
|
||||
// Verify the resolved client IP is correct in staging before relying on it
|
||||
// (used by the feedback rate limiter).
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Configure CORS. This has to run before the better-auth handler below, so
|
||||
// that preflights for /admin/auth/* get their headers, which is why it now
|
||||
// sits above express.json() instead of after it.
|
||||
let allowedHosts = [
|
||||
'https://www.nachklang.art',
|
||||
'https://calendar.nachklang.art',
|
||||
'https://feedback.nachklang.art',
|
||||
'https://tickets.nachklang.art',
|
||||
'https://admin.nachklang.art',
|
||||
// The admin app's origin comes from ADMIN_APP_URL, so a rename or a
|
||||
// staging host does not need a code change here.
|
||||
...ADMIN_ALLOWED_ORIGINS
|
||||
];
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const localhostRegex = /^http:\/\/localhost:\d+$/;
|
||||
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
|
||||
// be reached from a real phone over WiFi during dev (the phone's Origin is
|
||||
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
|
||||
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
|
||||
app.use(cors({
|
||||
// X-Session-* stay allowed until the calendar module is migrated off the
|
||||
// legacy header sessions (see docs/calendar-auth-migration.md).
|
||||
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
|
||||
// The admin session lives in a cookie, so browsers must be allowed to send
|
||||
// it cross-origin - this is what makes credentials: 'include' work.
|
||||
credentials: true,
|
||||
origin: function (origin: any, callback: any) {
|
||||
// Allow requests with no origin
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Any localhost port, or a private-LAN IP, is fine outside production -
|
||||
// dev servers pick whatever port is free (Next.js falls back from 3000
|
||||
// if it's taken), and real-device testing hits the dev machine by IP.
|
||||
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Block requests with wrong origin
|
||||
if (allowedHosts.indexOf(origin) === -1) {
|
||||
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
|
||||
}
|
||||
|
||||
// Allow all other requests
|
||||
return callback(null, true);
|
||||
}
|
||||
}));
|
||||
|
||||
// better-auth's own handler, mounted before express.json(): it reads the raw
|
||||
// request body stream itself and a parsed body would leave it hanging.
|
||||
app.all('/admin/auth/*', toNodeHandler(auth));
|
||||
|
||||
// here we are adding middleware to parse all incoming requests as JSON
|
||||
app.use(express.json());
|
||||
|
||||
// Swagger documentation
|
||||
const swaggerDefinition = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Nachklang e.V. REST API',
|
||||
version: '1.0.0',
|
||||
license: {
|
||||
name: 'Licensed Under MIT',
|
||||
url: 'https://spdx.org/licenses/MIT.html'
|
||||
},
|
||||
contact: {
|
||||
name: 'Nachklang e.V.',
|
||||
url: 'https://www.nachklang.art'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const options = {
|
||||
swaggerDefinition,
|
||||
// Paths to files containing OpenAPI definitions
|
||||
apis: [
|
||||
'./src/models/**/*.interface.ts',
|
||||
'./src/models/**/*.router.ts'
|
||||
]
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJSDoc(options);
|
||||
|
||||
app.use(
|
||||
'/docs',
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(swaggerSpec)
|
||||
);
|
||||
|
||||
// Add routers
|
||||
app.use('/calendar', calendarRouter);
|
||||
app.use('/feedback', feedbackRouter);
|
||||
app.use('/tickets', ticketsRouter);
|
||||
// JSON routes only; the auth handler above is mounted separately.
|
||||
app.use('/admin', adminRouter);
|
||||
|
||||
// this is a simple route to make sure everything is working properly
|
||||
app.get('/', (req: express.Request, res: express.Response) => {
|
||||
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
|
||||
});
|
||||
|
||||
return app;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import mysql from 'mysql2';
|
||||
import {Kysely, MysqlDialect} from 'kysely';
|
||||
import {AdminDatabase} from './admin.schema.js';
|
||||
import logger from '../../middleware/logger.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* The admin module is the one place in this API that does not use the
|
||||
* `mariadb` driver: better-auth talks to the database through Kysely, whose
|
||||
* MySQL dialect expects a mysql2 pool. The other domains keep their own
|
||||
* `mariadb` pools (see Feedback.db.ts) - this is an addition, not a migration.
|
||||
*
|
||||
* The pool is the callback-style `mysql2` one, NOT `mysql2/promise`: Kysely's
|
||||
* MysqlDialect calls `pool.getConnection((err, conn) => ...)`. The promise
|
||||
* wrapper ignores that callback and returns a Promise instead, so every query
|
||||
* through Kysely would hang forever with no error - which is exactly what it
|
||||
* did until the integration tests caught it.
|
||||
*
|
||||
* timezone 'Z' matters: better-auth computes session and token expiry in UTC.
|
||||
* Without it mysql2 would write and read those DATETIMEs in the process's local
|
||||
* zone, so sessions would expire an hour early or late depending on DST.
|
||||
*/
|
||||
|
||||
export namespace NachklangAdminDB {
|
||||
export const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.ADMIN_DB,
|
||||
// The other modules' pools default to 3306. This one is configurable so
|
||||
// the integration tests can point at a throwaway container on another
|
||||
// port without touching a developer's real .env.
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||
connectionLimit: 5,
|
||||
timezone: 'Z'
|
||||
});
|
||||
|
||||
// mysql2 emits connection trouble as an event on the pool, not only as a
|
||||
// rejected query. Without a listener Node turns that into an
|
||||
// uncaughtException, so a database restart would take the whole API - and
|
||||
// with it the calendar, feedback and tickets domains - down with it.
|
||||
// Individual queries still reject, and their callers still answer 500.
|
||||
pool.on('error', (err: unknown) => {
|
||||
logger.error('Admin database pool error', {detail: (err as any)?.message});
|
||||
});
|
||||
|
||||
// Handed to better-auth as `database: {dialect, type: 'mysql'}`.
|
||||
export const dialect = new MysqlDialect({pool});
|
||||
|
||||
// Used by this module's own services for the two custom tables and for
|
||||
// permission lookups that join better-auth's `user`.
|
||||
export const db = new Kysely<AdminDatabase>({dialect});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import {requireAppAccess, requireSignedIn} from './admin.middleware.js';
|
||||
import {usersAdminRouter} from './users/users.admin.router.js';
|
||||
import {invitationsRouter} from './invitations/invitations.router.js';
|
||||
|
||||
/**
|
||||
* The admin module's JSON routes. Deliberately *not* the better-auth handler:
|
||||
* that one is mounted separately in app.ts, ahead of express.json(), because it
|
||||
* needs the raw request body stream.
|
||||
*
|
||||
* Mounted at /admin, so the tree is:
|
||||
* /admin/me any signed-in account
|
||||
* /admin/users/* admin permission
|
||||
* /admin/invitations/* admin permission
|
||||
*/
|
||||
export const adminRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/me:
|
||||
* get:
|
||||
* summary: The current user's identity and app permissions
|
||||
* description: Used by every frontend to decide what to show. The API remains the real gate.
|
||||
* tags: [admin]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Not signed in
|
||||
* 403:
|
||||
* description: Account disabled
|
||||
*/
|
||||
adminRouter.get('/me', requireSignedIn, (req: Request, res: Response) => {
|
||||
res.status(200).send({
|
||||
id: res.locals.admin.id,
|
||||
email: res.locals.admin.email,
|
||||
fullName: res.locals.admin.displayName,
|
||||
apps: res.locals.admin.apps
|
||||
});
|
||||
});
|
||||
|
||||
adminRouter.use('/users', requireAppAccess('admin'), usersAdminRouter);
|
||||
adminRouter.use('/invitations', requireAppAccess('admin'), invitationsRouter);
|
||||
@@ -0,0 +1,141 @@
|
||||
import {betterAuth} from 'better-auth';
|
||||
import {APIError} from 'better-auth/api';
|
||||
import {passkey} from '@better-auth/passkey';
|
||||
import {NachklangAdminDB} from './Admin.db.js';
|
||||
import {invitationsPlugin} from './invitations/invitations.plugin.js';
|
||||
import {sendPasswordResetMail} from './admin.mail.js';
|
||||
import * as UsersService from './users/users.admin.service.js';
|
||||
import logger from '../../middleware/logger.js';
|
||||
import {
|
||||
ADMIN_ALLOWED_ORIGINS,
|
||||
API_BASE_URL,
|
||||
BETTER_AUTH_SECRET,
|
||||
PASSKEY_RP_ID,
|
||||
isProd
|
||||
} from './admin.config.js';
|
||||
|
||||
/**
|
||||
* The single better-auth instance for all *.nachklang.art apps. Mounted in
|
||||
* app.ts at /admin/auth/* with better-auth's own node handler, ahead of
|
||||
* express.json() (it needs the raw body stream).
|
||||
*
|
||||
* The session cookie is what every app trusts. Everything else in this module -
|
||||
* permissions, invitations, the admin UI - hangs off it.
|
||||
*/
|
||||
|
||||
const DAY = 60 * 60 * 24;
|
||||
|
||||
// Dev runs the apps on plain localhost ports; cookies ignore the port, so
|
||||
// single-sign-on across them works without fake subdomains or mkcert.
|
||||
const localhostOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3003'
|
||||
];
|
||||
|
||||
const trustedOrigins = isProd
|
||||
? ADMIN_ALLOWED_ORIGINS
|
||||
: Array.from(new Set([...ADMIN_ALLOWED_ORIGINS, ...localhostOrigins]));
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: 'Nachklang',
|
||||
database: {
|
||||
dialect: NachklangAdminDB.dialect,
|
||||
type: 'mysql'
|
||||
},
|
||||
basePath: '/admin/auth',
|
||||
// Mandatory once crossSubDomainCookies is on: better-auth derives the
|
||||
// cookie domain and its own absolute URLs from this.
|
||||
baseURL: API_BASE_URL,
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
trustedOrigins,
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
// There is no public sign-up: accounts exist only through an
|
||||
// invitation (see invitations.plugin.ts). This also makes
|
||||
// auth.api.signUpEmail throw, which is intended.
|
||||
disableSignUp: true,
|
||||
sendResetPassword: async ({user, url}) => {
|
||||
await sendPasswordResetMail(user.email, user.name, url);
|
||||
}
|
||||
},
|
||||
|
||||
user: {
|
||||
additionalFields: {
|
||||
// Not `returned`, and not settable through the API: disabling is an
|
||||
// admin action on /admin/users/:id/disable, never something a
|
||||
// session owner can flip on themselves.
|
||||
disabled: {
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
returned: false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
session: {
|
||||
expiresIn: 30 * DAY,
|
||||
updateAge: DAY
|
||||
// Deliberately no cookieCache: requireAppAccess hits the database on
|
||||
// every request anyway, and a cached session would keep a disabled
|
||||
// user or a revoked session alive for the cache's lifetime.
|
||||
},
|
||||
|
||||
advanced: {
|
||||
// Fixes the cookie name across releases so the frontends' middleware can
|
||||
// check for it: "nachklang.session_token", or
|
||||
// "__Secure-nachklang.session_token" over https.
|
||||
cookiePrefix: 'nachklang',
|
||||
crossSubDomainCookies: isProd
|
||||
? {enabled: true, domain: '.nachklang.art'}
|
||||
: {enabled: false},
|
||||
ipAddress: {
|
||||
// better-auth reads the request itself and does not know about
|
||||
// Express's `trust proxy`, so the header has to be named here.
|
||||
// Verify against what Plesk's nginx actually sets before relying on
|
||||
// the rate limiter (see the plan's pre-deploy checklist).
|
||||
ipAddressHeaders: ['x-real-ip', 'x-forwarded-for']
|
||||
}
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
// Passenger may run more than one instance; an in-memory limiter would
|
||||
// then give each of them its own budget.
|
||||
storage: 'database'
|
||||
},
|
||||
|
||||
plugins: [
|
||||
passkey({
|
||||
rpID: PASSKEY_RP_ID,
|
||||
rpName: 'Nachklang',
|
||||
origin: ADMIN_ALLOWED_ORIGINS
|
||||
}),
|
||||
invitationsPlugin()
|
||||
],
|
||||
|
||||
databaseHooks: {
|
||||
session: {
|
||||
create: {
|
||||
before: async session => {
|
||||
const access = await UsersService.loadAccess(session.userId);
|
||||
if (access?.disabled) {
|
||||
logger.warn('Admin: sign-in attempt by a disabled account', {userId: session.userId});
|
||||
// Throwing rather than returning false: `false` aborts
|
||||
// the session write silently and the caller sees a
|
||||
// confusing success-shaped response with no cookie.
|
||||
throw new APIError('FORBIDDEN', {
|
||||
code: 'ACCOUNT_DISABLED',
|
||||
message: 'Dieses Konto ist deaktiviert.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type AdminAuth = typeof auth;
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as UsersService from './users/users.admin.service.js';
|
||||
import * as InvitationsService from './invitations/invitations.service.js';
|
||||
import {sendInvitationMail} from './admin.mail.js';
|
||||
import {ADMIN_APP_URL, ADMIN_BOOTSTRAP_EMAIL, isProd} from './admin.config.js';
|
||||
import logger from '../../middleware/logger.js';
|
||||
|
||||
/**
|
||||
* Solves the empty-database problem: with invite-only accounts and no public
|
||||
* sign-up, a fresh nachklang_admin has nobody who can invite anybody. Rather
|
||||
* than a CLI script somebody has to remember to run against production, the API
|
||||
* makes sure on every start that ADMIN_BOOTSTRAP_EMAIL can get in.
|
||||
*
|
||||
* Idempotent by design - it is safe on every restart:
|
||||
* - an active admin already exists -> do nothing
|
||||
* - the address exists as a user -> grant it `admin`
|
||||
* - an open invitation exists -> do nothing (do not re-mail on restart)
|
||||
* - otherwise -> invite, and mail the link
|
||||
*
|
||||
* Never throws: a database blip at boot must not stop the API from serving the
|
||||
* calendar, feedback and tickets domains.
|
||||
*/
|
||||
export const bootstrapAdmin = async (): Promise<void> => {
|
||||
try {
|
||||
if (!ADMIN_BOOTSTRAP_EMAIL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = ADMIN_BOOTSTRAP_EMAIL.trim().toLowerCase();
|
||||
|
||||
if ((await UsersService.countActiveAdmins()) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await UsersService.findUserByEmail(email);
|
||||
if (existing) {
|
||||
await UsersService.grantPermission(existing.id, 'admin', null);
|
||||
logger.info('Admin bootstrap: granted the admin permission to the existing bootstrap user', {email});
|
||||
return;
|
||||
}
|
||||
|
||||
// An expired invitation is not "open", so the next restart re-issues
|
||||
// one - which is the recovery path if the first mail never arrived.
|
||||
if (await InvitationsService.hasOpenInvitationFor(email)) {
|
||||
logger.info('Admin bootstrap: an open invitation already exists', {email});
|
||||
return;
|
||||
}
|
||||
|
||||
const invitation = await InvitationsService.createInvitation(
|
||||
email,
|
||||
'Nachklang Admin',
|
||||
['admin'],
|
||||
null
|
||||
);
|
||||
|
||||
const mailed = await sendInvitationMail(email, 'Nachklang Admin', invitation.token, invitation.expiresAt);
|
||||
logger.info('Admin bootstrap: invitation created', {email, mailed});
|
||||
|
||||
// Outside production the Salesforce mail relay is usually off, so the
|
||||
// link is logged instead - that is how a local setup gets its first
|
||||
// admin. Never in production: the log would then hold a live credential.
|
||||
if (!isProd) {
|
||||
logger.info(`Admin bootstrap: ${ADMIN_APP_URL}/accept-invite?token=${invitation.token}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Admin bootstrap failed', {detail: e?.message});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import logger from '../../middleware/logger.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* One place that reads the admin module's environment. Both admin.auth.ts
|
||||
* (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the
|
||||
* same origin list, and a second parser would drift from this one.
|
||||
*
|
||||
* In production every value is mandatory: a missing BETTER_AUTH_SECRET or a
|
||||
* wrong ADMIN_APP_URL is the kind of misconfiguration that fails as "login
|
||||
* silently does nothing" hours later, so it fails at boot instead. In dev the
|
||||
* localhost defaults below let a fresh checkout run without an .env.
|
||||
*/
|
||||
|
||||
export const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
const required = (name: string, devDefault: string): string => {
|
||||
const value = process.env[name];
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
if (isProd) {
|
||||
logger.error(`Admin module: ${name} is not set`);
|
||||
throw new Error(`${name} must be set in production`);
|
||||
}
|
||||
return devDefault;
|
||||
};
|
||||
|
||||
export const API_BASE_URL = required('API_BASE_URL', 'http://localhost:3000');
|
||||
export const ADMIN_APP_URL = required('ADMIN_APP_URL', 'http://localhost:3002');
|
||||
|
||||
// 32+ random bytes; better-auth signs cookies and reset tokens with it.
|
||||
// Rotating it invalidates every session, which is why it is not derived.
|
||||
export const BETTER_AUTH_SECRET = required(
|
||||
'BETTER_AUTH_SECRET',
|
||||
'dev-only-insecure-secret-do-not-use-in-production'
|
||||
);
|
||||
|
||||
// Passkeys are bound to this: a credential registered for "nachklang.art"
|
||||
// works on every *.nachklang.art host, one registered for "localhost" only
|
||||
// works in dev. Changing it invalidates every registered passkey.
|
||||
export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost');
|
||||
|
||||
// The apps whose frontends may talk to /admin/* with credentials.
|
||||
const parseOrigins = (value: string | undefined): string[] => {
|
||||
return (value || '')
|
||||
.split(',')
|
||||
.map(origin => origin.trim().replace(/\/$/, ''))
|
||||
.filter(origin => origin.length > 0);
|
||||
};
|
||||
|
||||
export const APP_ORIGINS = parseOrigins(process.env.APP_ORIGINS);
|
||||
|
||||
// Kept in sync by construction rather than by three separate lists: the admin
|
||||
// app itself always counts, and dev adds the local ports.
|
||||
export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
|
||||
ADMIN_APP_URL.replace(/\/$/, ''),
|
||||
...APP_ORIGINS
|
||||
]));
|
||||
|
||||
export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || '';
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Response} from 'express';
|
||||
import {Guid} from 'guid-typescript';
|
||||
import logger from '../../middleware/logger.js';
|
||||
|
||||
/**
|
||||
* Same catch-block convention as the feedback and tickets modules: log with a
|
||||
* reference guid, never hand the real error message to the client.
|
||||
*/
|
||||
export const sendServerError = (res: Response, e: any): void => {
|
||||
const errorGuid = Guid.create().toString();
|
||||
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
|
||||
res.status(500).send({
|
||||
status: 'PROCESSING_ERROR',
|
||||
message: 'Internal Server Error. Try again later.',
|
||||
reference: errorGuid
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Swagger component definitions for the admin module. Picked up by
|
||||
* swagger-jsdoc through the `src/models/**\/*.interface.ts` glob in
|
||||
* app.factory.ts.
|
||||
*
|
||||
* Note what is *not* documented here: the better-auth routes under
|
||||
* /admin/auth/* (sign-in, sign-out, reset-password, passkey ceremonies, and
|
||||
* the invitation preview/accept endpoints). better-auth owns those paths and
|
||||
* their shapes; duplicating them by hand would only drift on the next upgrade.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* securitySchemes:
|
||||
* AdminSessionCookie:
|
||||
* type: apiKey
|
||||
* in: cookie
|
||||
* name: nachklang.session_token
|
||||
* description: >
|
||||
* Set by /admin/auth/sign-in/email. Over https the name is
|
||||
* __Secure-nachklang.session_token and the cookie is scoped to
|
||||
* .nachklang.art, so one sign-in covers every *.nachklang.art app.
|
||||
* schemas:
|
||||
* AdminApp:
|
||||
* type: string
|
||||
* enum: [calendar, feedback, tickets, admin]
|
||||
* description: Holding "admin" is what allows managing users and invitations.
|
||||
* AdminMe:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* fullName:
|
||||
* type: string
|
||||
* apps:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AdminApp'
|
||||
* AdminUserSession:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* createdAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* expiresAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* ipAddress:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* userAgent:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* AdminUser:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* apps:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AdminApp'
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [aktiv, deaktiviert]
|
||||
* description: Derived - there is no status column.
|
||||
* createdAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* lastSignInAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* nullable: true
|
||||
* description: Newest session's creation time; null once every session has expired.
|
||||
* AdminUserDetail:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/AdminUser'
|
||||
* - type: object
|
||||
* properties:
|
||||
* sessions:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AdminUserSession'
|
||||
* passkeyCount:
|
||||
* type: integer
|
||||
* AdminInvitation:
|
||||
* type: object
|
||||
* description: An open invitation. The token itself is never returned by any endpoint.
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* email:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* apps:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AdminApp'
|
||||
* invitedBy:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: Null for the invitation created by the ADMIN_BOOTSTRAP_EMAIL bootstrap.
|
||||
* createdAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* expiresAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,124 @@
|
||||
import {MailService} from '../../common/common.mail.js';
|
||||
import {ADMIN_APP_URL} from './admin.config.js';
|
||||
|
||||
/**
|
||||
* The two transactional mails the admin module sends. Both go out through the
|
||||
* shared MailService (Salesforce relay, see common.mail.ts), which never throws
|
||||
* on a delivery failure - the invitation row and the reset token are already
|
||||
* committed by the time we get here.
|
||||
*
|
||||
* HTML plus a plain-text body: the text part is not a fallback afterthought,
|
||||
* it is what allowlist-based receivers and text-only clients actually show.
|
||||
*/
|
||||
|
||||
const escapeHtml = (value: string): string => {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
};
|
||||
|
||||
// `heading` is escaped here; `paragraphs` are not, because callers pass markup
|
||||
// (a <strong> around the expiry date) and escape their own interpolations.
|
||||
const layout = (heading: string, paragraphs: string[], buttonLabel: string, buttonUrl: string): string => {
|
||||
const body = paragraphs.map(p => `<p style="margin:0 0 16px;">${p}</p>`).join('');
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<body style="margin:0;padding:24px;background:#f5f5f4;font-family:Helvetica,Arial,sans-serif;color:#1c1917;">
|
||||
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
|
||||
<h1 style="margin:0 0 24px;font-size:20px;">${escapeHtml(heading)}</h1>
|
||||
${body}
|
||||
<p style="margin:24px 0;">
|
||||
<a href="${escapeHtml(buttonUrl)}" style="display:inline-block;background:#1c1917;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:6px;">${escapeHtml(buttonLabel)}</a>
|
||||
</p>
|
||||
<p style="margin:0;font-size:13px;color:#57534e;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
|
||||
<span style="word-break:break-all;">${escapeHtml(buttonUrl)}</span></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Invitation mail. The link carries the raw token in the query string; the
|
||||
* admin app strips it from the URL as soon as it has read it (see the plan's
|
||||
* §3b - the token must never reach an API access log or a Referer header).
|
||||
*/
|
||||
export const sendInvitationMail = async (
|
||||
recipientAddress: string,
|
||||
name: string,
|
||||
token: string,
|
||||
expiresAt: Date
|
||||
): Promise<boolean> => {
|
||||
const url = `${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`;
|
||||
const expiry = expiresAt.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
|
||||
const subject = 'Dein Zugang zu Nachklang';
|
||||
|
||||
const text = [
|
||||
`Hallo ${name},`,
|
||||
'',
|
||||
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über diesen Link vergibst du dein Passwort:',
|
||||
'',
|
||||
url,
|
||||
'',
|
||||
`Der Link ist bis zum ${expiry} gültig.`,
|
||||
'',
|
||||
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.',
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Nachklang e.V.'
|
||||
].join('\n');
|
||||
|
||||
const html = layout(
|
||||
`Hallo ${name},`,
|
||||
[
|
||||
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über den Button vergibst du dein Passwort.',
|
||||
`Der Link ist bis zum <strong>${escapeHtml(expiry)}</strong> gültig.`,
|
||||
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.'
|
||||
],
|
||||
'Konto einrichten',
|
||||
url
|
||||
);
|
||||
|
||||
return MailService.sendMail(recipientAddress, subject, text, {html});
|
||||
};
|
||||
|
||||
/**
|
||||
* Password reset. better-auth builds the URL (it embeds its own token and the
|
||||
* redirectTo the admin app passed), so this only wraps it in our templates.
|
||||
*/
|
||||
export const sendPasswordResetMail = async (
|
||||
recipientAddress: string,
|
||||
name: string,
|
||||
url: string
|
||||
): Promise<boolean> => {
|
||||
const subject = 'Passwort zurücksetzen';
|
||||
|
||||
const text = [
|
||||
`Hallo ${name},`,
|
||||
'',
|
||||
'über diesen Link kannst du ein neues Passwort vergeben:',
|
||||
'',
|
||||
url,
|
||||
'',
|
||||
'Der Link ist eine Stunde gültig.',
|
||||
'',
|
||||
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.',
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Nachklang e.V.'
|
||||
].join('\n');
|
||||
|
||||
const html = layout(
|
||||
`Hallo ${name},`,
|
||||
[
|
||||
'über den Button kannst du ein neues Passwort vergeben.',
|
||||
'Der Link ist eine Stunde gültig.',
|
||||
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.'
|
||||
],
|
||||
'Neues Passwort vergeben',
|
||||
url
|
||||
);
|
||||
|
||||
return MailService.sendMail(recipientAddress, subject, text, {html});
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import express from 'express';
|
||||
import {fromNodeHeaders} from 'better-auth/node';
|
||||
import {auth} from './admin.auth.js';
|
||||
import * as UsersService from './users/users.admin.service.js';
|
||||
import {AppName} from './admin.schema.js';
|
||||
import {sendServerError} from './admin.errors.js';
|
||||
|
||||
/**
|
||||
* The one authenticator for every admin area in this API. It replaces
|
||||
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
|
||||
* header-session check against the calendar users table.
|
||||
*
|
||||
* Two things are checked on every request, deliberately without any caching:
|
||||
* that the session cookie is valid (better-auth), and that the user is still
|
||||
* enabled and still holds the permission for this app (one database query).
|
||||
* That is what makes "disable a user" and "revoke a session" take effect
|
||||
* immediately rather than whenever a cached session happens to expire.
|
||||
*/
|
||||
|
||||
// The shape the feedback and tickets services already expect - unchanged, so
|
||||
// nothing downstream of the authenticator needs to know this file replaced
|
||||
// their own.
|
||||
export interface AdminIdentity {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface AdminAccess extends AdminIdentity {
|
||||
disabled: boolean;
|
||||
apps: AppName[];
|
||||
}
|
||||
|
||||
const unauthorized = (res: express.Response): void => {
|
||||
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
|
||||
};
|
||||
|
||||
const forbidden = (res: express.Response, message: string): void => {
|
||||
res.status(403).send({status: 'FORBIDDEN', message});
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the session cookie to a user with their permissions, or null.
|
||||
* One database query, no cache. Throws only on infrastructure errors.
|
||||
*/
|
||||
export const resolveAccess = async (req: express.Request): Promise<AdminAccess | null> => {
|
||||
const session = await auth.api.getSession({headers: fromNodeHeaders(req.headers)});
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const access = await UsersService.loadAccess(session.user.id);
|
||||
if (!access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: access.id,
|
||||
email: access.email,
|
||||
displayName: access.displayName,
|
||||
disabled: access.disabled,
|
||||
apps: access.apps
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Any signed-in account, no permission required. Used by /admin/me and the
|
||||
* account-management routes: a user with no app permissions at all still has
|
||||
* to be able to see that, and to manage their own password and passkeys.
|
||||
*
|
||||
* The disabled check is not redundant with the session-create hook: that hook
|
||||
* stops a disabled user from signing in, this stops one who was disabled while
|
||||
* holding a live cookie. Disabling revokes sessions, so the window is small -
|
||||
* but "small" is not "closed".
|
||||
*/
|
||||
export const requireSignedIn: express.RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const access = await resolveAccess(req);
|
||||
if (!access) {
|
||||
unauthorized(res);
|
||||
return;
|
||||
}
|
||||
if (access.disabled) {
|
||||
forbidden(res, 'Dieses Konto ist deaktiviert.');
|
||||
return;
|
||||
}
|
||||
|
||||
res.locals.admin = access;
|
||||
next();
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The gate every admin area sits behind. `requireAppAccess('feedback')` is
|
||||
* what feedback.auth.ts's requireAdminAuth used to be, except that it now
|
||||
* answers 403 for a signed-in user without that app's permission instead of
|
||||
* letting any activated @nachklang.art account in.
|
||||
*/
|
||||
export const requireAppAccess = (app: AppName): express.RequestHandler => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const access = await resolveAccess(req);
|
||||
if (!access) {
|
||||
unauthorized(res);
|
||||
return;
|
||||
}
|
||||
if (access.disabled) {
|
||||
forbidden(res, 'Dieses Konto ist deaktiviert.');
|
||||
return;
|
||||
}
|
||||
if (!access.apps.includes(app)) {
|
||||
forbidden(res, 'Für diesen Bereich fehlt dir die Berechtigung.');
|
||||
return;
|
||||
}
|
||||
|
||||
res.locals.admin = access;
|
||||
next();
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import {Generated} from 'kysely';
|
||||
|
||||
/**
|
||||
* Kysely table types for `nachklang_admin`. Only the columns this module
|
||||
* actually reads or writes are declared - better-auth owns the full shape of
|
||||
* its own tables and does not use this interface, it is here so the users and
|
||||
* invitations services get compile-time checking instead of `any`.
|
||||
*
|
||||
* Column names follow better-auth's default "camel" casing for its tables
|
||||
* (`emailVerified`, `userId`, `createdAt`); our own two tables use the
|
||||
* snake_case convention of the rest of the repo's SQL.
|
||||
*/
|
||||
|
||||
export type AppName = 'calendar' | 'feedback' | 'tickets' | 'admin';
|
||||
|
||||
export const APP_NAMES: AppName[] = ['calendar', 'feedback', 'tickets', 'admin'];
|
||||
|
||||
export const isAppName = (value: unknown): value is AppName => {
|
||||
return typeof value === 'string' && (APP_NAMES as string[]).includes(value);
|
||||
};
|
||||
|
||||
export interface UserTable {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
// Added via better-auth `additionalFields` (see admin.auth.ts).
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface SessionTable {
|
||||
id: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
export interface PasskeyTable {
|
||||
id: string;
|
||||
name: string | null;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface UserAppPermissionTable {
|
||||
user_id: string;
|
||||
app: AppName;
|
||||
role: string;
|
||||
granted_by: string | null;
|
||||
granted_at: Generated<Date>;
|
||||
}
|
||||
|
||||
export interface InvitationTable {
|
||||
// AUTO_INCREMENT: present on select, never supplied on insert.
|
||||
id: Generated<number>;
|
||||
email: string;
|
||||
name: string;
|
||||
token_hash: string;
|
||||
// JSON column holding an AppName[].
|
||||
apps: string;
|
||||
invited_by: string | null;
|
||||
created_at: Generated<Date>;
|
||||
expires_at: Date;
|
||||
accepted_at: Date | null;
|
||||
revoked_at: Date | null;
|
||||
}
|
||||
|
||||
export interface AdminDatabase {
|
||||
user: UserTable;
|
||||
session: SessionTable;
|
||||
passkey: PasskeyTable;
|
||||
user_app_permissions: UserAppPermissionTable;
|
||||
invitations: InvitationTable;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as z from 'zod';
|
||||
import {APIError, createAuthEndpoint} from 'better-auth/api';
|
||||
import {setSessionCookie} from 'better-auth/cookies';
|
||||
import {createLocalAccountIssuer} from 'better-auth/db';
|
||||
import type {BetterAuthPlugin} from 'better-auth';
|
||||
import * as InvitationsService from './invitations.service.js';
|
||||
import * as UsersService from '../users/users.admin.service.js';
|
||||
import logger from '../../../middleware/logger.js';
|
||||
|
||||
/**
|
||||
* The two public endpoints of the invitation flow, implemented as a better-auth
|
||||
* plugin rather than as plain Express routes on the admin router.
|
||||
*
|
||||
* Why a plugin: `emailAndPassword.disableSignUp` is on, which makes
|
||||
* `auth.api.signUpEmail` refuse - deliberately, there is no public sign-up.
|
||||
* Accepting an invitation still has to create a user, hash a password, write a
|
||||
* credential account and sign the person in. All four are better-auth
|
||||
* internals reachable only from inside an endpoint's context, so this is where
|
||||
* account creation lives. Nothing outside this file may create users.
|
||||
*
|
||||
* Because they are plugin endpoints they sit under better-auth's basePath:
|
||||
* POST /admin/auth/invitations/preview
|
||||
* POST /admin/auth/invitations/accept
|
||||
*
|
||||
* The token travels in the request *body*, never in the path or query, so it
|
||||
* cannot end up in an access log or a Referer header.
|
||||
*/
|
||||
|
||||
// Unknown, expired, revoked and already-accepted tokens must be
|
||||
// indistinguishable to the caller: one shared error, one shared message.
|
||||
const invalidToken = (): APIError => {
|
||||
return new APIError('BAD_REQUEST', {
|
||||
code: 'INVALID_INVITATION',
|
||||
message: 'Diese Einladung ist nicht mehr gültig.'
|
||||
});
|
||||
};
|
||||
|
||||
export const invitationsPlugin = () => {
|
||||
return {
|
||||
id: 'nachklang-invitations',
|
||||
endpoints: {
|
||||
/**
|
||||
* Lets the accept-invite page show who the invitation is for before
|
||||
* asking for a password. Returns only name and email - never the
|
||||
* granted apps, which is information the invitee has no need for
|
||||
* and an attacker with a stolen link should not get either.
|
||||
*/
|
||||
previewInvitation: createAuthEndpoint(
|
||||
'/invitations/preview',
|
||||
{
|
||||
method: 'POST',
|
||||
body: z.object({
|
||||
token: z.string().min(1)
|
||||
})
|
||||
},
|
||||
async ctx => {
|
||||
const invitation = await InvitationsService.findByToken(ctx.body.token);
|
||||
if (!invitation) {
|
||||
throw invalidToken();
|
||||
}
|
||||
|
||||
return ctx.json({email: invitation.email, name: invitation.name});
|
||||
}
|
||||
),
|
||||
|
||||
/**
|
||||
* Redeems the invitation: creates the user, its credential account
|
||||
* and its permissions, then signs the person straight in so they
|
||||
* land in the app instead of on a login form.
|
||||
*/
|
||||
acceptInvitation: createAuthEndpoint(
|
||||
'/invitations/accept',
|
||||
{
|
||||
method: 'POST',
|
||||
body: z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128)
|
||||
})
|
||||
},
|
||||
async ctx => {
|
||||
const invitation = await InvitationsService.findByToken(ctx.body.token);
|
||||
if (!invitation) {
|
||||
throw invalidToken();
|
||||
}
|
||||
|
||||
// An account for this address already exists: the right fix
|
||||
// is for an admin to grant permissions on the existing user,
|
||||
// not to create a second one. Reported distinctly because
|
||||
// the person holds a valid token - this leaks nothing they
|
||||
// do not already know about their own mailbox.
|
||||
const existing = await UsersService.findUserByEmail(invitation.email);
|
||||
if (existing) {
|
||||
throw new APIError('CONFLICT', {
|
||||
code: 'USER_ALREADY_EXISTS',
|
||||
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Melde dich stattdessen an.'
|
||||
});
|
||||
}
|
||||
|
||||
// Claim the invitation before creating anything. The update
|
||||
// is conditional on it still being open, so two concurrent
|
||||
// submissions of the same link cannot both end up creating a
|
||||
// user.
|
||||
const claimed = await InvitationsService.markAccepted(invitation.id);
|
||||
if (!claimed) {
|
||||
throw invalidToken();
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await ctx.context.internalAdapter.createUser(
|
||||
{
|
||||
email: invitation.email,
|
||||
name: invitation.name,
|
||||
// Accepting a link sent to that mailbox *is* the
|
||||
// proof of address ownership, so there is no
|
||||
// separate verification mail (plan decision 15).
|
||||
emailVerified: true,
|
||||
disabled: false
|
||||
},
|
||||
{method: 'email-password'}
|
||||
);
|
||||
|
||||
// Same call better-auth's own sign-up route makes, down to
|
||||
// the synthetic issuer - a credential account written any
|
||||
// other way would not be found on sign-in.
|
||||
await ctx.context.internalAdapter.linkAccount({
|
||||
userId: user.id,
|
||||
providerId: 'credential',
|
||||
issuer: createLocalAccountIssuer('credential'),
|
||||
accountId: user.id,
|
||||
password: await ctx.context.password.hash(ctx.body.password)
|
||||
});
|
||||
|
||||
await UsersService.setPermissions(user.id, invitation.apps, null);
|
||||
|
||||
const session = await ctx.context.internalAdapter.createSession(user.id);
|
||||
await setSessionCookie(ctx, {session, user});
|
||||
|
||||
return ctx.json({
|
||||
user: {id: user.id, email: user.email, name: user.name}
|
||||
});
|
||||
} catch (e: any) {
|
||||
// The invitation is already marked accepted at this
|
||||
// point. Leaving it that way is deliberate: a token that
|
||||
// has been through a half-completed account creation
|
||||
// should not stay usable. The admin can send a new
|
||||
// invitation, and this log says why one is needed.
|
||||
logger.error('Admin: invitation accepted but account creation failed', {
|
||||
invitationId: invitation.id,
|
||||
detail: e?.message
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} satisfies BetterAuthPlugin;
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as InvitationsService from './invitations.service.js';
|
||||
import * as UsersService from '../users/users.admin.service.js';
|
||||
import {isAppName, AppName} from '../admin.schema.js';
|
||||
import {sendInvitationMail} from '../admin.mail.js';
|
||||
import {ADMIN_APP_URL} from '../admin.config.js';
|
||||
import {sendServerError} from '../admin.errors.js';
|
||||
import logger from '../../../middleware/logger.js';
|
||||
import {isProd} from '../admin.config.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@]+$/;
|
||||
|
||||
/**
|
||||
* Outside production the Salesforce relay is normally off, so the invitation
|
||||
* mail never arrives and only the token's hash is stored - there would be no
|
||||
* way to walk through the accept flow locally. Logging the link closes that,
|
||||
* and mirrors what the bootstrap already does. Never in production: the log
|
||||
* would then hold a live credential.
|
||||
*/
|
||||
const logInviteLinkInDev = (token: string): void => {
|
||||
if (!isProd) {
|
||||
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, apps]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* apps:
|
||||
* type: array
|
||||
* items:
|
||||
* 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();
|
||||
const apps: unknown = req.body?.apps;
|
||||
|
||||
if (!EMAIL_PATTERN.test(email) || name.length === 0 || !Array.isArray(apps) || !apps.every(isAppName)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'E-Mail, Name und App-Liste 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,
|
||||
apps as AppName[],
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import * as crypto from 'crypto';
|
||||
import {NachklangAdminDB} from '../Admin.db.js';
|
||||
import {AppName, APP_NAMES, isAppName} from '../admin.schema.js';
|
||||
|
||||
const db = NachklangAdminDB.db;
|
||||
|
||||
/**
|
||||
* Invitations are this API's only path to a new account (there is no public
|
||||
* sign-up). The raw token exists exactly twice: in the mail we send and in the
|
||||
* request body when it comes back. What we store is its SHA-256 hash, so a
|
||||
* database dump does not hand out account access - the same reasoning as the
|
||||
* calendar module's session key hashing, and the reason lookups go through
|
||||
* `findByToken` rather than any query on a plaintext column.
|
||||
*/
|
||||
|
||||
export const INVITATION_TTL_DAYS = 7;
|
||||
|
||||
export interface OpenInvitation {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
apps: AppName[];
|
||||
invitedBy: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface AcceptableInvitation {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
apps: AppName[];
|
||||
}
|
||||
|
||||
const hashToken = (token: string): string => {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
};
|
||||
|
||||
const generateToken = (): string => {
|
||||
// 32 bytes, url-safe: it travels in a mail link's query string.
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
};
|
||||
|
||||
const parseApps = (value: unknown): AppName[] => {
|
||||
// mysql2 hands back a JSON column already parsed; a driver or column-type
|
||||
// change that turns it into a string must not break the read path.
|
||||
const raw = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
return Array.isArray(raw) ? raw.filter(isAppName) : [];
|
||||
};
|
||||
|
||||
const expiryFromNow = (): Date => {
|
||||
return new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an invitation and returns the raw token for the mail. Any earlier
|
||||
* open invitation for the same address is revoked first: two valid links for
|
||||
* one mailbox is a needless second live credential, and "resend" would
|
||||
* otherwise quietly accumulate them.
|
||||
*/
|
||||
export const createInvitation = async (
|
||||
email: string,
|
||||
name: string,
|
||||
apps: AppName[],
|
||||
invitedBy: string | null
|
||||
): Promise<{id: number; token: string; expiresAt: Date}> => {
|
||||
const token = generateToken();
|
||||
const expiresAt = expiryFromNow();
|
||||
const validApps = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app));
|
||||
|
||||
const id = await db.transaction().execute(async trx => {
|
||||
await trx
|
||||
.updateTable('invitations')
|
||||
.set({revoked_at: new Date()})
|
||||
.where('email', '=', email)
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.execute();
|
||||
|
||||
const result = await trx
|
||||
.insertInto('invitations')
|
||||
.values({
|
||||
email,
|
||||
name,
|
||||
token_hash: hashToken(token),
|
||||
apps: JSON.stringify(validApps),
|
||||
invited_by: invitedBy,
|
||||
created_at: new Date(),
|
||||
expires_at: expiresAt
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.insertId);
|
||||
});
|
||||
|
||||
return {id, token, expiresAt};
|
||||
};
|
||||
|
||||
/**
|
||||
* Looks up a still-usable invitation by raw token. Callers must not
|
||||
* distinguish "unknown", "expired", "revoked" and "already accepted" to the
|
||||
* client: all four answer with the same shape, so a stranger cannot probe which
|
||||
* tokens ever existed.
|
||||
*/
|
||||
export const findByToken = async (token: string): Promise<AcceptableInvitation | null> => {
|
||||
const row = await db
|
||||
.selectFrom('invitations')
|
||||
.select(['id', 'email', 'name', 'apps'])
|
||||
.where('token_hash', '=', hashToken(token))
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.where('expires_at', '>', new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {id: row.id, email: row.email, name: row.name, apps: parseApps(row.apps)};
|
||||
};
|
||||
|
||||
/** Marks the invitation accepted. Conditional on it still being open so two
|
||||
* concurrent accepts of the same link cannot both create an account. */
|
||||
export const markAccepted = async (invitationId: number): Promise<boolean> => {
|
||||
const result = await db
|
||||
.updateTable('invitations')
|
||||
.set({accepted_at: new Date()})
|
||||
.where('id', '=', invitationId)
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.numUpdatedRows) > 0;
|
||||
};
|
||||
|
||||
export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
|
||||
const rows = await db
|
||||
.selectFrom('invitations')
|
||||
.select(['id', 'email', 'name', 'apps', 'invited_by', 'created_at', 'expires_at'])
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.where('expires_at', '>', new Date())
|
||||
.orderBy('created_at', 'desc')
|
||||
.execute();
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
apps: parseApps(row.apps),
|
||||
invitedBy: row.invited_by,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at
|
||||
}));
|
||||
};
|
||||
|
||||
export const getOpenInvitation = async (invitationId: number): Promise<OpenInvitation | null> => {
|
||||
const all = await listOpenInvitations();
|
||||
return all.find(invitation => invitation.id === invitationId) ?? null;
|
||||
};
|
||||
|
||||
/** Resend issues a *new* token and expiry and invalidates the old one, rather
|
||||
* than re-mailing the existing link: if the first mail leaked, resending it
|
||||
* would extend the leak's lifetime. */
|
||||
export const resendInvitation = async (
|
||||
invitationId: number
|
||||
): Promise<{token: string; email: string; name: string; expiresAt: Date} | null> => {
|
||||
const invitation = await getOpenInvitation(invitationId);
|
||||
if (!invitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = expiryFromNow();
|
||||
|
||||
await db
|
||||
.updateTable('invitations')
|
||||
.set({token_hash: hashToken(token), expires_at: expiresAt, created_at: new Date()})
|
||||
.where('id', '=', invitationId)
|
||||
.execute();
|
||||
|
||||
return {token, email: invitation.email, name: invitation.name, expiresAt};
|
||||
};
|
||||
|
||||
export const revokeInvitation = async (invitationId: number): Promise<boolean> => {
|
||||
const result = await db
|
||||
.updateTable('invitations')
|
||||
.set({revoked_at: new Date()})
|
||||
.where('id', '=', invitationId)
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.numUpdatedRows) > 0;
|
||||
};
|
||||
|
||||
/** Used by the bootstrap to stay idempotent across restarts. */
|
||||
export const hasOpenInvitationFor = async (email: string): Promise<boolean> => {
|
||||
const row = await db
|
||||
.selectFrom('invitations')
|
||||
.select('id')
|
||||
.where('email', '=', email)
|
||||
.where('accepted_at', 'is', null)
|
||||
.where('revoked_at', 'is', null)
|
||||
.where('expires_at', '>', new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
return Boolean(row);
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as UsersService from './users.admin.service.js';
|
||||
import {AppName, isAppName} 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:
|
||||
* apps:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* enum: [calendar, feedback, tickets, admin]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 400:
|
||||
* description: Invalid app name
|
||||
* 409:
|
||||
* description: Would lock the last admin out
|
||||
*/
|
||||
usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const apps: unknown = req.body?.apps;
|
||||
|
||||
if (!Array.isArray(apps) || !apps.every(isAppName)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige App-Liste.'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await UsersService.userExists(userId))) {
|
||||
notFound(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = await UsersService.loadAccess(userId);
|
||||
const losesAdmin = Boolean(target?.apps.includes('admin')) && !(apps as AppName[]).includes('admin');
|
||||
|
||||
if (losesAdmin && userId === res.locals.admin.id) {
|
||||
conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only an enabled admin counts; see countActiveAdmins.
|
||||
if (losesAdmin && !target?.disabled && (await UsersService.countActiveAdmins()) <= 1) {
|
||||
conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
await UsersService.setPermissions(userId, apps as AppName[], res.locals.admin.id);
|
||||
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;
|
||||
}
|
||||
|
||||
if (target.apps.includes('admin') && !target.disabled && (await UsersService.countActiveAdmins()) <= 1) {
|
||||
conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
await UsersService.disableUser(userId);
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import {NachklangAdminDB} from '../Admin.db.js';
|
||||
import {AppName, APP_NAMES} from '../admin.schema.js';
|
||||
|
||||
const db = NachklangAdminDB.db;
|
||||
|
||||
/**
|
||||
* Everything that reads or writes permissions. Two callers with very different
|
||||
* hot-path requirements share this file: admin.middleware.ts runs
|
||||
* `loadAccess` on *every* admin-authenticated request (which is why it is one
|
||||
* query joining `user.disabled` and the permission rows - see the plan's
|
||||
* decision to run without better-auth's cookieCache), and the /admin/users
|
||||
* routes run the rest.
|
||||
*/
|
||||
|
||||
export interface UserAccess {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
disabled: boolean;
|
||||
apps: AppName[];
|
||||
}
|
||||
|
||||
export type UserStatus = 'aktiv' | 'deaktiviert';
|
||||
|
||||
export interface UserListEntry {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
apps: AppName[];
|
||||
status: UserStatus;
|
||||
createdAt: Date;
|
||||
lastSignInAt: Date | null;
|
||||
}
|
||||
|
||||
export interface UserSessionEntry {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
export interface UserDetail extends UserListEntry {
|
||||
sessions: UserSessionEntry[];
|
||||
passkeyCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single per-request lookup behind requireAppAccess. Returns null when the
|
||||
* user row is gone; `disabled` is returned rather than filtered so the
|
||||
* middleware can answer 403 (account deactivated) instead of a misleading 401.
|
||||
*/
|
||||
export const loadAccess = async (userId: string): Promise<UserAccess | null> => {
|
||||
const rows = await db
|
||||
.selectFrom('user')
|
||||
.leftJoin('user_app_permissions', 'user_app_permissions.user_id', 'user.id')
|
||||
.where('user.id', '=', userId)
|
||||
.select([
|
||||
'user.id as id',
|
||||
'user.email as email',
|
||||
'user.name as name',
|
||||
'user.disabled as disabled',
|
||||
'user_app_permissions.app as app'
|
||||
])
|
||||
.execute();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: rows[0].id,
|
||||
email: rows[0].email,
|
||||
displayName: rows[0].name,
|
||||
// MySQL TINYINT(1) comes back as 0/1 through mysql2.
|
||||
disabled: Boolean(rows[0].disabled),
|
||||
apps: rows.map(row => row.app).filter((app): app is AppName => app !== null)
|
||||
};
|
||||
};
|
||||
|
||||
export const listUsers = async (): Promise<UserListEntry[]> => {
|
||||
const users = await db
|
||||
.selectFrom('user')
|
||||
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
|
||||
.orderBy('name', 'asc')
|
||||
.execute();
|
||||
|
||||
const permissions = await db
|
||||
.selectFrom('user_app_permissions')
|
||||
.select(['user_id', 'app'])
|
||||
.execute();
|
||||
|
||||
// Last sign-in is derived from the newest session rather than stored: a
|
||||
// session row is created on every sign-in and we never update its
|
||||
// createdAt, so max(createdAt) is exactly that, with no extra column to
|
||||
// keep in sync. Sessions are pruned on expiry, so this goes back to null
|
||||
// for someone who has not signed in for over 30 days.
|
||||
const lastSessions = await db
|
||||
.selectFrom('session')
|
||||
.select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')])
|
||||
.groupBy('userId')
|
||||
.execute();
|
||||
|
||||
const appsByUser = new Map<string, AppName[]>();
|
||||
for (const row of permissions) {
|
||||
const apps = appsByUser.get(row.user_id) || [];
|
||||
apps.push(row.app);
|
||||
appsByUser.set(row.user_id, apps);
|
||||
}
|
||||
|
||||
const lastSignInByUser = new Map<string, Date | null>(
|
||||
lastSessions.map(row => [row.userId, row.lastSignInAt as Date | null])
|
||||
);
|
||||
|
||||
return users.map(user => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
apps: appsByUser.get(user.id) || [],
|
||||
status: user.disabled ? 'deaktiviert' : 'aktiv',
|
||||
createdAt: user.createdAt,
|
||||
lastSignInAt: lastSignInByUser.get(user.id) ?? null
|
||||
}));
|
||||
};
|
||||
|
||||
export const getUserDetail = async (userId: string): Promise<UserDetail | null> => {
|
||||
const user = await db
|
||||
.selectFrom('user')
|
||||
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
|
||||
.where('id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [permissions, sessions, passkeys] = await Promise.all([
|
||||
db.selectFrom('user_app_permissions').select('app').where('user_id', '=', userId).execute(),
|
||||
db
|
||||
.selectFrom('session')
|
||||
.select(['id', 'createdAt', 'expiresAt', 'ipAddress', 'userAgent'])
|
||||
.where('userId', '=', userId)
|
||||
.where('expiresAt', '>', new Date())
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute(),
|
||||
db
|
||||
.selectFrom('passkey')
|
||||
.select(({fn}) => fn.countAll<number>().as('count'))
|
||||
.where('userId', '=', userId)
|
||||
.executeTakeFirst()
|
||||
]);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
apps: permissions.map(row => row.app),
|
||||
status: user.disabled ? 'deaktiviert' : 'aktiv',
|
||||
createdAt: user.createdAt,
|
||||
lastSignInAt: sessions.length > 0 ? sessions[0].createdAt : null,
|
||||
sessions,
|
||||
passkeyCount: Number(passkeys?.count ?? 0)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces a user's permission set. Written as delete-then-insert inside one
|
||||
* transaction rather than a diff: the set is at most four rows, and a diff
|
||||
* would only add branches for no measurable gain.
|
||||
*/
|
||||
export const setPermissions = async (
|
||||
userId: string,
|
||||
apps: AppName[],
|
||||
grantedBy: string | null
|
||||
): Promise<void> => {
|
||||
const unique = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app));
|
||||
|
||||
await db.transaction().execute(async trx => {
|
||||
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
|
||||
if (unique.length > 0) {
|
||||
await trx
|
||||
.insertInto('user_app_permissions')
|
||||
.values(unique.map(app => ({
|
||||
user_id: userId,
|
||||
app,
|
||||
role: 'admin',
|
||||
granted_by: grantedBy,
|
||||
granted_at: new Date()
|
||||
})))
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const grantPermission = async (
|
||||
userId: string,
|
||||
app: AppName,
|
||||
grantedBy: string | null
|
||||
): Promise<void> => {
|
||||
await db
|
||||
.insertInto('user_app_permissions')
|
||||
.values({user_id: userId, app, role: 'admin', granted_by: grantedBy, granted_at: new Date()})
|
||||
.onDuplicateKeyUpdate({role: 'admin'})
|
||||
.execute();
|
||||
};
|
||||
|
||||
/** Disabling revokes every session: a disabled user must lose access now, not
|
||||
* when their 30-day cookie happens to expire. */
|
||||
export const disableUser = async (userId: string): Promise<void> => {
|
||||
await db.transaction().execute(async trx => {
|
||||
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
|
||||
await trx.deleteFrom('session').where('userId', '=', userId).execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const enableUser = async (userId: string): Promise<void> => {
|
||||
await db.updateTable('user').set({disabled: false}).where('id', '=', userId).execute();
|
||||
};
|
||||
|
||||
export const revokeSession = async (userId: string, sessionId: string): Promise<boolean> => {
|
||||
const result = await db
|
||||
.deleteFrom('session')
|
||||
.where('id', '=', sessionId)
|
||||
.where('userId', '=', userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.numDeletedRows) > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guard input for the self-lockout rules: how many enabled users still hold the
|
||||
* `admin` permission. Disabled admins do not count - they cannot sign in, so
|
||||
* leaving only disabled admins is the same lockout as leaving none.
|
||||
*/
|
||||
export const countActiveAdmins = async (): Promise<number> => {
|
||||
const row = await db
|
||||
.selectFrom('user_app_permissions')
|
||||
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
|
||||
.where('user_app_permissions.app', '=', 'admin')
|
||||
.where('user.disabled', '=', false)
|
||||
.select(({fn}) => fn.countAll<number>().as('count'))
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(row?.count ?? 0);
|
||||
};
|
||||
|
||||
export const userExists = async (userId: string): Promise<boolean> => {
|
||||
const row = await db.selectFrom('user').select('id').where('id', '=', userId).executeTakeFirst();
|
||||
return Boolean(row);
|
||||
};
|
||||
|
||||
export const findUserByEmail = async (email: string): Promise<{id: string; email: string} | null> => {
|
||||
const row = await db
|
||||
.selectFrom('user')
|
||||
.select(['id', 'email'])
|
||||
.where('email', '=', email)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row ?? null;
|
||||
};
|
||||
Reference in New Issue
Block a user