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:
Gerhard Scheikl
2026-05-31 14:46:22 +02:00
parent 1adb6e7b3f
commit d317e8c758
22 changed files with 1296 additions and 173 deletions
+64 -11
View File
@@ -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>
+64
View File
@@ -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();
}
+212 -13
View File
@@ -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 ?? '—'}
+9 -1
View File
@@ -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
View File
@@ -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>
))}
+73
View File
@@ -0,0 +1,73 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getAuditList } from '@/lib/admin/list';
import { logAdminAction } from '@/lib/auth/audit';
import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const url = new URL(req.url);
const action = url.searchParams.get('action') ?? '';
const targetType = url.searchParams.get('target_type') ?? '';
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
// Respect action/target_type filters + sort, full set (capped). Audit
// details already exclude secrets (no tokens are ever logged).
const { entries } = await getAuditList({
page: 1,
perPage: EXPORT_MAX_ROWS,
action,
targetType,
sort,
order,
});
const header = [
'created_at',
'actor_email',
'action',
'target_type',
'target_id',
'details',
];
const rows = entries.map((e) => [
e.created_at,
e.actor_email ?? '',
e.action,
e.target_type ?? '',
e.target_id ?? '',
JSON.stringify(e.details ?? {}),
]);
const csv = toCsv(header, rows);
await logAdminAction(auth.user, {
action: 'audit.export',
target_type: 'audit',
details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS },
});
return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="audit.csv"',
'Cache-Control': 'no-store',
Pragma: 'no-cache',
},
});
} catch (e) {
console.error('admin audit export failed', e);
return NextResponse.json(
{ error: 'internal error' },
{ status: 500, headers: { 'Cache-Control': 'no-store' } },
);
}
}
+4
View File
@@ -16,6 +16,8 @@ export async function GET(req: NextRequest) {
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100);
const action = url.searchParams.get('action') ?? '';
const targetType = url.searchParams.get('target_type') ?? '';
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
const { entries, total } = await getAuditList({
@@ -23,6 +25,8 @@ export async function GET(req: NextRequest) {
perPage,
action,
targetType,
sort,
order,
});
return jsonNoStore({ entries, total, page, perPage });
} catch (e) {
+6 -4
View File
@@ -3,7 +3,7 @@ import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { logAdminAction } from '@/lib/auth/audit';
import { isUuid, parseBoolean } from '@/lib/admin/validators';
import { redisSet } from '@/lib/redis';
import { setTunnelActive } from '@/lib/redis';
import { jsonNoStore } from '@/lib/admin/response';
export const runtime = 'nodejs';
@@ -50,14 +50,16 @@ export async function POST(
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
}
// Best-effort live kill-switch (never throws).
await redisSet(`tunnel:active:${data.subdomain}`, isActive ? '1' : '0');
// Best-effort live kill-switch (never throws). Writes tunnel:active:<sub>
// = "1"/"0" with TTL so the edge gate drops/allows a live connection within
// ~1s. No-op when REDIS_URL is unset.
const redisOk = await setTunnelActive(data.subdomain, isActive);
await logAdminAction(auth.user, {
action: isActive ? 'tunnel.activate' : 'tunnel.deactivate',
target_type: 'tunnel',
target_id: id,
details: { subdomain: data.subdomain, is_active: isActive },
details: { subdomain: data.subdomain, is_active: isActive, redis: redisOk },
});
return jsonNoStore({ ok: true, is_active: isActive });
+19 -3
View File
@@ -5,6 +5,7 @@ import { logAdminAction } from '@/lib/auth/audit';
import { isUuid } from '@/lib/admin/validators';
import { validateSubdomain } from '@/lib/validation';
import { isSubdomainReserved } from '@/lib/admin/reserved';
import { setTunnelActive } from '@/lib/redis';
import { jsonNoStore } from '@/lib/admin/response';
export const runtime = 'nodejs';
@@ -49,13 +50,22 @@ export async function POST(
// Reject if taken by a different tunnel (keyed by owner user_id).
const { data: existing } = await admin
.from('tunnels')
.select('user_id')
.select('user_id, subdomain')
.eq('subdomain', subdomain)
.maybeSingle<{ user_id: string }>();
.maybeSingle<{ user_id: string; subdomain: string }>();
if (existing && existing.user_id !== id) {
return jsonNoStore({ error: 'subdomain taken' }, { status: 409 });
}
// Capture the current subdomain so we can drop the OLD hostname's live
// connection once it is freed by the rename.
const { data: current } = await admin
.from('tunnels')
.select('subdomain')
.eq('user_id', id)
.maybeSingle<{ subdomain: string }>();
const oldSubdomain = current?.subdomain ?? null;
const { data, error } = await admin
.from('tunnels')
.update({ subdomain })
@@ -74,11 +84,17 @@ export async function POST(
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
}
// Best-effort: drop any live connection on the OLD subdomain so the former
// hostname stops resolving as active. No-op when REDIS_URL is unset.
if (oldSubdomain && oldSubdomain !== subdomain) {
await setTunnelActive(oldSubdomain, false);
}
await logAdminAction(auth.user, {
action: 'tunnel.reassign',
target_type: 'tunnel',
target_id: id,
details: { subdomain },
details: { subdomain, previous_subdomain: oldSubdomain },
});
return jsonNoStore({ ok: true, subdomain });
+5 -4
View File
@@ -3,7 +3,7 @@ import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { logAdminAction } from '@/lib/auth/audit';
import { isUuid } from '@/lib/admin/validators';
import { redisSet } from '@/lib/redis';
import { setTunnelActive } from '@/lib/redis';
import { jsonNoStore } from '@/lib/admin/response';
export const runtime = 'nodejs';
@@ -36,14 +36,15 @@ export async function DELETE(
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
}
// Best-effort live kill-switch.
await redisSet(`tunnel:active:${data.subdomain}`, '0');
// Best-effort live kill-switch: write "0" so any live connection on the
// freed subdomain drops within ~1s. No-op when REDIS_URL is unset.
const redisOk = await setTunnelActive(data.subdomain, false);
await logAdminAction(auth.user, {
action: 'tunnel.delete',
target_type: 'tunnel',
target_id: id,
details: { subdomain: data.subdomain },
details: { subdomain: data.subdomain, redis: redisOk },
});
return jsonNoStore({ ok: true });
+79
View File
@@ -0,0 +1,79 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getTunnelsList } from '@/lib/admin/list';
import { logAdminAction } from '@/lib/auth/audit';
import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const url = new URL(req.url);
const search = url.searchParams.get('search') ?? '';
const status = url.searchParams.get('status');
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
// Respect search/status/sort but pull the full matching set (capped).
// SECURITY: the tunnel token is NEVER selected or exported.
const { tunnels } = await getTunnelsList({
page: 1,
perPage: EXPORT_MAX_ROWS,
search,
status,
sort,
order,
});
const header = [
'user_id',
'owner_email',
'subdomain',
'is_active',
'bytes_used',
'quota_bytes',
'usage_pct',
'last_seen_at',
'created_at',
];
const rows = tunnels.map((t) => [
t.user_id,
t.owner_email ?? '',
t.subdomain,
t.is_active ? 'true' : 'false',
t.bytes_used,
t.quota_bytes,
t.usage_pct.toFixed(1),
t.last_seen_at ?? '',
t.created_at,
]);
const csv = toCsv(header, rows);
await logAdminAction(auth.user, {
action: 'tunnel.export',
target_type: 'tunnel',
details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS },
});
return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="tunnels.csv"',
'Cache-Control': 'no-store',
Pragma: 'no-cache',
},
});
} catch (e) {
console.error('admin tunnels export failed', e);
return NextResponse.json(
{ error: 'internal error' },
{ status: 500, headers: { 'Cache-Control': 'no-store' } },
);
}
}
+4
View File
@@ -16,6 +16,8 @@ export async function GET(req: NextRequest) {
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
const search = url.searchParams.get('search') ?? '';
const status = url.searchParams.get('status'); // active|inactive|over_quota
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
const { tunnels, total } = await getTunnelsList({
@@ -23,6 +25,8 @@ export async function GET(req: NextRequest) {
perPage,
search,
status,
sort,
order,
});
return jsonNoStore({ tunnels, total, page, perPage });
} catch (e) {
+85
View File
@@ -0,0 +1,85 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getUsersList } from '@/lib/admin/list';
import { logAdminAction } from '@/lib/auth/audit';
import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function isBanned(banned_until: string | null): boolean {
return !!banned_until && new Date(banned_until).getTime() > Date.now();
}
export async function GET(req: NextRequest) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const url = new URL(req.url);
const search = url.searchParams.get('search') ?? '';
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
// Respect search/sort but pull the full matching set (capped). NOTE: never
// export secrets — the users list carries no token.
const { users } = await getUsersList({
page: 1,
perPage: EXPORT_MAX_ROWS,
search,
sort,
order,
});
const header = [
'email',
'role',
'status',
'tunnel_subdomain',
'tunnel_active',
'bytes_used',
'quota_bytes',
'created_at',
'last_sign_in_at',
];
const rows = users.map((u) => [
u.email ?? '',
u.role,
isBanned(u.banned_until)
? 'banned'
: u.email_confirmed_at
? 'confirmed'
: 'unconfirmed',
u.tunnel?.subdomain ?? '',
u.tunnel ? (u.tunnel.is_active ? 'true' : 'false') : '',
u.tunnel?.bytes_used ?? '',
u.tunnel?.quota_bytes ?? '',
u.created_at,
u.last_sign_in_at ?? '',
]);
const csv = toCsv(header, rows);
await logAdminAction(auth.user, {
action: 'user.export',
target_type: 'user',
details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS },
});
return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="users.csv"',
'Cache-Control': 'no-store',
Pragma: 'no-cache',
},
});
} catch (e) {
console.error('admin users export failed', e);
return NextResponse.json(
{ error: 'internal error' },
{ status: 500, headers: { 'Cache-Control': 'no-store' } },
);
}
}
+9 -1
View File
@@ -15,9 +15,17 @@ export async function GET(req: NextRequest) {
const page = parsePageParam(url.searchParams.get('page'), 1);
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
const search = url.searchParams.get('search') ?? '';
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
const { users, total } = await getUsersList({ page, perPage, search });
const { users, total } = await getUsersList({
page,
perPage,
search,
sort,
order,
});
return jsonNoStore({ users, total, page, perPage });
} catch (e) {
console.error('admin users list failed', e);
+63
View File
@@ -365,3 +365,66 @@ button:disabled,
select {
font-size: 1rem;
}
/* Sortable table headers */
.th-sort {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.th-sort:hover {
color: var(--fg);
}
.th-sort .sort-ind {
margin-left: 0.3rem;
opacity: 0.5;
font-size: 0.7rem;
}
.th-sort.sorted .sort-ind {
opacity: 1;
color: var(--accent);
}
/* Checkbox / selection column */
.admin-table th.col-check,
.admin-table td.col-check {
width: 1%;
white-space: nowrap;
text-align: center;
}
.admin-table input[type='checkbox'] {
width: auto;
margin: 0;
cursor: pointer;
accent-color: var(--accent);
}
.admin-table tr.row-selected td {
background: rgba(59, 130, 246, 0.08);
}
/* Bulk action bar */
.bulk-bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0 0 1rem;
padding: 0.6rem 0.85rem;
background: var(--card);
border: 1px solid var(--accent);
border-radius: 8px;
}
.bulk-bar .bulk-count {
font-weight: 600;
margin-right: 0.25rem;
}
.bulk-bar .spacer {
flex: 1 1 auto;
}
.toolbar-actions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}