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

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