feat(invite-system): implement user invite functionality with registration mode control
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m24s
Build & Push Docker Image / build (release) Successful in 6m25s

This commit is contained in:
2026-03-01 12:53:45 +01:00
parent 8c39275615
commit df4666bb63
15 changed files with 516 additions and 38 deletions

View File

@@ -1,7 +1,9 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
import { log } from '../config/logger.js';
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
@@ -164,4 +166,98 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
}
});
// ── User Invite System ─────────────────────────────────────────────────────
// POST /api/admin/invites - Create and send an invite
router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const { email } = req.body;
if (!email || !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'A valid email address is required' });
}
const db = getDb();
// Check if user with this email already exists
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'A user with this email already exists' });
}
// Check if there's already a pending invite for this email
const existingInvite = await db.get(
'SELECT id FROM user_invites WHERE email = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[email.toLowerCase()]
);
if (existingInvite) {
return res.status(409).json({ error: 'There is already a pending invite for this email' });
}
const token = uuidv4();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
await db.run(
'INSERT INTO user_invites (token, email, created_by, expires_at) VALUES (?, ?, ?, ?)',
[token, email.toLowerCase(), req.user.id, expiresAt]
);
// Send invite email if SMTP is configured
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inviteUrl = `${baseUrl}/register?invite=${token}`;
// Load app name
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
const appName = brandingSetting?.value || 'Redlight';
if (isMailerConfigured()) {
try {
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName);
} catch (mailErr) {
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
}
}
res.status(201).json({ invite: { token, email: email.toLowerCase(), expiresAt, inviteUrl } });
} catch (err) {
log.admin.error(`Create invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be created' });
}
});
// GET /api/admin/invites - List all invites
router.get('/invites', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invites = await db.all(`
SELECT ui.id, ui.token, ui.email, ui.expires_at, ui.created_at, ui.used_at,
creator.name as created_by_name,
used_user.name as used_by_name
FROM user_invites ui
LEFT JOIN users creator ON creator.id = ui.created_by
LEFT JOIN users used_user ON used_user.id = ui.used_by
ORDER BY ui.created_at DESC
`);
res.json({ invites });
} catch (err) {
log.admin.error(`List invites error: ${err.message}`);
res.status(500).json({ error: 'Invites could not be loaded' });
}
});
// DELETE /api/admin/invites/:id - Delete an invite
router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const invite = await db.get('SELECT id FROM user_invites WHERE id = ?', [req.params.id]);
if (!invite) {
return res.status(404).json({ error: 'Invite not found' });
}
await db.run('DELETE FROM user_invites WHERE id = ?', [req.params.id]);
res.json({ message: 'Invite deleted' });
} catch (err) {
log.admin.error(`Delete invite error: ${err.message}`);
res.status(500).json({ error: 'Invite could not be deleted' });
}
});
export default router;

View File

@@ -112,7 +112,27 @@ const router = Router();
// POST /api/auth/register
router.post('/register', registerLimiter, async (req, res) => {
try {
const { username, display_name, email, password } = req.body;
const { username, display_name, email, password, invite_token } = req.body;
// Check registration mode
const db = getDb();
const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'");
const registrationMode = regModeSetting?.value || 'open';
let validatedInvite = null;
if (registrationMode === 'invite') {
if (!invite_token) {
return res.status(403).json({ error: 'Registration is currently invite-only. You need an invitation link to register.' });
}
// Validate the invite token
validatedInvite = await db.get(
'SELECT * FROM user_invites WHERE token = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP',
[invite_token]
);
if (!validatedInvite) {
return res.status(403).json({ error: 'Invalid or expired invitation link.' });
}
}
if (!username || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' });
@@ -138,7 +158,6 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
return res.status(409).json({ error: 'Email is already in use' });
@@ -161,6 +180,14 @@ router.post('/register', registerLimiter, async (req, res) => {
[username, display_name, email.toLowerCase(), hash, verificationToken, expires]
);
// Mark invite as used if applicable
if (validatedInvite) {
const newUser = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (newUser) {
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [newUser.id, validatedInvite.id]);
}
}
// Build verification URL
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
@@ -189,6 +216,11 @@ router.post('/register', registerLimiter, async (req, res) => {
[username, display_name, email.toLowerCase(), hash]
);
// Mark invite as used if applicable
if (validatedInvite) {
await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastInsertRowid, validatedInvite.id]);
}
const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]);

View File

@@ -82,11 +82,14 @@ router.get('/', async (req, res) => {
const defaultTheme = await getSetting('default_theme');
const logoFile = findLogoFile();
const registrationMode = await getSetting('registration_mode');
res.json({
appName: appName || 'Redlight',
hasLogo: !!logoFile,
logoUrl: logoFile ? '/api/branding/logo' : null,
defaultTheme: defaultTheme || null,
registrationMode: registrationMode || 'open',
});
} catch (err) {
log.branding.error('Get branding error:', err);
@@ -192,4 +195,19 @@ router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) =
}
});
// PUT /api/branding/registration-mode - Set registration mode (admin only)
router.put('/registration-mode', authenticateToken, requireAdmin, async (req, res) => {
try {
const { registrationMode } = req.body;
if (!registrationMode || !['open', 'invite'].includes(registrationMode)) {
return res.status(400).json({ error: 'registrationMode must be "open" or "invite"' });
}
await setSetting('registration_mode', registrationMode);
res.json({ registrationMode });
} catch (err) {
log.branding.error('Update registration mode error:', err);
res.status(500).json({ error: 'Could not update registration mode' });
}
});
export default router;

View File

@@ -39,7 +39,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation',
public_key: getPublicKey(),
software: 'Redlight',
version: '1.2.1',
version: '1.3.0',
});
}