d317e8c758
WS1: pin all Docker stages to node:24.16.0-alpine; add engines node>=20. WS2: lib/redis.ts gains TTL-backed redisSet, redisDel, setTunnelActive (writes tunnel:active:<sub>=1/0 EX 30, TUNNEL_ACTIVE_TTL override, no-op without REDIS_URL); wired into tunnel active/delete/reassign routes. WS3: sortable columns, CSV export routes (token excluded), and bulk actions (self-account guard) across users/tunnels/audit admin tables.
347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
import type { User } from '@supabase/supabase-js';
|
|
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
|
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<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;
|
|
|
|
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++) {
|
|
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 (!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.
|
|
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 };
|
|
}
|
|
|
|
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).
|
|
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;
|
|
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 };
|
|
}
|