'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { formatBytes, formatDate } from '@/lib/format'; import type { AdminUserItem } from '@/lib/admin/list'; import { SortHeader, downloadUrl, type SortOrder } from '../table-ui'; const PER_PAGE = 25; function isBanned(u: AdminUserItem): boolean { return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now(); } type BulkResult = { ok: number; fail: number; skipped: number }; export function UsersTable({ initialUsers, initialTotal, currentUserId, }: { initialUsers: AdminUserItem[]; initialTotal: number; currentUserId: string; }) { const [users, setUsers] = useState(initialUsers); const [total, setTotal] = useState(initialTotal); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [sort, setSort] = useState('created_at'); const [order, setOrder] = useState('desc'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [selected, setSelected] = useState>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); const [bulkProgress, setBulkProgress] = useState(null); const queryParams = useCallback(() => { const params = new URLSearchParams({ page: String(page), perPage: String(PER_PAGE), sort, order, }); if (search.trim()) params.set('search', search.trim()); return params; }, [page, search, sort, order]); const load = useCallback(async () => { setLoading(true); setError(null); try { const res = await fetch(`/api/admin/users?${queryParams().toString()}`, { credentials: 'same-origin', }); if (!res.ok) { const b = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(b.error ?? `Request failed (${res.status})`); } const data = (await res.json()) as { users: AdminUserItem[]; total: number; }; setUsers(data.users); setTotal(data.total); setSelected(new Set()); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }, [queryParams]); // The first page is rendered from server-supplied data (see the parent server // component), so skip the initial on-mount fetch — that on-mount request used // to race the SSR session-cookie refresh and intermittently 401. const didMount = useRef(false); useEffect(() => { if (!didMount.current) { didMount.current = true; return; } const t = setTimeout(load, 250); return () => clearTimeout(t); }, [load]); function onSort(col: string) { setPage(1); if (col === sort) { setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); } else { setSort(col); setOrder('asc'); } } function toggleRow(id: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } const pageIds = users.map((u) => u.id); const allSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id)); function toggleAll() { setSelected((prev) => { if (pageIds.every((id) => prev.has(id))) return new Set(); return new Set(pageIds); }); } async function runBulk( label: string, perItem: (u: AdminUserItem) => Promise<'ok' | 'fail' | 'skip'>, confirmMsg?: string, ) { const targets = users.filter((u) => selected.has(u.id)); if (targets.length === 0) return; if (confirmMsg && !window.confirm(confirmMsg)) return; setBulkBusy(true); setError(null); setNotice(null); const result: BulkResult = { ok: 0, fail: 0, skipped: 0 }; for (let i = 0; i < targets.length; i++) { setBulkProgress(`${label}: ${i + 1}/${targets.length}…`); try { const r = await perItem(targets[i]); if (r === 'ok') result.ok++; else if (r === 'skip') result.skipped++; else result.fail++; } catch { result.fail++; } } setBulkProgress(null); setBulkBusy(false); const parts = [`${result.ok} ${label.toLowerCase()}`]; if (result.skipped) parts.push(`${result.skipped} skipped (self)`); if (result.fail) parts.push(`${result.fail} failed`); setNotice(parts.join(', ')); await load(); } async function banOne( u: AdminUserItem, banned: boolean, ): Promise<'ok' | 'fail' | 'skip'> { if (u.id === currentUserId) return 'skip'; const res = await fetch(`/api/admin/users/${u.id}/ban`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ banned }), }); return res.ok ? 'ok' : 'fail'; } async function deleteOne( u: AdminUserItem, ): Promise<'ok' | 'fail' | 'skip'> { if (u.id === currentUserId) return 'skip'; const res = await fetch(`/api/admin/users/${u.id}`, { method: 'DELETE', credentials: 'same-origin', }); return res.ok ? 'ok' : 'fail'; } function exportCsv() { const params = new URLSearchParams({ sort, order }); if (search.trim()) params.set('search', search.trim()); downloadUrl(`/api/admin/users/export?${params.toString()}`); } const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); const selectedCount = selected.size; return (

Users

{ setPage(1); setSearch(e.target.value); }} style={{ maxWidth: 320 }} />
{error &&

{error}

} {notice &&

{notice}

} {selectedCount > 0 && (
{selectedCount} selected {bulkProgress && {bulkProgress}}
)} {loading ? (

Loading…

) : users.length === 0 ? (

No users found.

) : (
{users.map((u) => ( ))}
Status Tunnel Usage
toggleRow(u.id)} /> {u.email ?? u.id} {u.role === 'admin' ? ( admin ) : ( user )} {isBanned(u) ? ( banned ) : u.email_confirmed_at ? ( confirmed ) : ( unconfirmed )} {u.tunnel ? u.tunnel.subdomain : '—'} {u.tunnel ? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes( u.tunnel.quota_bytes, )}` : '—'} {formatDate(u.last_sign_in_at)} {formatDate(u.created_at)}
)}
Page {page} of {totalPages} ({total} total)
); }