Files
redlight/src/pages/Admin.jsx
Michelle d04793148a
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m12s
feat: add room management functionality for admins with listing and deletion options
2026-04-01 11:54:10 +02:00

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