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 */}
+
+
+ {/* 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')}
+
+
) : (
<>