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

@@ -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 || '{}') })) });