Files
linumiq_net-web_app/app/api/admin/reserved/route.ts
T
Gerhard Scheikl fb4880a1d9 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
2026-05-31 10:58:23 +02:00

96 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse, type NextRequest } from 'next/server';
import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { logAdminAction } from '@/lib/auth/audit';
import { RESERVED_SUBDOMAINS } from '@/lib/validation';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const NAME_RE = /^[a-z0-9-]{1,63}$/;
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const admin = getSupabaseAdmin();
const { data, error } = await admin
.from('reserved_subdomains')
.select('name, created_at')
.order('name', { ascending: true });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({
reserved: data ?? [],
hardcoded: Array.from(RESERVED_SUBDOMAINS).sort(),
});
}
export async function POST(req: NextRequest) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
let body: { name?: unknown };
try {
body = (await req.json()) as { name?: unknown };
} catch {
return NextResponse.json({ error: 'invalid json' }, { status: 400 });
}
if (typeof body.name !== 'string') {
return NextResponse.json({ error: 'name must be a string' }, { status: 400 });
}
const name = body.name.trim().toLowerCase();
if (!NAME_RE.test(name)) {
return NextResponse.json(
{ error: 'name must be 163 chars, lowercase az, 09, hyphen' },
{ status: 400 },
);
}
const admin = getSupabaseAdmin();
const { error } = await admin
.from('reserved_subdomains')
.upsert({ name }, { onConflict: 'name' });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
await logAdminAction(auth.user, {
action: 'reserved.add',
target_type: 'reserved_subdomain',
target_id: name,
});
return NextResponse.json({ ok: true, name });
}
export async function DELETE(req: NextRequest) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const url = new URL(req.url);
const name = (url.searchParams.get('name') ?? '').trim().toLowerCase();
if (!name) {
return NextResponse.json({ error: 'name is required' }, { status: 400 });
}
const admin = getSupabaseAdmin();
const { error } = await admin
.from('reserved_subdomains')
.delete()
.eq('name', name);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
await logAdminAction(auth.user, {
action: 'reserved.remove',
target_type: 'reserved_subdomain',
target_id: name,
});
return NextResponse.json({ ok: true });
}