All checks were successful
Build & Push Docker Image / build (push) Successful in 4m12s
1210 lines
50 KiB
JavaScript
1210 lines
50 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
|
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, Send, Copy, Clock, Check,
|
|
ShieldCheck, Globe, Link as LinkIcon, LogIn, DoorOpen, Eye, ExternalLink,
|
|
} from 'lucide-react';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { useBranding } from '../contexts/BrandingContext';
|
|
import { themes } from '../themes';
|
|
import api from '../services/api';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function Admin() {
|
|
const { user } = useAuth();
|
|
const { t, language } = useLanguage();
|
|
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, hideAppName, refreshBranding } = useBranding();
|
|
const navigate = useNavigate();
|
|
const [users, setUsers] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [openMenu, setOpenMenu] = useState(null);
|
|
const [resetPwModal, setResetPwModal] = useState(null);
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [showCreateUser, setShowCreateUser] = useState(false);
|
|
const [creatingUser, setCreatingUser] = useState(false);
|
|
const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' });
|
|
const menuBtnRefs = useRef({});
|
|
const [menuPos, setMenuPos] = useState(null);
|
|
|
|
// 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);
|
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
|
const logoInputRef = useRef(null);
|
|
const [editDefaultTheme, setEditDefaultTheme] = useState('');
|
|
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
|
|
const [editImprintUrl, setEditImprintUrl] = useState('');
|
|
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
|
|
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
|
|
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
|
|
const [savingHideAppName, setSavingHideAppName] = useState(false);
|
|
|
|
// OAuth state
|
|
const [oauthConfig, setOauthConfig] = useState(null);
|
|
const [oauthLoading, setOauthLoading] = useState(true);
|
|
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
|
const [savingOauth, setSavingOauth] = useState(false);
|
|
|
|
// Rooms state
|
|
const [adminRooms, setAdminRooms] = useState([]);
|
|
const [adminRoomsLoading, setAdminRoomsLoading] = useState(true);
|
|
const [roomSearch, setRoomSearch] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (user?.role !== 'admin') {
|
|
navigate('/dashboard');
|
|
return;
|
|
}
|
|
fetchUsers();
|
|
fetchInvites();
|
|
fetchOauthConfig();
|
|
fetchAdminRooms();
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
setEditAppName(appName || 'Redlight');
|
|
}, [appName]);
|
|
|
|
useEffect(() => {
|
|
setEditDefaultTheme(defaultTheme || 'dark');
|
|
}, [defaultTheme]);
|
|
|
|
useEffect(() => {
|
|
setEditImprintUrl(imprintUrl || '');
|
|
}, [imprintUrl]);
|
|
|
|
useEffect(() => {
|
|
setEditPrivacyUrl(privacyUrl || '');
|
|
}, [privacyUrl]);
|
|
|
|
const fetchUsers = async () => {
|
|
try {
|
|
const res = await api.get('/admin/users');
|
|
setUsers(res.data.users);
|
|
} catch {
|
|
toast.error(t('admin.roleUpdateFailed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchInvites = async () => {
|
|
try {
|
|
const res = await api.get('/admin/invites');
|
|
setInvites(res.data.invites);
|
|
} catch {
|
|
// silently fail
|
|
}
|
|
};
|
|
|
|
const fetchAdminRooms = async () => {
|
|
setAdminRoomsLoading(true);
|
|
try {
|
|
const res = await api.get('/admin/rooms');
|
|
setAdminRooms(res.data.rooms);
|
|
} catch {
|
|
// silently fail
|
|
} finally {
|
|
setAdminRoomsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAdminDeleteRoom = async (uid, name) => {
|
|
if (!confirm(t('admin.deleteRoomConfirm', { name }))) return;
|
|
try {
|
|
await api.delete(`/rooms/${uid}`);
|
|
toast.success(t('admin.roomDeleted'));
|
|
fetchAdminRooms();
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('admin.roomDeleteFailed'));
|
|
}
|
|
};
|
|
|
|
const handleRoleChange = async (userId, newRole) => {
|
|
try {
|
|
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
|
toast.success(t('admin.roleUpdated'));
|
|
fetchUsers();
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('admin.roleUpdateFailed'));
|
|
}
|
|
setOpenMenu(null);
|
|
setMenuPos(null);
|
|
};
|
|
|
|
const handleDelete = async (userId, userName) => {
|
|
if (!confirm(t('admin.deleteUserConfirm', { name: userName }))) return;
|
|
try {
|
|
await api.delete(`/admin/users/${userId}`);
|
|
toast.success(t('admin.userDeleted'));
|
|
fetchUsers();
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
|
|
}
|
|
setOpenMenu(null);
|
|
setMenuPos(null);
|
|
};
|
|
|
|
const handleResetPassword = async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await api.put(`/admin/users/${resetPwModal}/password`, { newPassword });
|
|
toast.success(t('admin.passwordReset'));
|
|
setResetPwModal(null);
|
|
setNewPassword('');
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('admin.passwordResetFailed'));
|
|
}
|
|
};
|
|
|
|
// ── Branding handlers ──────────────────────────────────────────────────
|
|
const handleLogoUpload = async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setUploadingLogo(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('logo', file);
|
|
await api.post('/branding/logo', formData, {
|
|
headers: { 'Content-Type': undefined },
|
|
});
|
|
toast.success(t('admin.logoUploaded'));
|
|
refreshBranding();
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('admin.logoUploadFailed'));
|
|
} finally {
|
|
setUploadingLogo(false);
|
|
if (logoInputRef.current) logoInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleLogoRemove = async () => {
|
|
try {
|
|
await api.delete('/branding/logo');
|
|
toast.success(t('admin.logoRemoved'));
|
|
refreshBranding();
|
|
} catch {
|
|
toast.error(t('admin.logoRemoveFailed'));
|
|
}
|
|
};
|
|
|
|
const handleHideAppNameToggle = async (value) => {
|
|
setSavingHideAppName(true);
|
|
try {
|
|
await api.put('/branding/hide-app-name', { hideAppName: value });
|
|
refreshBranding();
|
|
} catch {
|
|
toast.error(t('admin.hideAppNameFailed'));
|
|
} finally {
|
|
setSavingHideAppName(false);
|
|
}
|
|
};
|
|
|
|
const handleAppNameSave = async () => {
|
|
if (!editAppName.trim()) return;
|
|
setSavingName(true);
|
|
try {
|
|
await api.put('/branding/name', { appName: editAppName.trim() });
|
|
toast.success(t('admin.appNameUpdated'));
|
|
refreshBranding();
|
|
} catch {
|
|
toast.error(t('admin.appNameUpdateFailed'));
|
|
} finally {
|
|
setSavingName(false);
|
|
}
|
|
};
|
|
|
|
const handleDefaultThemeSave = async () => {
|
|
if (!editDefaultTheme) return;
|
|
setSavingDefaultTheme(true);
|
|
try {
|
|
await api.put('/branding/default-theme', { defaultTheme: editDefaultTheme });
|
|
toast.success(t('admin.defaultThemeSaved'));
|
|
refreshBranding();
|
|
} catch {
|
|
toast.error(t('admin.defaultThemeUpdateFailed'));
|
|
} finally {
|
|
setSavingDefaultTheme(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateUser = async (e) => {
|
|
e.preventDefault();
|
|
setCreatingUser(true);
|
|
try {
|
|
await api.post('/admin/users', newUser);
|
|
toast.success(t('admin.userCreated'));
|
|
setShowCreateUser(false);
|
|
setNewUser({ name: '', display_name: '', email: '', password: '', role: 'user' });
|
|
fetchUsers();
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('admin.userCreateFailed'));
|
|
} finally {
|
|
setCreatingUser(false);
|
|
}
|
|
};
|
|
|
|
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 handleImprintUrlSave = async () => {
|
|
setSavingImprintUrl(true);
|
|
try {
|
|
await api.put('/branding/imprint-url', { imprintUrl: editImprintUrl.trim() });
|
|
toast.success(t('admin.imprintUrlSaved'));
|
|
refreshBranding();
|
|
} catch {
|
|
toast.error(t('admin.imprintUrlFailed'));
|
|
} finally {
|
|
setSavingImprintUrl(false);
|
|
}
|
|
};
|
|
|
|
const handlePrivacyUrlSave = async () => {
|
|
setSavingPrivacyUrl(true);
|
|
try {
|
|
await api.put('/branding/privacy-url', { privacyUrl: editPrivacyUrl.trim() });
|
|
toast.success(t('admin.privacyUrlSaved'));
|
|
refreshBranding();
|
|
} catch {
|
|
toast.error(t('admin.privacyUrlFailed'));
|
|
} finally {
|
|
setSavingPrivacyUrl(false);
|
|
}
|
|
};
|
|
|
|
// ── OAuth handlers ──────────────────────────────────────────────────────
|
|
const fetchOauthConfig = async () => {
|
|
setOauthLoading(true);
|
|
try {
|
|
const res = await api.get('/admin/oauth');
|
|
if (res.data.configured) {
|
|
setOauthConfig(res.data.config);
|
|
setOauthForm({
|
|
issuer: res.data.config.issuer || '',
|
|
clientId: res.data.config.clientId || '',
|
|
clientSecret: '',
|
|
displayName: res.data.config.displayName || 'SSO',
|
|
autoRegister: res.data.config.autoRegister ?? true,
|
|
});
|
|
} else {
|
|
setOauthConfig(null);
|
|
}
|
|
} catch {
|
|
// silently fail
|
|
} finally {
|
|
setOauthLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOauthSave = async (e) => {
|
|
e.preventDefault();
|
|
setSavingOauth(true);
|
|
try {
|
|
await api.put('/admin/oauth', oauthForm);
|
|
toast.success(t('admin.oauthSaved'));
|
|
fetchOauthConfig();
|
|
refreshBranding();
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('admin.oauthSaveFailed'));
|
|
} finally {
|
|
setSavingOauth(false);
|
|
}
|
|
};
|
|
|
|
const handleOauthRemove = async () => {
|
|
if (!confirm(t('admin.oauthRemoveConfirm'))) return;
|
|
try {
|
|
await api.delete('/admin/oauth');
|
|
toast.success(t('admin.oauthRemoved'));
|
|
setOauthConfig(null);
|
|
setOauthForm({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
|
|
refreshBranding();
|
|
} catch {
|
|
toast.error(t('admin.oauthRemoveFailed'));
|
|
}
|
|
};
|
|
|
|
const filteredUsers = users.filter(u =>
|
|
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
|
u.email.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 size={32} className="animate-spin text-th-accent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-8">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Shield size={24} className="text-th-accent" />
|
|
<h1 className="text-2xl font-bold text-th-text">{t('admin.title')}</h1>
|
|
</div>
|
|
<p className="text-sm text-th-text-s">
|
|
{t('admin.userCount', { count: users.length })}
|
|
</p>
|
|
</div>
|
|
<button onClick={() => setShowCreateUser(true)} className="btn-primary">
|
|
<UserPlus size={18} />
|
|
<span className="hidden sm:inline">{t('admin.createUser')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Branding */}
|
|
<div className="card p-6 mb-8">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Image size={20} className="text-th-accent" />
|
|
<h2 className="text-lg font-semibold text-th-text">{t('admin.brandingTitle')}</h2>
|
|
</div>
|
|
<p className="text-sm text-th-text-s mb-5">{t('admin.brandingDescription')}</p>
|
|
|
|
<div className="grid gap-6 sm:grid-cols-2">
|
|
{/* Logo upload */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</label>
|
|
<div className="flex items-center gap-4">
|
|
{hasLogo && logoUrl ? (
|
|
<div className="relative group">
|
|
<img
|
|
src={`${logoUrl}?t=${Date.now()}`}
|
|
alt="Logo"
|
|
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
|
|
/>
|
|
<button
|
|
onClick={handleLogoRemove}
|
|
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<XIcon size={12} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="w-14 h-14 rounded-xl border-2 border-dashed border-th-border flex items-center justify-center text-th-text-s">
|
|
<Image size={24} />
|
|
</div>
|
|
)}
|
|
<div>
|
|
<input
|
|
ref={logoInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleLogoUpload}
|
|
className="hidden"
|
|
/>
|
|
<button
|
|
onClick={() => logoInputRef.current?.click()}
|
|
disabled={uploadingLogo}
|
|
className="btn-secondary text-sm"
|
|
>
|
|
{uploadingLogo ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Upload size={14} />
|
|
)}
|
|
{hasLogo ? t('admin.logoChange') : t('admin.logoUpload')}
|
|
</button>
|
|
<p className="text-xs text-th-text-s mt-1">{t('admin.logoHint')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* App name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={editAppName}
|
|
onChange={e => setEditAppName(e.target.value)}
|
|
className="input-field pl-9 text-sm"
|
|
placeholder="Redlight"
|
|
maxLength={30}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleAppNameSave}
|
|
disabled={savingName || editAppName.trim() === appName}
|
|
className="btn-primary text-sm px-4"
|
|
>
|
|
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
|
</button>
|
|
</div>
|
|
{hasLogo && (
|
|
<div className="flex items-center justify-between mt-3 p-3 rounded-lg bg-th-bg-s border border-th-border">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-th-text">{t('admin.hideAppNameLabel')}</p>
|
|
<p className="text-xs text-th-text-s mt-0.5">{t('admin.hideAppNameHint')}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
disabled={savingHideAppName}
|
|
onClick={() => handleHideAppNameToggle(!hideAppName)}
|
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
|
|
hideAppName ? 'bg-th-accent' : 'bg-th-border'
|
|
}`}
|
|
aria-checked={hideAppName}
|
|
role="switch"
|
|
>
|
|
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
hideAppName ? 'translate-x-4' : 'translate-x-0'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Default theme */}
|
|
<div className="mt-6 pt-6 border-t border-th-border">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Palette size={16} className="text-th-accent" />
|
|
<label className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
|
|
</div>
|
|
<p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={editDefaultTheme}
|
|
onChange={e => setEditDefaultTheme(e.target.value)}
|
|
className="input-field text-sm flex-1"
|
|
>
|
|
{themes.map(th => (
|
|
<option key={th.id} value={th.id}>
|
|
{th.name} ({th.type === 'light' ? t('themes.light') : t('themes.dark')})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={handleDefaultThemeSave}
|
|
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
|
|
className="btn-primary text-sm px-4 flex-shrink-0"
|
|
>
|
|
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Legal links */}
|
|
<div className="mt-6 pt-6 border-t border-th-border">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<LinkIcon size={16} className="text-th-accent" />
|
|
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label>
|
|
</div>
|
|
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{/* Imprint */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="url"
|
|
value={editImprintUrl}
|
|
onChange={e => setEditImprintUrl(e.target.value)}
|
|
className="input-field text-sm flex-1"
|
|
placeholder="https://example.com/imprint"
|
|
maxLength={500}
|
|
/>
|
|
<button
|
|
onClick={handleImprintUrlSave}
|
|
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
|
|
className="btn-primary text-sm px-4 flex-shrink-0"
|
|
>
|
|
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* Privacy Policy */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="url"
|
|
value={editPrivacyUrl}
|
|
onChange={e => setEditPrivacyUrl(e.target.value)}
|
|
className="input-field text-sm flex-1"
|
|
placeholder="https://example.com/privacy"
|
|
maxLength={500}
|
|
/>
|
|
<button
|
|
onClick={handlePrivacyUrlSave}
|
|
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
|
|
className="btn-primary text-sm px-4 flex-shrink-0"
|
|
>
|
|
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
|
|
{/* OAuth / SSO Configuration */}
|
|
<div className="card p-6 mb-8">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<LogIn size={20} className="text-th-accent" />
|
|
<h2 className="text-lg font-semibold text-th-text">{t('admin.oauthTitle')}</h2>
|
|
</div>
|
|
<p className="text-sm text-th-text-s mb-5">{t('admin.oauthDescription')}</p>
|
|
|
|
{oauthLoading ? (
|
|
<div className="flex justify-center py-4">
|
|
<Loader2 size={20} className="animate-spin text-th-accent" />
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleOauthSave} className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
|
|
<input
|
|
type="url"
|
|
value={oauthForm.issuer}
|
|
onChange={e => setOauthForm(f => ({ ...f, issuer: e.target.value }))}
|
|
className="input-field text-sm"
|
|
placeholder="https://auth.example.com/realms/main"
|
|
required
|
|
/>
|
|
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthIssuerHint')}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
|
|
<input
|
|
type="text"
|
|
value={oauthForm.clientId}
|
|
onChange={e => setOauthForm(f => ({ ...f, clientId: e.target.value }))}
|
|
className="input-field text-sm"
|
|
placeholder="redlight"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
|
|
<input
|
|
type="password"
|
|
value={oauthForm.clientSecret}
|
|
onChange={e => setOauthForm(f => ({ ...f, clientSecret: e.target.value }))}
|
|
className="input-field text-sm"
|
|
placeholder={oauthConfig?.hasClientSecret ? '••••••••' : ''}
|
|
/>
|
|
{oauthConfig?.hasClientSecret && (
|
|
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthClientSecretHint')}</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
|
|
<input
|
|
type="text"
|
|
value={oauthForm.displayName}
|
|
onChange={e => setOauthForm(f => ({ ...f, displayName: e.target.value }))}
|
|
className="input-field text-sm"
|
|
placeholder="Company SSO"
|
|
maxLength={50}
|
|
/>
|
|
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthDisplayNameHint')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={oauthForm.autoRegister}
|
|
onChange={e => setOauthForm(f => ({ ...f, autoRegister: e.target.checked }))}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-9 h-5 bg-th-border rounded-full peer peer-checked:bg-th-accent transition-colors after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
|
|
</label>
|
|
<div>
|
|
<span className="text-sm font-medium text-th-text">{t('admin.oauthAutoRegister')}</span>
|
|
<p className="text-xs text-th-text-s">{t('admin.oauthAutoRegisterHint')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<button type="submit" disabled={savingOauth} className="btn-primary text-sm px-5">
|
|
{savingOauth ? <Loader2 size={14} className="animate-spin" /> : null}
|
|
{t('admin.oauthSave')}
|
|
</button>
|
|
{oauthConfig && (
|
|
<button type="button" onClick={handleOauthRemove} className="btn-secondary text-sm px-5 text-red-400 hover:text-red-300">
|
|
<Trash2 size={14} />
|
|
{t('admin.oauthRemove')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
{/* Room Management */}
|
|
<div className="card p-6 mb-8">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<DoorOpen size={20} className="text-th-accent" />
|
|
<h2 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h2>
|
|
</div>
|
|
<p className="text-sm text-th-text-s mb-5">{t('admin.roomsDescription')}</p>
|
|
|
|
{adminRoomsLoading ? (
|
|
<div className="flex justify-center py-4">
|
|
<Loader2 size={20} className="animate-spin text-th-accent" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Room search */}
|
|
<div className="relative mb-4">
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={roomSearch}
|
|
onChange={e => setRoomSearch(e.target.value)}
|
|
className="input-field pl-9 text-sm"
|
|
placeholder={t('admin.searchRooms')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-th-border">
|
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
|
{t('admin.roomName')}
|
|
</th>
|
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden sm:table-cell">
|
|
{t('admin.roomOwner')}
|
|
</th>
|
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden md:table-cell">
|
|
{t('admin.roomShares')}
|
|
</th>
|
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5 hidden lg:table-cell">
|
|
{t('admin.roomCreated')}
|
|
</th>
|
|
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-4 py-2.5">
|
|
{t('admin.actions')}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{adminRooms
|
|
.filter(r =>
|
|
r.name.toLowerCase().includes(roomSearch.toLowerCase()) ||
|
|
r.owner_name.toLowerCase().includes(roomSearch.toLowerCase()) ||
|
|
r.uid.toLowerCase().includes(roomSearch.toLowerCase())
|
|
)
|
|
.map(r => (
|
|
<tr key={r.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
|
<td className="px-4 py-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-th-text">{r.name}</p>
|
|
<p className="text-xs text-th-text-s font-mono">{r.uid}</p>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 hidden sm:table-cell">
|
|
<div>
|
|
<p className="text-sm text-th-text">{r.owner_name}</p>
|
|
<p className="text-xs text-th-text-s">{r.owner_email}</p>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-th-text hidden md:table-cell">
|
|
{r.share_count}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-th-text-s hidden lg:table-cell">
|
|
{new Date(r.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
onClick={() => navigate(`/rooms/${r.uid}`)}
|
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
|
title={t('admin.roomView')}
|
|
>
|
|
<Eye size={15} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleAdminDeleteRoom(r.uid, r.name)}
|
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-error transition-colors"
|
|
title={t('admin.deleteRoom')}
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{adminRooms.length === 0 && (
|
|
<div className="text-center py-8">
|
|
<DoorOpen size={36} className="mx-auto text-th-text-s/40 mb-2" />
|
|
<p className="text-th-text-s text-sm">{t('admin.noRoomsFound')}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="card p-4 mb-6">
|
|
<div className="relative">
|
|
<Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
className="input-field pl-11"
|
|
placeholder={t('admin.searchUsers')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Users table */}
|
|
<div className="card overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-th-border">
|
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3">
|
|
{t('admin.user')}
|
|
</th>
|
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden sm:table-cell">
|
|
{t('admin.role')}
|
|
</th>
|
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden md:table-cell">
|
|
{t('admin.rooms')}
|
|
</th>
|
|
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden lg:table-cell">
|
|
{t('admin.registered')}
|
|
</th>
|
|
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3">
|
|
{t('admin.actions')}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredUsers.map(u => (
|
|
<tr key={u.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
|
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
|
>
|
|
{u.avatar_image ? (
|
|
<img
|
|
src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
(u.display_name || u.name)[0]?.toUpperCase()
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-th-text">{u.display_name || u.name}</p>
|
|
<p className="text-xs text-th-text-s">@{u.name} · {u.email}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4 hidden sm:table-cell">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
u.role === 'admin'
|
|
? 'bg-th-accent/15 text-th-accent'
|
|
: 'bg-th-bg-t text-th-text-s'
|
|
}`}>
|
|
{u.role === 'admin' ? <Shield size={10} /> : <Users size={10} />}
|
|
{u.role === 'admin' ? t('admin.admin') : t('admin.user')}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-4 text-sm text-th-text hidden md:table-cell">
|
|
{u.room_count}
|
|
</td>
|
|
<td className="px-5 py-4 text-sm text-th-text-s hidden lg:table-cell">
|
|
{new Date(u.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center justify-end">
|
|
<button
|
|
ref={el => { menuBtnRefs.current[u.id] = el; }}
|
|
onClick={() => {
|
|
if (openMenu === u.id) {
|
|
setOpenMenu(null);
|
|
setMenuPos(null);
|
|
} else {
|
|
const rect = menuBtnRefs.current[u.id]?.getBoundingClientRect();
|
|
if (rect) {
|
|
const menuHeight = 130;
|
|
const spaceAbove = rect.top;
|
|
if (spaceAbove >= menuHeight) {
|
|
setMenuPos({ top: rect.top - menuHeight - 4, left: rect.right - 192 });
|
|
} else {
|
|
setMenuPos({ top: rect.bottom + 4, left: rect.right - 192 });
|
|
}
|
|
}
|
|
setOpenMenu(u.id);
|
|
}
|
|
}}
|
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
|
disabled={u.id === user.id}
|
|
>
|
|
<MoreVertical size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{filteredUsers.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<Users size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
|
<p className="text-th-text-s text-sm">{t('admin.noUsersFound')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Context menu portal */}
|
|
{openMenu && menuPos && openMenu !== user.id && (() => {
|
|
const u = users.find(u => u.id === openMenu);
|
|
if (!u) return null;
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => { setOpenMenu(null); setMenuPos(null); }} />
|
|
<div
|
|
className="fixed z-50 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden"
|
|
style={{ top: menuPos.top, left: menuPos.left }}
|
|
>
|
|
<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"
|
|
>
|
|
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
|
|
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
|
|
</button>
|
|
<button
|
|
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); setMenuPos(null); }}
|
|
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
|
>
|
|
<Key size={14} />
|
|
{t('admin.resetPassword')}
|
|
</button>
|
|
<button
|
|
onClick={() => { handleDelete(u.id, u.name); setMenuPos(null); }}
|
|
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
|
|
>
|
|
<Trash2 size={14} />
|
|
{t('admin.deleteUser')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
|
|
{/* Reset password modal */}
|
|
{resetPwModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetPwModal(null)} />
|
|
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
|
|
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
|
|
<form onSubmit={handleResetPassword}>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
|
|
<input
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={e => setNewPassword(e.target.value)}
|
|
className="input-field"
|
|
placeholder={t('auth.minPassword')}
|
|
required
|
|
minLength={6}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button type="button" onClick={() => setResetPwModal(null)} className="btn-secondary flex-1">
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="submit" className="btn-primary flex-1">
|
|
{t('admin.resetPassword')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create user modal */}
|
|
{showCreateUser && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateUser(false)} />
|
|
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
|
|
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
|
|
<form onSubmit={handleCreateUser} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
|
|
<div className="relative">
|
|
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={newUser.name}
|
|
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
|
|
className="input-field pl-11"
|
|
placeholder={t('auth.usernamePlaceholder')}
|
|
required
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
|
|
<div className="relative">
|
|
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={newUser.display_name}
|
|
onChange={e => setNewUser({ ...newUser, display_name: e.target.value })}
|
|
className="input-field pl-11"
|
|
placeholder={t('auth.displayNamePlaceholder')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
|
<div className="relative">
|
|
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="email"
|
|
value={newUser.email}
|
|
onChange={e => setNewUser({ ...newUser, email: e.target.value })}
|
|
className="input-field pl-11"
|
|
placeholder={t('auth.emailPlaceholder')}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
|
<div className="relative">
|
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="password"
|
|
value={newUser.password}
|
|
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
|
|
className="input-field pl-11"
|
|
placeholder={t('auth.minPassword')}
|
|
required
|
|
minLength={6}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
|
|
<select
|
|
value={newUser.role}
|
|
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
|
|
className="input-field"
|
|
>
|
|
<option value="user">{t('admin.user')}</option>
|
|
<option value="admin">{t('admin.admin')}</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<button type="button" onClick={() => setShowCreateUser(false)} className="btn-secondary flex-1">
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="submit" disabled={creatingUser} className="btn-primary flex-1">
|
|
{creatingUser ? <Loader2 size={18} className="animate-spin" /> : t('common.create')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|