feat(invite-system): implement user invite functionality with registration mode control
This commit is contained in:
@@ -405,17 +405,52 @@ export async function initDatabase() {
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Default admin ───────────────────────────────────────────────────────
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
// User invite tokens (invite-only registration)
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
used_at TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_by INTEGER NOT NULL,
|
||||
used_by INTEGER,
|
||||
used_at DATETIME,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_token ON user_invites(token);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Default admin (only on very first start) ────────────────────────────
|
||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||
if (!adminAlreadySeeded) {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
const existingAdmin = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
|
||||
if (!existingAdmin) {
|
||||
const hash = bcrypt.hashSync(adminPassword, 12);
|
||||
await db.run(
|
||||
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
|
||||
['Administrator', 'Administrator', adminEmail, hash, 'admin']
|
||||
);
|
||||
// Mark as seeded so it never runs again, even if the admin email is changed
|
||||
await db.run("INSERT INTO settings (key, value) VALUES ('admin_seeded', '1')");
|
||||
log.db.info(`Default admin created: ${adminEmail}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,3 +139,46 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
||||
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n– ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user registration invite email.
|
||||
* @param {string} to – recipient email
|
||||
* @param {string} inviteUrl – full invite registration URL
|
||||
* @param {string} appName – branding app name (default "Redlight")
|
||||
*/
|
||||
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') {
|
||||
if (!transporter) {
|
||||
throw new Error('SMTP not configured');
|
||||
}
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const headerAppName = sanitizeHeaderValue(appName);
|
||||
const safeAppName = escapeHtml(appName);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${headerAppName}" <${from}>`,
|
||||
to,
|
||||
subject: `${headerAppName} – You've been invited`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
|
||||
<h2 style="color:#cba6f7;margin-top:0;">You've been invited! 🎉</h2>
|
||||
<p>You have been invited to create an account on <strong style="color:#cdd6f4;">${safeAppName}</strong>.</p>
|
||||
<p>Click the button below to register:</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inviteUrl}"
|
||||
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
|
||||
Create Account
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">
|
||||
Or copy this link in your browser:<br/>
|
||||
<a href="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">This link is valid for 7 days.</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
<p style="font-size:12px;color:#585b70;">If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `You've been invited to create an account on ${appName}.\n\nRegister here: ${inviteUrl}\n\nThis link is valid for 7 days.\n\n– ${appName}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user