import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Layout } from '../components/Layout'; import { ConfirmModal } from '../components/ConfirmModal'; import { useAuth } from '../context/AuthContext'; import * as api from '../api'; import type { Collection } from '../types'; import { Shield, UserPlus, RefreshCw, UserCog, LogIn, Settings as SettingsIcon, KeyRound } from 'lucide-react'; import { IMPERSONATION_KEY, type ImpersonationState, readImpersonationState, USER_KEY, } from '../utils/impersonation'; type AdminUser = { id: string; username: string | null; email: string; name: string; role: 'ADMIN' | 'USER' | string; mustResetPassword: boolean; isActive: boolean; createdAt: string; updatedAt: string; }; export const Admin: React.FC = () => { const navigate = useNavigate(); const { user: authUser, authEnabled } = useAuth(); const isAdmin = authUser?.role === 'ADMIN'; const [collections, setCollections] = useState([]); const [users, setUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [createOpen, setCreateOpen] = useState(false); const [createEmail, setCreateEmail] = useState(''); const [createName, setCreateName] = useState(''); const [createUsername, setCreateUsername] = useState(''); const [createPassword, setCreatePassword] = useState(''); const [createRole, setCreateRole] = useState<'ADMIN' | 'USER'>('USER'); const [createMustReset, setCreateMustReset] = useState(true); const [createActive, setCreateActive] = useState(true); const [impersonateTarget, setImpersonateTarget] = useState(null); const [resetPasswordLoadingId, setResetPasswordLoadingId] = useState(null); const [resetPasswordResult, setResetPasswordResult] = useState<{ email: string; tempPassword: string } | null>(null); const [loginRateLimitLoading, setLoginRateLimitLoading] = useState(false); const [loginRateLimitSaving, setLoginRateLimitSaving] = useState(false); const [loginRateLimitEnabled, setLoginRateLimitEnabled] = useState(true); const [loginRateLimitWindowMinutes, setLoginRateLimitWindowMinutes] = useState(15); const [loginRateLimitMax, setLoginRateLimitMax] = useState(20); const [resetIdentifier, setResetIdentifier] = useState(''); const [resetLoading, setResetLoading] = useState(false); useEffect(() => { if (authEnabled === false) { navigate('/settings', { replace: true }); return; } if (authEnabled && !isAdmin) { navigate('/', { replace: true }); return; } }, [authEnabled, isAdmin, navigate]); const loadCollections = async () => { try { const data = await api.getCollections(); setCollections(data); } catch (err) { console.error('Failed to fetch collections:', err); } }; const loadUsers = async () => { setLoadingUsers(true); setError(''); try { const response = await api.api.get<{ users: AdminUser[] }>('/auth/users'); setUsers(response.data.users || []); } catch (err: unknown) { let message = 'Failed to load users'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setError(message); } finally { setLoadingUsers(false); } }; const generateTempPassword = async (target: AdminUser) => { setResetPasswordLoadingId(target.id); setError(''); setSuccess(''); try { const response = await api.api.post<{ tempPassword: string; user: { id: string; email: string } }>( `/auth/users/${target.id}/reset-password` ); setResetPasswordResult({ email: response.data.user?.email || target.email, tempPassword: response.data.tempPassword }); setSuccess(`Temporary password generated for ${target.email}`); await loadUsers(); } catch (err: unknown) { let message = 'Failed to reset password'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setError(message); } finally { setResetPasswordLoadingId(null); } }; const loadLoginRateLimitConfig = async () => { setLoginRateLimitLoading(true); setError(''); try { const response = await api.api.get<{ config: { enabled: boolean; windowMs: number; max: number }; }>('/auth/rate-limit/login'); const cfg = response.data.config; setLoginRateLimitEnabled(Boolean(cfg.enabled)); setLoginRateLimitMax(Number(cfg.max)); setLoginRateLimitWindowMinutes(Math.max(1, Math.round(Number(cfg.windowMs) / 60000))); } catch (err: unknown) { let message = 'Failed to load rate limit config'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setError(message); } finally { setLoginRateLimitLoading(false); } }; const saveLoginRateLimitConfig = async () => { setLoginRateLimitSaving(true); setError(''); setSuccess(''); try { const payload = { enabled: loginRateLimitEnabled, windowMs: Math.max(10_000, Math.round(loginRateLimitWindowMinutes * 60_000)), max: Math.max(1, Math.round(loginRateLimitMax)), }; const response = await api.api.put<{ config: { enabled: boolean; windowMs: number; max: number }; }>('/auth/rate-limit/login', payload); const cfg = response.data.config; setLoginRateLimitEnabled(Boolean(cfg.enabled)); setLoginRateLimitMax(Number(cfg.max)); setLoginRateLimitWindowMinutes(Math.max(1, Math.round(Number(cfg.windowMs) / 60000))); setSuccess('Login rate limit updated'); } catch (err: unknown) { let message = 'Failed to save rate limit config'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setError(message); } finally { setLoginRateLimitSaving(false); } }; const resetLoginRateLimit = async () => { const identifier = resetIdentifier.trim(); if (!identifier) { setError('Enter an email/username to reset'); return; } setResetLoading(true); setError(''); setSuccess(''); try { await api.api.post('/auth/rate-limit/login/reset', { identifier }); setSuccess(`Reset login rate limit for ${identifier}`); setResetIdentifier(''); } catch (err: unknown) { let message = 'Failed to reset rate limit'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setError(message); } finally { setResetLoading(false); } }; useEffect(() => { if (!authEnabled || !isAdmin) return; void loadCollections(); void loadUsers(); void loadLoginRateLimitConfig(); }, [authEnabled, isAdmin]); const handleSelectCollection = (id: string | null | undefined) => { if (id === undefined) navigate('/'); else if (id === null) navigate('/collections?id=unorganized'); else navigate(`/collections?id=${id}`); }; const handleCreateCollection = async (name: string) => { await api.createCollection(name); const newCollections = await api.getCollections(); setCollections(newCollections); }; const handleEditCollection = async (id: string, name: string) => { setCollections(prev => prev.map(c => (c.id === id ? { ...c, name } : c))); await api.updateCollection(id, name); }; const handleDeleteCollection = async (id: string) => { setCollections(prev => prev.filter(c => c.id !== id)); await api.deleteCollection(id); }; const handleCreateUser = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setSuccess(''); try { const payload = { email: createEmail.trim().toLowerCase(), name: createName.trim(), username: createUsername.trim() ? createUsername.trim() : undefined, password: createPassword, role: createRole, mustResetPassword: createMustReset, isActive: createActive, }; const response = await api.api.post<{ user: AdminUser }>('/auth/users', payload); setUsers(prev => [...prev, response.data.user].sort((a, b) => a.createdAt.localeCompare(b.createdAt))); setSuccess('User created'); setCreateEmail(''); setCreateName(''); setCreateUsername(''); setCreatePassword(''); setCreateRole('USER'); setCreateMustReset(true); setCreateActive(true); setCreateOpen(false); } catch (err: unknown) { let message = 'Failed to create user'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setError(message); } }; const patchUser = async (id: string, data: Partial>) => { setError(''); setSuccess(''); try { const response = await api.api.patch<{ user: AdminUser }>(`/auth/users/${id}`, data); setUsers(prev => prev.map(u => (u.id === id ? response.data.user : u))); setSuccess('User updated'); } catch (err: unknown) { let message = 'Failed to update user'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setError(message); } }; const startImpersonation = async (target: AdminUser) => { setError(''); setSuccess(''); if (readImpersonationState()) { setError('Stop the current impersonation before starting a new one.'); return; } const originalUser = localStorage.getItem(USER_KEY); if (!originalUser) { setError('Missing current session user state.'); return; } try { const response = await api.api.post<{ user: { id: string; email: string; name: string }; accessToken: string; refreshToken: string; }>('/auth/impersonate', { userId: target.id }); const state: ImpersonationState = { original: { user: JSON.parse(originalUser), }, impersonator: { id: authUser?.id || 'unknown', email: authUser?.email || 'unknown', name: authUser?.name || 'Unknown Admin', }, target: { id: response.data.user.id, email: response.data.user.email, name: response.data.user.name, }, startedAt: new Date().toISOString(), }; localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state)); localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); window.location.href = '/'; } catch (err: unknown) { let message = 'Failed to impersonate user'; if (api.isAxiosError(err)) { message = err.response?.data?.message || err.response?.data?.error || message; } setError(message); } }; if (authEnabled === null) { return (
Loading...
); } return (

Admin

User management and impersonation

{success && (

{success}

)} {error && (

{error}

)} {createOpen && (

Create User

setCreateEmail(e.target.value)} required className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" />
setCreateName(e.target.value)} required className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" />
setCreateUsername(e.target.value)} className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" />
setCreatePassword(e.target.value)} minLength={8} required className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" />
)}

Login Rate Limiting

Reduce brute-force attacks; disable only for trusted environments.

{loginRateLimitLoading && ( Loading… )}
setLoginRateLimitWindowMinutes(Number(e.target.value))} className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" />
setLoginRateLimitMax(Number(e.target.value))} className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" />
setResetIdentifier(e.target.value)} placeholder="user@example.com" className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" /> {users.map(u => (

Users

{loadingUsers && Loading…}
{users.map(u => ( ))} {users.length === 0 && !loadingUsers && ( )}
User Role Active Must Reset Actions
{u.name}
{u.email}
{u.username &&
@{u.username}
}
No users found.
{ if (impersonateTarget) { void startImpersonation(impersonateTarget); } setImpersonateTarget(null); }} onCancel={() => setImpersonateTarget(null)} />
Temporary password for {resetPasswordResult.email}. They will be prompted to set a new password after signing in.
{resetPasswordResult.tempPassword}
) : ( '' ) } confirmText="Copy" cancelText="Close" isDangerous={false} variant="success" onConfirm={() => { if (!resetPasswordResult) return; void navigator.clipboard?.writeText(resetPasswordResult.tempPassword); setResetPasswordResult(null); }} onCancel={() => setResetPasswordResult(null)} />
); };