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:
@@ -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' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user