Files
linumiq_net-web_app/lib/admin/list.ts
T

254 lines
7.8 KiB
TypeScript

import type { User } from '@supabase/supabase-js';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
/**
* 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<string, unknown>;
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;
export async function getUsersList(opts: {
page: number;
perPage: number;
search: string;
}): Promise<{ users: AdminUserItem[]; total: number }> {
const { page, perPage } = opts;
const search = opts.search.trim().toLowerCase();
const admin = getSupabaseAdmin();
let pageUsers: User[];
let total: number;
if (search) {
// Search must match across ALL users, not just the current listUsers page.
// Page through the user directory (bounded by USER_SCAN_MAX_PAGES),
// accumulate case-insensitive email substring matches, then paginate the
// filtered set.
const matched: User[] = [];
for (let p = 1; p <= USER_SCAN_MAX_PAGES; p++) {
const { data, error } = await 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 ((u.email ?? '').toLowerCase().includes(search)) matched.push(u);
}
if (us.length < USER_SCAN_PER_PAGE) break;
}
total = matched.length;
const from = (page - 1) * perPage;
pageUsers = matched.slice(from, from + perPage);
} else {
// Common no-search path: cheap single-page lookup (unchanged behavior).
const { data, error } = await 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<string, TunnelJoinRow>();
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 };
}
export async function getTunnelsList(opts: {
page: number;
perPage: number;
search: string;
status: string | null;
}): Promise<{ tunnels: TunnelItem[]; total: number }> {
const { page, perPage, status } = opts;
const search = opts.search.trim().toLowerCase();
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.order('created_at', { ascending: false });
if (error) throw new Error(error.message);
const all = ((data ?? []) as TunnelRow[]).filter(
(t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes,
);
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);
query = query.order('created_at', { ascending: false }).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).
const emails = await Promise.all(
rows.map(async (t) => {
try {
const { data: u } = await 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;
}): Promise<{ entries: AuditItem[]; total: number }> {
const { page, perPage } = opts;
const action = opts.action.trim();
const targetType = opts.targetType.trim();
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('created_at', { ascending: false }).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 };
}