feat(invite-system): implement user invite functionality with registration mode control
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user