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
+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);