feat(admin): comprehensive admin interface (users, tunnels, metrics, audit, reserved subdomains)

Adds an authenticated admin surface gated by auth.users.app_metadata.role==='admin'.

- lib/auth/admin-guard.ts: requireAdmin() (pages) + requireAdminApi() (routes)
- middleware.ts: defense-in-depth /admin and /api/admin guarding
- API: users (list/detail/role/ban/delete), tunnels (list + active/quota/reset/reassign/regenerate-token/delete), metrics, audit log, reserved subdomains
- Self-lockout prevention (no self demote/ban/delete)
- Best-effort Redis kill-switch via dependency-free net-socket client (REDIS_URL)
- admin_audit_log + reserved_subdomains migration (RLS on, service-role only)
- Admin UI (overview, users, tunnels, reserved, audit) + conditional nav link
This commit is contained in:
Gerhard Scheikl
2026-05-31 10:58:23 +02:00
parent aad01f1fc5
commit fb4880a1d9
36 changed files with 2936 additions and 2 deletions
+78
View File
@@ -0,0 +1,78 @@
import { NextResponse, type NextRequest } from 'next/server';
import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type TunnelRow = {
user_id: string;
subdomain: string;
is_active: boolean;
bytes_used: number;
quota_bytes: number;
};
export async function GET(req: NextRequest) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const url = new URL(req.url);
const page = parsePageParam(url.searchParams.get('page'), 1);
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
const search = (url.searchParams.get('search') ?? '').trim().toLowerCase();
const admin = getSupabaseAdmin();
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
let users = data.users;
const total = (data as unknown as { total?: number }).total ?? users.length;
if (search) {
users = users.filter((u) =>
(u.email ?? '').toLowerCase().includes(search),
);
}
// Join tunnel rows for this page's users in a single query.
const ids = users.map((u) => u.id);
const tunnelMap = new Map<string, TunnelRow>();
if (ids.length > 0) {
const { data: tunnels } = await admin
.from('tunnels')
.select('user_id, subdomain, is_active, bytes_used, quota_bytes')
.in('user_id', ids);
for (const t of (tunnels ?? []) as TunnelRow[]) {
tunnelMap.set(t.user_id, t);
}
}
const result = users.map((u) => {
const t = tunnelMap.get(u.id) ?? null;
return {
id: u.id,
email: u.email ?? null,
role: (u.app_metadata?.role as string | undefined) ?? 'user',
banned_until: (u as unknown as { banned_until?: string | null })
.banned_until ?? null,
email_confirmed_at: u.email_confirmed_at ?? null,
created_at: u.created_at,
last_sign_in_at: u.last_sign_in_at ?? null,
tunnel: t
? {
subdomain: t.subdomain,
is_active: t.is_active,
bytes_used: t.bytes_used,
quota_bytes: t.quota_bytes,
}
: null,
};
});
return NextResponse.json({ users: result, total, page, perPage });
}