import {useCallback, useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, UserCog, X} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import PageContainer from '../components/PageContainer.jsx'; import UserRoleDialog from '../components/UserRoleDialog.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, REFUSAL_KEY, sendPasswordReset, setUserBlocked} from '../utils/admin.js'; import {formatDate} from '../utils/format.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js'; const PAGE_SIZE = 20; const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null); /** * PLATFORM_ADMIN only: list, block and delete users, accept or deny registrations that wait, delete the ones whose address is not confirmed. Auth checks the role; a * 403 shows "no access". */ export default function AdminUsersPage() { const {t, i18n} = useTranslation(); const [query, setQuery] = useState(''); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); const [pendingOnly, setPendingOnly] = useState(false); const [result, setResult] = useState(null); const [status, setStatus] = useState('loading'); // {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny' | 'passwordReset', user} const [pending, setPending] = useState(null); const [typedEmail, setTypedEmail] = useState(''); const [busy, setBusy] = useState(false); // Only the newest load may show its answer: a slow answer to an older search must not replace the list. const nextSignal = useAbortPrevious(); const ownId = useCurrentUserContext().user?.id; const [roleUser, setRoleUser] = useState(null); const load = useCallback(async () => { const signal = nextSignal(); try { const response = await fetchUsers(search, pendingOnly ? 'PENDING' : '', page, PAGE_SIZE, {signal}); if (signal.aborted) return; if (response.status === 401) return; if (response.status === 403) { setStatus('forbidden'); return; } if (!response.ok) { setStatus('error'); return; } const data = await response.json(); if (signal.aborted) return; setResult(data); setStatus('ready'); } catch (err) { if (signal.aborted) return; console.error('Admin users error:', err); setStatus('error'); } }, [nextSignal, search, pendingOnly, page]); useEffect(() => { load(); }, [load]); const closeDialog = () => { setPending(null); setTypedEmail(''); }; const email = pending?.user.email; const dialog = pending && { delete: { title: t('ADMIN.DELETE_TITLE', {email}), text: t('ADMIN.DELETE_TEXT'), action: 'ADMIN.DELETE', done: 'ADMIN.DONE_DELETE', destructive: true, run: () => deleteUser(pending.user.id), }, deleteUnconfirmed: { title: t('ADMIN.DELETE_UNCONFIRMED_TITLE', {email}), text: t('ADMIN.DELETE_UNCONFIRMED_TEXT'), action: 'ADMIN.DELETE', done: 'ADMIN.DONE_DELETE', destructive: true, run: () => deleteUnconfirmedUser(pending.user.id), }, block: { ...(pending.user.blocked ? {title: t('ADMIN.UNBLOCK'), text: email, action: 'ADMIN.UNBLOCK', done: 'ADMIN.DONE_UNBLOCK'} : {title: t('ADMIN.BLOCK_TITLE', {email}), text: t('ADMIN.BLOCK_TEXT'), action: 'ADMIN.BLOCK', done: 'ADMIN.DONE_BLOCK'}), run: () => setUserBlocked(pending.user.id, !pending.user.blocked), }, passwordReset: { title: t('ADMIN.PASSWORD_RESET_TITLE', {email}), text: t('ADMIN.PASSWORD_RESET_TEXT'), action: 'ADMIN.SEND_PASSWORD_RESET', done: 'ADMIN.DONE_PASSWORD_RESET', run: () => sendPasswordReset(pending.user.id), }, accept: { title: t('ADMIN.ACCEPT_TITLE', {email}), text: t('ADMIN.ACCEPT_TEXT'), action: 'ADMIN.ACCEPT', done: 'ADMIN.DONE_ACCEPT', run: () => decideUser(pending.user.id, true), }, deny: { title: t('ADMIN.DENY_TITLE', {email}), text: t('ADMIN.DENY_TEXT'), action: 'ADMIN.DENY', done: 'ADMIN.DONE_DENY', destructive: true, run: () => decideUser(pending.user.id, false), }, }[pending.kind]; const confirm = async () => { setBusy(true); try { const response = await dialog.run(); if (response.status === 401) return; if (response.ok) { toast.success(t(dialog.done)); closeDialog(); await load(); } else { const data = await response.json().catch(() => ({})); toast.error(t(REFUSAL_KEY[data.code] ?? 'ADMIN.ERROR')); } } catch (err) { console.error('Admin action error:', err); toast.error(t('INFO.ERROR.NETWORK_FAILURE')); } finally { setBusy(false); } }; const submitSearch = (event) => { event.preventDefault(); setPage(0); setSearch(query.trim()); }; const confirmDisabled = busy || (pending?.kind === 'delete' && typedEmail.trim().toLowerCase() !== pending.user.email.toLowerCase()); const metaLine = (user) => [user.displayName, t('ADMIN.REGISTERED', {date: formatDateOrNull(user.registrationDate, i18n.language)}), t('ADMIN.LAST_LOGIN', {date: formatDateOrNull(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}), ].filter(Boolean).join(' ยท '); return ( {t('ADMIN.TITLE')} {status === 'forbidden' &&

{t('ADMIN.NO_ACCESS')}

} {status === 'error' &&

{t('ADMIN.LOAD_ERROR')}

} {status !== 'forbidden' && (
setQuery(event.target.value)}/>
)} {status === 'loading' && } {status === 'ready' && result.content.length === 0 && (

{t('ADMIN.EMPTY')}

)} {status === 'ready' && result.content.length > 0 && (
    {result.content.map((user) => (
  • {user.email}

    {metaLine(user)}

    {user.role !== 'USER' && {t(`ROLES.${user.role}`)}} {user.steamLinked && {t('ADMIN.STEAM')}} {user.status === 'UNCONFIRMED' && {t('ADMIN.UNCONFIRMED')}} {user.status === 'PENDING' && {t('ADMIN.PENDING')}} {user.blocked && {t('ADMIN.BLOCKED')}} {user.deletedAt && ( {t(user.status === 'DENIED' ? 'ADMIN.DENIED' : 'ADMIN.DELETED')} )}
    {user.status === 'UNCONFIRMED' && ( )} {user.status === 'PENDING' && (
    )} {user.status === 'ACTIVE' && !user.blocked && !user.deletedAt && user.id !== ownId && ( )} {user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
    {user.status === 'ACTIVE' && !user.blocked && ( )}
    )}
  • ))}
)} {status === 'ready' && result.totalPages > 1 && (
{t('ADMIN.PAGE_OF', {current: page + 1, total: result.totalPages})}
)}
{ if (!open) closeDialog(); }}> {pending && ( {dialog.title} {dialog.text} {pending.kind === 'delete' && (
setTypedEmail(event.target.value)}/>
)}
)}
setRoleUser(null)} onChanged={load}/>
); }