diff --git a/package-lock.json b/package-lock.json index a6ac2db..258d3c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "redlight", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "redlight", - "version": "1.2.1", + "version": "1.3.0", "dependencies": { "axios": "^1.7.0", "bcryptjs": "^2.4.3", diff --git a/package.json b/package.json index 56cbb4f..57fc468 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "redlight", "private": true, - "version": "1.2.1", + "version": "1.3.0", "type": "module", "scripts": { "dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"", diff --git a/server/config/database.js b/server/config/database.js index f653082..3a96311 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -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}`); } } diff --git a/server/config/mailer.js b/server/config/mailer.js index f943ec6..edc114f 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -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: ` +
+

You've been invited! 🎉

+

You have been invited to create an account on ${safeAppName}.

+

Click the button below to register:

+

+ + Create Account + +

+

+ Or copy this link in your browser:
+ ${escapeHtml(inviteUrl)} +

+

This link is valid for 7 days.

+
+

If you didn't expect this invitation, you can safely ignore this email.

+
+ `, + 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}`, + }); +} diff --git a/server/routes/admin.js b/server/routes/admin.js index 9a241a2..c3708c7 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -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; diff --git a/server/routes/auth.js b/server/routes/auth.js index 75bb024..aecb2f2 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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]); diff --git a/server/routes/branding.js b/server/routes/branding.js index e6c5735..fb3a84d 100644 --- a/server/routes/branding.js +++ b/server/routes/branding.js @@ -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; diff --git a/server/routes/federation.js b/server/routes/federation.js index 871193f..c166dad 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -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', }); } diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index f0c234c..41fe1c0 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -28,8 +28,10 @@ export function AuthProvider({ children }) { return res.data.user; }, []); - const register = useCallback(async (username, displayName, email, password) => { - const res = await api.post('/auth/register', { username, display_name: displayName, email, password }); + const register = useCallback(async (username, displayName, email, password, inviteToken) => { + const payload = { username, display_name: displayName, email, password }; + if (inviteToken) payload.invite_token = inviteToken; + const res = await api.post('/auth/register', payload); if (res.data.needsVerification) { return { needsVerification: true }; } diff --git a/src/i18n/de.json b/src/i18n/de.json index f58df63..4b8e247 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -86,7 +86,9 @@ "emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten", "emailVerificationResendCooldown": "Erneut senden in {seconds}s", "emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!", - "emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden" + "emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden", + "inviteOnly": "Nur mit Einladung", + "inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen." }, "home": { "poweredBy": "Powered by BigBlueButton", @@ -333,7 +335,26 @@ "defaultThemeLabel": "Standard-Theme", "defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.", "defaultThemeSaved": "Standard-Theme gespeichert", - "defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden" + "defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden", + "regModeTitle": "Registrierungsmodus", + "regModeDescription": "Steuern Sie, wie sich neue Benutzer registrieren können. \"Offen\" erlaubt jedem die Anmeldung. \"Nur mit Einladung\" erfordert einen Einladungslink.", + "regModeOpen": "Offene Registrierung", + "regModeInvite": "Nur mit Einladung", + "regModeSaved": "Registrierungsmodus aktualisiert", + "regModeFailed": "Registrierungsmodus konnte nicht aktualisiert werden", + "inviteTitle": "Benutzer-Einladungen", + "inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.", + "sendInvite": "Einladung senden", + "inviteSent": "Einladung gesendet!", + "inviteFailed": "Einladung konnte nicht gesendet werden", + "inviteDeleted": "Einladung gelöscht", + "inviteDeleteFailed": "Einladung konnte nicht gelöscht werden", + "inviteLinkCopied": "Einladungslink kopiert!", + "copyInviteLink": "Einladungslink kopieren", + "inviteExpired": "Abgelaufen", + "inviteUsedBy": "Verwendet von", + "inviteExpiresAt": "Läuft ab am", + "noInvites": "Noch keine Einladungen" }, "federation": { "inbox": "Einladungen", diff --git a/src/i18n/en.json b/src/i18n/en.json index c6fc854..989ccbb 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -86,7 +86,9 @@ "emailVerificationResend": "Click here to receive a new verification email", "emailVerificationResendCooldown": "Resend in {seconds}s", "emailVerificationResendSuccess": "Verification email sent!", - "emailVerificationResendFailed": "Could not send verification email" + "emailVerificationResendFailed": "Could not send verification email", + "inviteOnly": "Invite Only", + "inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account." }, "home": { "poweredBy": "Powered by BigBlueButton", @@ -333,7 +335,26 @@ "defaultThemeLabel": "Default Theme", "defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.", "defaultThemeSaved": "Default theme saved", - "defaultThemeUpdateFailed": "Could not update default theme" + "defaultThemeUpdateFailed": "Could not update default theme", + "regModeTitle": "Registration Mode", + "regModeDescription": "Control how new users can register. \"Open\" allows everyone to sign up. \"Invite only\" requires an invitation link.", + "regModeOpen": "Open registration", + "regModeInvite": "Invite only", + "regModeSaved": "Registration mode updated", + "regModeFailed": "Could not update registration mode", + "inviteTitle": "User Invitations", + "inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.", + "sendInvite": "Send invite", + "inviteSent": "Invitation sent!", + "inviteFailed": "Could not send invitation", + "inviteDeleted": "Invitation deleted", + "inviteDeleteFailed": "Could not delete invitation", + "inviteLinkCopied": "Invite link copied!", + "copyInviteLink": "Copy invite link", + "inviteExpired": "Expired", + "inviteUsedBy": "Used by", + "inviteExpiresAt": "Expires", + "noInvites": "No invitations yet" }, "federation": { "inbox": "Invitations", diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx index 17b8dd1..22b8897 100644 --- a/src/pages/Admin.jsx +++ b/src/pages/Admin.jsx @@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom'; import { Users, Shield, Search, Trash2, ChevronDown, Loader2, MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User, - Upload, X as XIcon, Image, Type, Palette, + Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check, + ShieldCheck, Globe, } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; @@ -15,7 +16,7 @@ import toast from 'react-hot-toast'; export default function Admin() { const { user } = useAuth(); const { t, language } = useLanguage(); - const { appName, hasLogo, logoUrl, defaultTheme, refreshBranding } = useBranding(); + const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, refreshBranding } = useBranding(); const navigate = useNavigate(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -27,6 +28,12 @@ export default function Admin() { const [creatingUser, setCreatingUser] = useState(false); const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' }); + // Invite state + const [invites, setInvites] = useState([]); + const [inviteEmail, setInviteEmail] = useState(''); + const [sendingInvite, setSendingInvite] = useState(false); + const [savingRegMode, setSavingRegMode] = useState(false); + // Branding state const [editAppName, setEditAppName] = useState(''); const [savingName, setSavingName] = useState(false); @@ -41,6 +48,7 @@ export default function Admin() { return; } fetchUsers(); + fetchInvites(); }, [user]); useEffect(() => { @@ -62,6 +70,15 @@ export default function Admin() { } }; + const fetchInvites = async () => { + try { + const res = await api.get('/admin/invites'); + setInvites(res.data.invites); + } catch { + // silently fail + } + }; + const handleRoleChange = async (userId, newRole) => { try { await api.put(`/admin/users/${userId}/role`, { role: newRole }); @@ -172,6 +189,50 @@ export default function Admin() { } }; + const handleSendInvite = async (e) => { + e.preventDefault(); + setSendingInvite(true); + try { + const res = await api.post('/admin/invites', { email: inviteEmail }); + toast.success(t('admin.inviteSent')); + setInviteEmail(''); + fetchInvites(); + } catch (err) { + toast.error(err.response?.data?.error || t('admin.inviteFailed')); + } finally { + setSendingInvite(false); + } + }; + + const handleDeleteInvite = async (id) => { + try { + await api.delete(`/admin/invites/${id}`); + toast.success(t('admin.inviteDeleted')); + fetchInvites(); + } catch { + toast.error(t('admin.inviteDeleteFailed')); + } + }; + + const handleCopyInviteLink = (token) => { + const baseUrl = window.location.origin; + navigator.clipboard.writeText(`${baseUrl}/register?invite=${token}`); + toast.success(t('admin.inviteLinkCopied')); + }; + + const handleRegModeChange = async (mode) => { + setSavingRegMode(true); + try { + await api.put('/branding/registration-mode', { registrationMode: mode }); + toast.success(t('admin.regModeSaved')); + refreshBranding(); + } catch { + toast.error(t('admin.regModeFailed')); + } finally { + setSavingRegMode(false); + } + }; + const filteredUsers = users.filter(u => (u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase()) @@ -318,6 +379,128 @@ export default function Admin() { + {/* Registration Mode */} +
+
+ +

{t('admin.regModeTitle')}

+
+

{t('admin.regModeDescription')}

+ +
+ + +
+
+ + {/* User Invites */} +
+
+ +

{t('admin.inviteTitle')}

+
+

{t('admin.inviteDescription')}

+ + {/* Send invite form */} +
+
+ + setInviteEmail(e.target.value)} + className="input-field pl-9 text-sm" + placeholder={t('auth.emailPlaceholder')} + required + /> +
+ +
+ + {/* Invite list */} + {invites.length > 0 && ( +
+ {invites.map(inv => { + const isExpired = new Date(inv.expires_at) < new Date(); + const isUsed = !!inv.used_at; + return ( +
+
+
+ {isUsed ? : isExpired ? : } +
+
+

{inv.email}

+

+ {isUsed + ? `${t('admin.inviteUsedBy')} ${inv.used_by_name}` + : isExpired + ? t('admin.inviteExpired') + : `${t('admin.inviteExpiresAt')} ${new Date(inv.expires_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}` + } +

+
+
+
+ {!isUsed && !isExpired && ( + + )} + +
+
+ ); + })} +
+ )} + + {invites.length === 0 && ( +

{t('admin.noInvites')}

+ )} +
+ {/* Search */}
@@ -409,7 +592,7 @@ export default function Admin() { {openMenu === u.id && u.id !== user.id && ( <>
setOpenMenu(null)} /> -
+
@@ -78,11 +83,13 @@ export default function Home() {

- - {t('home.getStarted')} - - - + {!isInviteOnly && ( + + {t('home.getStarted')} + + + )} + {t('auth.login')}
diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index c6566f3..9dc9221 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; +import { useBranding } from '../contexts/BrandingContext'; import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'; import BrandLogo from '../components/BrandLogo'; import api from '../services/api'; @@ -16,6 +17,7 @@ export default function Login() { const [resending, setResending] = useState(false); const { login } = useAuth(); const { t } = useLanguage(); + const { registrationMode } = useBranding(); const navigate = useNavigate(); useEffect(() => { @@ -152,12 +154,14 @@ export default function Login() {
)} -

- {t('auth.noAccount')}{' '} - - {t('auth.signUpNow')} - -

+ {registrationMode !== 'invite' && ( +

+ {t('auth.noAccount')}{' '} + + {t('auth.signUpNow')} + +

+ )} {t('auth.backToHome')} diff --git a/src/pages/Register.jsx b/src/pages/Register.jsx index 05006f8..a3580a7 100644 --- a/src/pages/Register.jsx +++ b/src/pages/Register.jsx @@ -1,12 +1,15 @@ import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; -import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react'; +import { useBranding } from '../contexts/BrandingContext'; +import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert } from 'lucide-react'; import BrandLogo from '../components/BrandLogo'; import toast from 'react-hot-toast'; export default function Register() { + const [searchParams] = useSearchParams(); + const inviteToken = searchParams.get('invite') || ''; const [username, setUsername] = useState(''); const [displayName, setDisplayName] = useState(''); const [email, setEmail] = useState(''); @@ -16,8 +19,12 @@ export default function Register() { const [needsVerification, setNeedsVerification] = useState(false); const { register } = useAuth(); const { t } = useLanguage(); + const { registrationMode } = useBranding(); const navigate = useNavigate(); + // Invite-only mode without a token → show blocked message + const isBlocked = registrationMode === 'invite' && !inviteToken; + const handleSubmit = async (e) => { e.preventDefault(); @@ -33,7 +40,7 @@ export default function Register() { setLoading(true); try { - const result = await register(username, displayName, email, password); + const result = await register(username, displayName, email, password, inviteToken); if (result?.needsVerification) { setNeedsVerification(true); toast.success(t('auth.verificationSent')); @@ -77,6 +84,15 @@ export default function Register() { {t('auth.login')}
+ ) : isBlocked ? ( +
+ +

{t('auth.inviteOnly')}

+

{t('auth.inviteOnlyDesc')}

+ + {t('auth.login')} + +
) : ( <>