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:
@@ -0,0 +1,80 @@
|
||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||
|
||||
export type AdminMetrics = {
|
||||
totalUsers: number;
|
||||
totalTunnels: number;
|
||||
activeTunnels: number;
|
||||
inactiveTunnels: number;
|
||||
overQuota: number;
|
||||
bytesUsedTotal: number;
|
||||
quotaTotal: number;
|
||||
signups7d: number;
|
||||
signups30d: number;
|
||||
recentlyActive: number;
|
||||
};
|
||||
|
||||
type TunnelAgg = {
|
||||
is_active: boolean;
|
||||
bytes_used: number;
|
||||
quota_bytes: number;
|
||||
last_seen_at: string | null;
|
||||
};
|
||||
|
||||
export async function computeMetrics(): Promise<AdminMetrics> {
|
||||
const admin = getSupabaseAdmin();
|
||||
|
||||
const { data: tunnelsData } = await admin
|
||||
.from('tunnels')
|
||||
.select('is_active, bytes_used, quota_bytes, last_seen_at');
|
||||
const tunnels = (tunnelsData ?? []) as TunnelAgg[];
|
||||
|
||||
const now = Date.now();
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
let activeTunnels = 0;
|
||||
let inactiveTunnels = 0;
|
||||
let overQuota = 0;
|
||||
let bytesUsedTotal = 0;
|
||||
let quotaTotal = 0;
|
||||
let recentlyActive = 0;
|
||||
for (const t of tunnels) {
|
||||
if (t.is_active) activeTunnels++;
|
||||
else inactiveTunnels++;
|
||||
if (t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes) overQuota++;
|
||||
bytesUsedTotal += Number(t.bytes_used) || 0;
|
||||
quotaTotal += Number(t.quota_bytes) || 0;
|
||||
if (t.last_seen_at && now - new Date(t.last_seen_at).getTime() <= day) {
|
||||
recentlyActive++;
|
||||
}
|
||||
}
|
||||
|
||||
let totalUsers = 0;
|
||||
let signups7d = 0;
|
||||
let signups30d = 0;
|
||||
const perPage = 1000;
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
|
||||
if (error) break;
|
||||
const users = data.users;
|
||||
if (users.length === 0) break;
|
||||
totalUsers += users.length;
|
||||
for (const u of users) {
|
||||
const created = new Date(u.created_at).getTime();
|
||||
if (now - created <= 7 * day) signups7d++;
|
||||
if (now - created <= 30 * day) signups30d++;
|
||||
}
|
||||
if (users.length < perPage) break;
|
||||
}
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
totalTunnels: tunnels.length,
|
||||
activeTunnels,
|
||||
inactiveTunnels,
|
||||
overQuota,
|
||||
bytesUsedTotal,
|
||||
quotaTotal,
|
||||
signups7d,
|
||||
signups30d,
|
||||
recentlyActive,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||
import { RESERVED_SUBDOMAINS } from '@/lib/validation';
|
||||
|
||||
/**
|
||||
* Returns true if the subdomain is reserved in EITHER the hardcoded fallback
|
||||
* set or the reserved_subdomains DB table.
|
||||
*/
|
||||
export async function isSubdomainReserved(subdomain: string): Promise<boolean> {
|
||||
if (RESERVED_SUBDOMAINS.has(subdomain)) return true;
|
||||
const admin = getSupabaseAdmin();
|
||||
const { data } = await admin
|
||||
.from('reserved_subdomains')
|
||||
.select('name')
|
||||
.eq('name', subdomain)
|
||||
.maybeSingle<{ name: string }>();
|
||||
return !!data;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Small manual validators for admin API inputs (zod is intentionally not a
|
||||
* dependency). Each returns a discriminated result.
|
||||
*/
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuid(v: unknown): v is string {
|
||||
return typeof v === 'string' && UUID_RE.test(v);
|
||||
}
|
||||
|
||||
export function parseBoolean(v: unknown): boolean | null {
|
||||
if (typeof v === 'boolean') return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parsePositiveInt(
|
||||
v: unknown,
|
||||
max: number,
|
||||
): { ok: true; value: number } | { ok: false; error: string } {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v) || !Number.isInteger(v)) {
|
||||
return { ok: false, error: 'must be an integer' };
|
||||
}
|
||||
if (v <= 0) return { ok: false, error: 'must be positive' };
|
||||
if (v > max) return { ok: false, error: `must be <= ${max}` };
|
||||
return { ok: true, value: v };
|
||||
}
|
||||
|
||||
export function parsePageParam(v: string | null, fallback = 1): number {
|
||||
const n = v ? Number(v) : NaN;
|
||||
if (!Number.isFinite(n) || n < 1) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
export function parsePerPageParam(
|
||||
v: string | null,
|
||||
fallback = 25,
|
||||
max = 100,
|
||||
): number {
|
||||
const n = v ? Number(v) : NaN;
|
||||
if (!Number.isFinite(n) || n < 1) return fallback;
|
||||
return Math.min(max, Math.floor(n));
|
||||
}
|
||||
Reference in New Issue
Block a user