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

@@ -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 };
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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() {
</div>
</div>
{/* Registration Mode */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<ShieldCheck size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.regModeTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.regModeDescription')}</p>
<div className="flex items-center gap-3">
<button
onClick={() => handleRegModeChange('open')}
disabled={savingRegMode}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors ${
registrationMode === 'open'
? 'border-th-accent bg-th-accent/10 text-th-accent'
: 'border-th-border text-th-text-s hover:bg-th-hover'
}`}
>
<Globe size={16} />
{t('admin.regModeOpen')}
</button>
<button
onClick={() => handleRegModeChange('invite')}
disabled={savingRegMode}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border text-sm font-medium transition-colors ${
registrationMode === 'invite'
? 'border-th-accent bg-th-accent/10 text-th-accent'
: 'border-th-border text-th-text-s hover:bg-th-hover'
}`}
>
<Mail size={16} />
{t('admin.regModeInvite')}
</button>
</div>
</div>
{/* User Invites */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<Send size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.inviteTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.inviteDescription')}</p>
{/* Send invite form */}
<form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6">
<div className="relative flex-1">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="email"
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
className="input-field pl-9 text-sm"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div>
<button
type="submit"
disabled={sendingInvite || !inviteEmail.trim()}
className="btn-primary text-sm px-4 flex-shrink-0"
>
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
{t('admin.sendInvite')}
</button>
</form>
{/* Invite list */}
{invites.length > 0 && (
<div className="space-y-2">
{invites.map(inv => {
const isExpired = new Date(inv.expires_at) < new Date();
const isUsed = !!inv.used_at;
return (
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
}`}>
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{inv.email}</p>
<p className="text-xs text-th-text-s">
{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')}`
}
</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{!isUsed && !isExpired && (
<button
onClick={() => handleCopyInviteLink(inv.token)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
title={t('admin.copyInviteLink')}
>
<Copy size={14} />
</button>
)}
<button
onClick={() => handleDeleteInvite(inv.id)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
title={t('common.delete')}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
</div>
)}
{invites.length === 0 && (
<p className="text-sm text-th-text-s text-center py-4">{t('admin.noInvites')}</p>
)}
</div>
{/* Search */}
<div className="card p-4 mb-6">
<div className="relative">
@@ -409,7 +592,7 @@ export default function Admin() {
{openMenu === u.id && u.id !== user.id && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpenMenu(null)} />
<div className="absolute right-0 top-8 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
<div className="absolute right-0 bottom-full mb-1 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
<button
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"

View File

@@ -2,9 +2,12 @@ import { Link } from 'react-router-dom';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
export default function Home() {
const { t } = useLanguage();
const { registrationMode } = useBranding();
const isInviteOnly = registrationMode === 'invite';
const features = [
{
@@ -54,10 +57,12 @@ export default function Home() {
<Link to="/login" className="btn-ghost text-sm">
{t('auth.login')}
</Link>
<Link to="/register" className="btn-primary text-sm">
{t('auth.register')}
<ArrowRight size={16} />
</Link>
{!isInviteOnly && (
<Link to="/register" className="btn-primary text-sm">
{t('auth.register')}
<ArrowRight size={16} />
</Link>
)}
</div>
</nav>
@@ -78,11 +83,13 @@ export default function Home() {
</p>
<div className="flex items-center gap-4 justify-center">
<Link to="/register" className="btn-primary text-base px-8 py-3">
{t('home.getStarted')}
<ArrowRight size={18} />
</Link>
<Link to="/login" className="btn-secondary text-base px-8 py-3">
{!isInviteOnly && (
<Link to="/register" className="btn-primary text-base px-8 py-3">
{t('home.getStarted')}
<ArrowRight size={18} />
</Link>
)}
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} text-base px-8 py-3`}>
{t('auth.login')}
</Link>
</div>

View File

@@ -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() {
</div>
)}
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')}
</Link>
</p>
{registrationMode !== 'invite' && (
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">
{t('auth.signUpNow')}
</Link>
</p>
)}
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}

View File

@@ -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')}
</Link>
</div>
) : isBlocked ? (
<div className="text-center space-y-4">
<ShieldAlert size={48} className="mx-auto text-amber-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.inviteOnly')}</h2>
<p className="text-th-text-s">{t('auth.inviteOnlyDesc')}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
) : (
<>
<div className="mb-8">