feat(admin): live redis kill-switch on tunnel actions, sortable columns + CSV export + bulk actions, Node 24 LTS
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.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import type { AuditItem } from '@/lib/admin/list';
|
||||
import { SortHeader, downloadUrl, type SortOrder } from '../table-ui';
|
||||
|
||||
const PER_PAGE = 50;
|
||||
|
||||
@@ -18,20 +19,28 @@ export function AuditTable({
|
||||
const [page, setPage] = useState(1);
|
||||
const [action, setAction] = useState('');
|
||||
const [targetType, setTargetType] = 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 queryParams = useCallback(() => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: String(PER_PAGE),
|
||||
sort,
|
||||
order,
|
||||
});
|
||||
if (action.trim()) params.set('action', action.trim());
|
||||
if (targetType.trim()) params.set('target_type', targetType.trim());
|
||||
return params;
|
||||
}, [page, action, targetType, sort, order]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: String(PER_PAGE),
|
||||
});
|
||||
if (action.trim()) params.set('action', action.trim());
|
||||
if (targetType.trim()) params.set('target_type', targetType.trim());
|
||||
const res = await fetch(`/api/admin/audit?${params.toString()}`, {
|
||||
const res = await fetch(`/api/admin/audit?${queryParams().toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -49,7 +58,7 @@ export function AuditTable({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, action, targetType]);
|
||||
}, [queryParams]);
|
||||
|
||||
// First page is server-rendered; skip the on-mount fetch to avoid racing the
|
||||
// SSR session-cookie refresh (which intermittently 401'd).
|
||||
@@ -63,6 +72,23 @@ export function AuditTable({
|
||||
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 exportCsv() {
|
||||
const params = new URLSearchParams({ sort, order });
|
||||
if (action.trim()) params.set('action', action.trim());
|
||||
if (targetType.trim()) params.set('target_type', targetType.trim());
|
||||
downloadUrl(`/api/admin/audit/export?${params.toString()}`);
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
|
||||
return (
|
||||
@@ -93,6 +119,15 @@ export function AuditTable({
|
||||
<button className="secondary btn-sm" type="button" onClick={load}>
|
||||
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>}
|
||||
@@ -106,9 +141,27 @@ export function AuditTable({
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<SortHeader
|
||||
label="When"
|
||||
col="created_at"
|
||||
sort={sort}
|
||||
order={order}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label="Actor"
|
||||
col="actor_email"
|
||||
sort={sort}
|
||||
order={order}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label="Action"
|
||||
col="action"
|
||||
sort={sort}
|
||||
order={order}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<th>Target</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Small shared client helpers for the admin tables: a clickable sortable
|
||||
* column header and a CSV-download trigger. Kept dependency-free and
|
||||
* dark-theme consistent with globals.css.
|
||||
*/
|
||||
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export function SortHeader({
|
||||
label,
|
||||
col,
|
||||
sort,
|
||||
order,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
col: string;
|
||||
sort: string;
|
||||
order: SortOrder;
|
||||
onSort: (col: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const active = sort === col;
|
||||
const indicator = active ? (order === 'asc' ? '▲' : '▼') : '↕';
|
||||
return (
|
||||
<th
|
||||
className={`th-sort${active ? ' sorted' : ''}${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
onClick={() => onSort(col)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-sort={active ? (order === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSort(col);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span className="sort-ind" aria-hidden="true">
|
||||
{indicator}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a browser download of a same-origin URL. The server sets
|
||||
* Content-Disposition: attachment, so the cookie-authenticated GET streams the
|
||||
* CSV straight to a file.
|
||||
*/
|
||||
export function downloadUrl(url: string): void {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { formatBytes, formatDate } from '@/lib/format';
|
||||
import type { TunnelItem } from '@/lib/admin/list';
|
||||
import { SortHeader, downloadUrl, type SortOrder } from '../table-ui';
|
||||
|
||||
const PER_PAGE = 25;
|
||||
|
||||
type BulkResult = { ok: number; fail: number };
|
||||
|
||||
export function TunnelsTable({
|
||||
initialTunnels,
|
||||
initialTotal,
|
||||
@@ -18,22 +21,33 @@ export function TunnelsTable({
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = 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 [busyId, setBusyId] = 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());
|
||||
if (status) params.set('status', status);
|
||||
return params;
|
||||
}, [page, search, status, sort, order]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: String(PER_PAGE),
|
||||
});
|
||||
if (search.trim()) params.set('search', search.trim());
|
||||
if (status) params.set('status', status);
|
||||
const res = await fetch(`/api/admin/tunnels?${params.toString()}`, {
|
||||
const res = await fetch(`/api/admin/tunnels?${queryParams().toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -43,12 +57,13 @@ export function TunnelsTable({
|
||||
const data = (await res.json()) as { tunnels: TunnelItem[]; total: number };
|
||||
setTunnels(data.tunnels);
|
||||
setTotal(data.total);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, status]);
|
||||
}, [queryParams]);
|
||||
|
||||
// First page is server-rendered; skip the on-mount fetch to avoid racing the
|
||||
// SSR session-cookie refresh (which intermittently 401'd).
|
||||
@@ -62,6 +77,16 @@ export function TunnelsTable({
|
||||
return () => clearTimeout(t);
|
||||
}, [load]);
|
||||
|
||||
function onSort(col: string) {
|
||||
setPage(1);
|
||||
if (col === sort) {
|
||||
setOrder((o) => (o === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSort(col);
|
||||
setOrder('asc');
|
||||
}
|
||||
}
|
||||
|
||||
async function act(
|
||||
id: string,
|
||||
label: string,
|
||||
@@ -172,7 +197,82 @@ export function TunnelsTable({
|
||||
);
|
||||
}
|
||||
|
||||
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 = tunnels.map((t) => t.user_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: (t: TunnelItem) => Promise<boolean>,
|
||||
confirmMsg?: string,
|
||||
) {
|
||||
const targets = tunnels.filter((t) => selected.has(t.user_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 };
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
setBulkProgress(`${label}: ${i + 1}/${targets.length}…`);
|
||||
try {
|
||||
const ok = await perItem(targets[i]);
|
||||
if (ok) result.ok++;
|
||||
else result.fail++;
|
||||
} catch {
|
||||
result.fail++;
|
||||
}
|
||||
}
|
||||
setBulkProgress(null);
|
||||
setBulkBusy(false);
|
||||
const parts = [`${result.ok} ${label.toLowerCase()}`];
|
||||
if (result.fail) parts.push(`${result.fail} failed`);
|
||||
setNotice(parts.join(', '));
|
||||
await load();
|
||||
}
|
||||
|
||||
async function setActiveOne(t: TunnelItem, active: boolean): Promise<boolean> {
|
||||
const res = await fetch(`/api/admin/tunnels/${t.user_id}/active`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: active }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function deleteOne(t: TunnelItem): Promise<boolean> {
|
||||
const res = await fetch(`/api/admin/tunnels/${t.user_id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const params = new URLSearchParams({ sort, order });
|
||||
if (search.trim()) params.set('search', search.trim());
|
||||
if (status) params.set('status', status);
|
||||
downloadUrl(`/api/admin/tunnels/export?${params.toString()}`);
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
const selectedCount = selected.size;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -211,11 +311,67 @@ export function TunnelsTable({
|
||||
<button className="secondary btn-sm" type="button" onClick={load}>
|
||||
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('Activated', (t) => setActiveOne(t, true))}
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
<button
|
||||
className="secondary btn-sm"
|
||||
type="button"
|
||||
disabled={bulkBusy}
|
||||
onClick={() =>
|
||||
runBulk('Deactivated', (t) => setActiveOne(t, false))
|
||||
}
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
<button
|
||||
className="btn-danger btn-sm"
|
||||
type="button"
|
||||
disabled={bulkBusy}
|
||||
onClick={() =>
|
||||
runBulk(
|
||||
'Deleted',
|
||||
deleteOne,
|
||||
`Delete ${selectedCount} selected tunnel(s)? This frees their subdomains.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
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>
|
||||
) : tunnels.length === 0 ? (
|
||||
@@ -225,17 +381,60 @@ export function TunnelsTable({
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subdomain</th>
|
||||
<th className="col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select all on page"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<SortHeader
|
||||
label="Subdomain"
|
||||
col="subdomain"
|
||||
sort={sort}
|
||||
order={order}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<th>Owner</th>
|
||||
<th>Status</th>
|
||||
<th>Usage</th>
|
||||
<th>Last seen</th>
|
||||
<SortHeader
|
||||
label="Status"
|
||||
col="is_active"
|
||||
sort={sort}
|
||||
order={order}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label="Usage"
|
||||
col="usage_pct"
|
||||
sort={sort}
|
||||
order={order}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortHeader
|
||||
label="Last seen"
|
||||
col="last_seen_at"
|
||||
sort={sort}
|
||||
order={order}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tunnels.map((t) => (
|
||||
<tr key={t.user_id}>
|
||||
<tr
|
||||
key={t.user_id}
|
||||
className={selected.has(t.user_id) ? 'row-selected' : undefined}
|
||||
>
|
||||
<td className="col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${t.subdomain}`}
|
||||
checked={selected.has(t.user_id)}
|
||||
onChange={() => toggleRow(t.user_id)}
|
||||
/>
|
||||
</td>
|
||||
<td>{t.subdomain}</td>
|
||||
<td style={{ wordBreak: 'break-all' }}>
|
||||
{t.owner_email ?? '—'}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getUsersList } from '@/lib/admin/list';
|
||||
import { requireAdmin } from '@/lib/auth/admin-guard';
|
||||
import { UsersTable } from './users-table';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -10,11 +11,18 @@ export default async function AdminUsersPage() {
|
||||
// Initial load runs on the server (the admin session is already validated by
|
||||
// the admin layout's requireAdmin()), so the first paint never races the
|
||||
// client session-cookie refresh.
|
||||
const admin = await requireAdmin();
|
||||
const { users, total } = await getUsersList({
|
||||
page: 1,
|
||||
perPage: PER_PAGE,
|
||||
search: '',
|
||||
});
|
||||
|
||||
return <UsersTable initialUsers={users} initialTotal={total} />;
|
||||
return (
|
||||
<UsersTable
|
||||
initialUsers={users}
|
||||
initialTotal={total}
|
||||
currentUserId={admin.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+223
-12
@@ -4,6 +4,7 @@ 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;
|
||||
|
||||
@@ -11,30 +12,46 @@ 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 params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: String(PER_PAGE),
|
||||
});
|
||||
if (search.trim()) params.set('search', search.trim());
|
||||
const res = await fetch(`/api/admin/users?${params.toString()}`, {
|
||||
const res = await fetch(`/api/admin/users?${queryParams().toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -47,12 +64,13 @@ export function UsersTable({
|
||||
};
|
||||
setUsers(data.users);
|
||||
setTotal(data.total);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search]);
|
||||
}, [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
|
||||
@@ -67,13 +85,106 @@ export function UsersTable({
|
||||
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' }}>
|
||||
<div className="row" style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email…"
|
||||
@@ -87,9 +198,64 @@ export function UsersTable({
|
||||
<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>
|
||||
@@ -100,17 +266,61 @@ export function UsersTable({
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<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>
|
||||
<th>Created</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}>
|
||||
<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}
|
||||
@@ -140,6 +350,7 @@ export function UsersTable({
|
||||
)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td>{formatDate(u.last_sign_in_at)}</td>
|
||||
<td>{formatDate(u.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user