d317e8c758
WS1: pin all Docker stages to node:24.16.0-alpine; add engines node>=20. WS2: lib/redis.ts gains TTL-backed redisSet, redisDel, setTunnelActive (writes tunnel:active:<sub>=1/0 EX 30, TUNNEL_ACTIVE_TTL override, no-op without REDIS_URL); wired into tunnel active/delete/reassign routes. WS3: sortable columns, CSV export routes (token excluded), and bulk actions (self-account guard) across users/tunnels/audit admin tables.
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
'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<AdminUserItem[]>(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<SortOrder>('desc');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [bulkBusy, setBulkBusy] = useState(false);
|
|
const [bulkProgress, setBulkProgress] = useState<string | null>(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 (
|
|
<div>
|
|
<h1>Users</h1>
|
|
|
|
<div className="row" style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by email…"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setPage(1);
|
|
setSearch(e.target.value);
|
|
}}
|
|
style={{ maxWidth: 320 }}
|
|
/>
|
|
<button className="secondary btn-sm" onClick={load} type="button">
|
|
Refresh
|
|
</button>
|
|
<div className="toolbar-actions">
|
|
<button
|
|
className="secondary btn-sm"
|
|
type="button"
|
|
onClick={exportCsv}
|
|
>
|
|
Export CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="error">{error}</p>}
|
|
{notice && <p className="success">{notice}</p>}
|
|
|
|
{selectedCount > 0 && (
|
|
<div className="bulk-bar">
|
|
<span className="bulk-count">{selectedCount} selected</span>
|
|
<button
|
|
className="secondary btn-sm"
|
|
type="button"
|
|
disabled={bulkBusy}
|
|
onClick={() => runBulk('Banned', (u) => banOne(u, true))}
|
|
>
|
|
Ban
|
|
</button>
|
|
<button
|
|
className="secondary btn-sm"
|
|
type="button"
|
|
disabled={bulkBusy}
|
|
onClick={() => runBulk('Unbanned', (u) => banOne(u, false))}
|
|
>
|
|
Unban
|
|
</button>
|
|
<button
|
|
className="btn-danger btn-sm"
|
|
type="button"
|
|
disabled={bulkBusy}
|
|
onClick={() =>
|
|
runBulk(
|
|
'Deleted',
|
|
deleteOne,
|
|
`Delete ${selectedCount} selected user(s)? This cannot be undone. Your own account is skipped.`,
|
|
)
|
|
}
|
|
>
|
|
Delete
|
|
</button>
|
|
<button
|
|
className="secondary btn-sm"
|
|
type="button"
|
|
disabled={bulkBusy}
|
|
onClick={() => setSelected(new Set())}
|
|
>
|
|
Clear
|
|
</button>
|
|
{bulkProgress && <span className="muted">{bulkProgress}</span>}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<p className="muted">Loading…</p>
|
|
) : users.length === 0 ? (
|
|
<p className="muted">No users found.</p>
|
|
) : (
|
|
<div className="admin-table-wrap">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="col-check">
|
|
<input
|
|
type="checkbox"
|
|
aria-label="Select all on page"
|
|
checked={allSelected}
|
|
onChange={toggleAll}
|
|
/>
|
|
</th>
|
|
<SortHeader
|
|
label="Email"
|
|
col="email"
|
|
sort={sort}
|
|
order={order}
|
|
onSort={onSort}
|
|
/>
|
|
<SortHeader
|
|
label="Role"
|
|
col="role"
|
|
sort={sort}
|
|
order={order}
|
|
onSort={onSort}
|
|
/>
|
|
<th>Status</th>
|
|
<th>Tunnel</th>
|
|
<th>Usage</th>
|
|
<SortHeader
|
|
label="Last sign-in"
|
|
col="last_sign_in_at"
|
|
sort={sort}
|
|
order={order}
|
|
onSort={onSort}
|
|
/>
|
|
<SortHeader
|
|
label="Created"
|
|
col="created_at"
|
|
sort={sort}
|
|
order={order}
|
|
onSort={onSort}
|
|
/>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u) => (
|
|
<tr
|
|
key={u.id}
|
|
className={selected.has(u.id) ? 'row-selected' : undefined}
|
|
>
|
|
<td className="col-check">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Select ${u.email ?? u.id}`}
|
|
checked={selected.has(u.id)}
|
|
onChange={() => toggleRow(u.id)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<Link href={`/admin/users/${u.id}`}>
|
|
{u.email ?? u.id}
|
|
</Link>
|
|
</td>
|
|
<td>
|
|
{u.role === 'admin' ? (
|
|
<span className="badge badge-admin">admin</span>
|
|
) : (
|
|
<span className="badge">user</span>
|
|
)}
|
|
</td>
|
|
<td>
|
|
{isBanned(u) ? (
|
|
<span className="badge badge-banned">banned</span>
|
|
) : u.email_confirmed_at ? (
|
|
<span className="badge badge-ok">confirmed</span>
|
|
) : (
|
|
<span className="badge">unconfirmed</span>
|
|
)}
|
|
</td>
|
|
<td>{u.tunnel ? u.tunnel.subdomain : '—'}</td>
|
|
<td>
|
|
{u.tunnel
|
|
? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes(
|
|
u.tunnel.quota_bytes,
|
|
)}`
|
|
: '—'}
|
|
</td>
|
|
<td>{formatDate(u.last_sign_in_at)}</td>
|
|
<td>{formatDate(u.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="row" style={{ marginTop: '1rem' }}>
|
|
<button
|
|
className="secondary btn-sm"
|
|
type="button"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
Prev
|
|
</button>
|
|
<span className="muted">
|
|
Page {page} of {totalPages} ({total} total)
|
|
</span>
|
|
<button
|
|
className="secondary btn-sm"
|
|
type="button"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|