import type { User } from '@supabase/supabase-js'; import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { withAdminRetry, mapWithConcurrency } from '@/lib/admin/retry'; import { parseOrder, parseSort, USER_SORTS, TUNNEL_SORTS, AUDIT_SORTS, type SortOrder, type UserSort, type TunnelSort, } from '@/lib/admin/sort'; /** * Shared admin list-query logic, used by BOTH the JSON route handlers and the * server components that render the initial page of each list. Doing the first * load on the server (where the admin session is already validated by * `requireAdmin()`) avoids the on-mount client fetch racing the SSR session * cookie refresh, which previously produced intermittent 401s. */ export type AdminUserItem = { id: string; email: string | null; role: string; banned_until: string | null; email_confirmed_at: string | null; created_at: string; last_sign_in_at: string | null; tunnel: { subdomain: string; is_active: boolean; bytes_used: number; quota_bytes: number; } | null; }; export type TunnelItem = { user_id: string; owner_email: string | null; subdomain: string; is_active: boolean; bytes_used: number; quota_bytes: number; usage_pct: number; last_seen_at: string | null; created_at: string; }; export type AuditItem = { id: number; actor_id: string | null; actor_email: string | null; action: string; target_type: string | null; target_id: string | null; details: Record; created_at: string; }; type TunnelJoinRow = { user_id: string; subdomain: string; is_active: boolean; bytes_used: number; quota_bytes: number; }; type TunnelRow = TunnelJoinRow & { last_seen_at: string | null; created_at: string; }; // Bound the full-email-scan search: 50 pages * 1000 = up to 50k users. Beyond // this we stop scanning (extremely unlikely for this deployment's scale). const USER_SCAN_MAX_PAGES = 50; const USER_SCAN_PER_PAGE = 1000; // Resolve tunnel owner emails a couple at a time rather than all at once. The // upstream tolerates a small concurrent burst, but a large fan-out of // getUserById calls (one per row, all in flight) trips an upstream throttle that // truncates the tail of the burst into empty/partial bodies. Those truncations // arrive faster than the per-call retry window can clear them, so a few rows // were deterministically rendered as "—" on every list load. A low concurrency // keeps each lookup inside the throttle allowance; measured 0 failures at 2 and // consistent truncations at 4, so we stay conservative here. const OWNER_EMAIL_CONCURRENCY = 2; function userSortValue(u: User, sort: UserSort): string | number { switch (sort) { case 'email': return (u.email ?? '').toLowerCase(); case 'role': return ((u.app_metadata?.role as string | undefined) ?? 'user').toLowerCase(); case 'last_sign_in_at': return u.last_sign_in_at ? Date.parse(u.last_sign_in_at) : 0; case 'created_at': default: return u.created_at ? Date.parse(u.created_at) : 0; } } function sortUsers(arr: User[], sort: UserSort, order: SortOrder): void { const dir = order === 'asc' ? 1 : -1; arr.sort((a, b) => { const av = userSortValue(a, sort); const bv = userSortValue(b, sort); if (av < bv) return -1 * dir; if (av > bv) return 1 * dir; return 0; }); } export async function getUsersList(opts: { page: number; perPage: number; search: string; sort?: string | null; order?: string | null; }): Promise<{ users: AdminUserItem[]; total: number }> { const { page, perPage } = opts; const search = opts.search.trim().toLowerCase(); const sort = parseSort(opts.sort, USER_SORTS, 'created_at'); const order = parseOrder(opts.order, 'desc'); const admin = getSupabaseAdmin(); let pageUsers: User[]; let total: number; // A non-default sort must order across ALL users (not just one listUsers // page), so it shares the search path's full directory scan. const isDefaultSort = sort === 'created_at' && order === 'desc'; const needScan = !!search || !isDefaultSort; if (needScan) { // Page through the user directory (bounded by USER_SCAN_MAX_PAGES), // accumulate case-insensitive email substring matches, sort, then // paginate the filtered+sorted set. const matched: User[] = []; for (let p = 1; p <= USER_SCAN_MAX_PAGES; p++) { // Retry transient empty-body GoTrue responses so a burst-induced flake // doesn't abort the full directory scan mid-way. const { data, error } = await withAdminRetry(() => admin.auth.admin.listUsers({ page: p, perPage: USER_SCAN_PER_PAGE, }), ); if (error) throw new Error(error.message); const us = data.users; if (us.length === 0) break; for (const u of us) { if (!search || (u.email ?? '').toLowerCase().includes(search)) { matched.push(u); } } if (us.length < USER_SCAN_PER_PAGE) break; } sortUsers(matched, sort, order); total = matched.length; const from = (page - 1) * perPage; pageUsers = matched.slice(from, from + perPage); } else { // Common no-search, default-sort path: cheap single-page lookup. Retry // transient empty-body responses so the post-mutation auto-refresh that // hits this path doesn't intermittently 500. const { data, error } = await withAdminRetry(() => admin.auth.admin.listUsers({ page, perPage }), ); if (error) throw new Error(error.message); pageUsers = data.users; total = (data as unknown as { total?: number }).total ?? pageUsers.length; } // Join tunnel rows for this page's users in a single query. const ids = pageUsers.map((u) => u.id); const tunnelMap = new Map(); 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 TunnelJoinRow[]) { tunnelMap.set(t.user_id, t); } } const users: AdminUserItem[] = pageUsers.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 { users, total }; } function tunnelSortValue(t: TunnelRow, sort: TunnelSort): string | number { switch (sort) { case 'subdomain': return t.subdomain.toLowerCase(); case 'bytes_used': return t.bytes_used; case 'quota_bytes': return t.quota_bytes; case 'is_active': return t.is_active ? 1 : 0; case 'last_seen_at': return t.last_seen_at ? Date.parse(t.last_seen_at) : 0; case 'usage_pct': return t.quota_bytes > 0 ? t.bytes_used / t.quota_bytes : 0; case 'created_at': default: return t.created_at ? Date.parse(t.created_at) : 0; } } function sortTunnelRows( arr: TunnelRow[], sort: TunnelSort, ascending: boolean, ): void { const dir = ascending ? 1 : -1; arr.sort((a, b) => { const av = tunnelSortValue(a, sort); const bv = tunnelSortValue(b, sort); if (av < bv) return -1 * dir; if (av > bv) return 1 * dir; return 0; }); } export async function getTunnelsList(opts: { page: number; perPage: number; search: string; status: string | null; sort?: string | null; order?: string | null; }): Promise<{ tunnels: TunnelItem[]; total: number }> { const { page, perPage, status } = opts; const search = opts.search.trim().toLowerCase(); const sort = parseSort(opts.sort, TUNNEL_SORTS, 'created_at'); const order = parseOrder(opts.order, 'desc'); const ascending = order === 'asc'; const admin = getSupabaseAdmin(); // The tunnels table's primary key is user_id (one tunnel per user); there is // no `id` column. The user_id is the identifier exposed to the UI. const cols = 'user_id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at'; let rows: TunnelRow[]; let total: number; const from = (page - 1) * perPage; const to = from + perPage - 1; if (status === 'over_quota') { // Column-to-column comparison is not expressible via PostgREST filters, // so fetch matching rows and paginate in memory. let q = admin.from('tunnels').select(cols); if (search) q = q.ilike('subdomain', `%${search}%`); const { data, error } = await q; if (error) throw new Error(error.message); const all = ((data ?? []) as TunnelRow[]).filter( (t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes, ); sortTunnelRows(all, sort, ascending); total = all.length; rows = all.slice(from, from + perPage); } else { let query = admin.from('tunnels').select(cols, { count: 'exact' }); if (search) query = query.ilike('subdomain', `%${search}%`); if (status === 'active') query = query.eq('is_active', true); else if (status === 'inactive') query = query.eq('is_active', false); // usage_pct is computed; order by bytes_used as its closest DB proxy. const dbCol = sort === 'usage_pct' ? 'bytes_used' : sort; query = query.order(dbCol, { ascending }).range(from, to); const { data, error, count } = await query; if (error) throw new Error(error.message); rows = (data ?? []) as TunnelRow[]; total = count ?? rows.length; } // Resolve owner emails (per-row getUserById; acceptable for current scale). // The user_id comes from an existing tunnel row, so an empty body here is a // transient burst flake rather than a genuine not-found — retry it, and bound // the concurrency so the enrichment doesn't self-throttle. The try/catch null // fallback remains as a last resort so one bad row can never 500 the whole // list (it surfaces as "—" only if every retry still fails). const emails = await mapWithConcurrency( rows, OWNER_EMAIL_CONCURRENCY, async (t) => { try { const { data: u } = await withAdminRetry(() => admin.auth.admin.getUserById(t.user_id), ); return u.user?.email ?? null; } catch { return null; } }, ); const tunnels: TunnelItem[] = rows.map((t, i) => ({ user_id: t.user_id, owner_email: emails[i], subdomain: t.subdomain, is_active: t.is_active, bytes_used: t.bytes_used, quota_bytes: t.quota_bytes, usage_pct: t.quota_bytes > 0 ? Math.min(100, (t.bytes_used / t.quota_bytes) * 100) : 0, last_seen_at: t.last_seen_at, created_at: t.created_at, })); return { tunnels, total }; } export async function getAuditList(opts: { page: number; perPage: number; action: string; targetType: string; sort?: string | null; order?: string | null; }): Promise<{ entries: AuditItem[]; total: number }> { const { page, perPage } = opts; const action = opts.action.trim(); const targetType = opts.targetType.trim(); const sort = parseSort(opts.sort, AUDIT_SORTS, 'created_at'); const ascending = parseOrder(opts.order, 'desc') === 'asc'; const admin = getSupabaseAdmin(); let query = admin .from('admin_audit_log') .select( 'id, actor_id, actor_email, action, target_type, target_id, details, created_at', { count: 'exact' }, ); if (action) query = query.eq('action', action); if (targetType) query = query.eq('target_type', targetType); const from = (page - 1) * perPage; const to = from + perPage - 1; query = query.order(sort, { ascending }).range(from, to); const { data, error, count } = await query; if (error) throw new Error(error.message); const entries = (data ?? []) as AuditItem[]; return { entries, total: count ?? entries.length }; }