import type { AdminUserListView, AdminUserView } from '@dorfteich/shared'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../auth/auth-context'; import { IconButton } from '../components/IconButton'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; const PAGE_SIZE = 20; /** * Site-Admin user management (issue #59): a searchable, paginated list with the * lifecycle actions. Destructive ones use an inline two-step confirm; the api * enforces the "not on self / not the last Site Admin" rules — this only hides * the buttons on your own row. */ export function UserManager(): React.JSX.Element { const { t } = useTranslation('users'); const { user: me } = useAuth(); const [q, setQ] = useState(''); const [page, setPage] = useState(1); const query = useQuery({ queryKey: ['admin', 'users', q, page], queryFn: () => apiGet( `/admin/users?q=${encodeURIComponent(q)}&page=${page}&pageSize=${PAGE_SIZE}`, ), placeholderData: keepPreviousData, }); const run = async (fn: () => Promise): Promise => { await fn(); await query.refetch(); }; const data = query.data; const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1; return (

{t('title')}

{ setQ(e.target.value); setPage(1); }} /> {(data?.users ?? []).map((u) => ( ))}
{t('columns.user')} {t('columns.status')} {t('columns.role')} {t('columns.ponds')} {t('columns.lastLogin')} {t('columns.actions')}
{t('page', { page })}
); } function UserRow({ user, isSelf, run, }: { user: AdminUserView; isSelf: boolean; run: (fn: () => Promise) => Promise; }): React.JSX.Element { const { t } = useTranslation('users'); const [confirmingDelete, setConfirmingDelete] = useState(false); const confirmRef = useRef(null); // The icon button unmounts when the confirm step appears — hand focus over // so keyboard users land on the confirmation instead of losing focus. useEffect(() => { if (confirmingDelete) confirmRef.current?.focus(); }, [confirmingDelete]); const disabled = user.status === 'DISABLED'; return ( {user.displayName} @{user.username} {isSelf && {t('actions.you')}}
{user.email}
{t(`status.${user.status}`)} {user.isSiteAdmin ? t('role.admin') : t('role.user')} {user.pondCount} {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : t('never')} {/* Flex lives on the inner div: a td with display:flex stops behaving like a table cell and its bottom border no longer meets the row's. */} {!isSelf && (
{user.status === 'PENDING_VERIFICATION' && ( void run(() => apiPost(`/admin/users/${user.id}/resend-verification`)) } > )} void run(() => apiPatch(`/admin/users/${user.id}/site-admin`, { isSiteAdmin: !user.isSiteAdmin, }), ) } > {user.isSiteAdmin ? : } void run(() => apiPatch(`/admin/users/${user.id}/disabled`, { disabled: !disabled }), ) } > {disabled ? : } {confirmingDelete ? ( ) : ( setConfirmingDelete(true)} > )}
)} ); }