feat: add invite registration links with configurable usage limits

Admins can create one-time registration links (1–5× or unlimited uses)
with optional expiry (1d–14d or never). Recipients can register even
when public registration is disabled. Atomic usage counting prevents
race conditions, all endpoints are rate-limited.
This commit is contained in:
Maurice
2026-03-29 12:49:15 +02:00
parent d909aac751
commit 99514ddce1
15 changed files with 388 additions and 13 deletions

View File

@@ -205,6 +205,17 @@ function runMigrations(db: Database.Database): void {
try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {}
},
() => {
db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
},
];
if (currentVersion < migrations.length) {

View File

@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
@@ -221,6 +222,50 @@ router.post('/update', async (_req: Request, res: Response) => {
}
});
// ── Invite Tokens ───────────────────────────────────────────────────────────
router.get('/invites', (_req: Request, res: Response) => {
const invites = db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
ORDER BY i.created_at DESC
`).all();
res.json({ invites });
});
router.post('/invites', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { max_uses, expires_in_days } = req.body;
const rawUses = parseInt(max_uses);
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
const token = crypto.randomBytes(16).toString('hex');
const expiresAt = expires_in_days
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
: null;
db.prepare(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
).run(token, uses, expiresAt, authReq.user.id);
const invite = db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
WHERE i.id = last_insert_rowid()
`).get();
res.status(201).json({ invite });
});
router.delete('/invites/:id', (_req: Request, res: Response) => {
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id);
if (!invite) return res.status(404).json({ error: 'Invite not found' });
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id);
res.json({ success: true });
});
router.get('/addons', (_req: Request, res: Response) => {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) });

View File

@@ -128,14 +128,33 @@ router.post('/demo-login', (_req: Request, res: Response) => {
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
});
// Validate invite token (public, no auth needed, rate limited)
router.get('/invite/:token', authLimiter, (req: Request, res: Response) => {
const invite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(req.params.token) as any;
if (!invite) return res.status(404).json({ error: 'Invalid invite link' });
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' });
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' });
res.json({ valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at });
});
router.post('/register', authLimiter, (req: Request, res: Response) => {
const { username, email, password } = req.body;
const { username, email, password, invite_token } = req.body;
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0 && isOidcOnlyMode()) {
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
// Check invite token first — valid token bypasses registration restrictions
let validInvite: any = null;
if (invite_token) {
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(invite_token);
if (!validInvite) return res.status(400).json({ error: 'Invalid invite link' });
if (validInvite.used_count >= validInvite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' });
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' });
}
if (userCount > 0) {
if (userCount > 0 && !validInvite) {
if (isOidcOnlyMode()) {
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
}
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' });
@@ -177,6 +196,17 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
const user = { id: result.lastInsertRowid, username, email, role, avatar: null };
const token = generateToken(user);
// Atomically increment invite token usage (prevents race condition)
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count'
).get(validInvite.id);
if (!updated) {
// Race condition: token was used up between check and now — user was already created, so just log it
console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`);
}
}
res.status(201).json({ token, user: { ...user, avatar_url: null } });
} catch (err: unknown) {
res.status(500).json({ error: 'Error creating user' });