diff --git a/Dockerfile b/Dockerfile index 88f00c6..e1dd0d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ # syntax=docker/dockerfile:1.7 -FROM node:20.18.0-alpine AS deps +FROM node:24.16.0-alpine AS deps WORKDIR /app RUN apk add --no-cache libc6-compat COPY package.json ./ RUN npm install --no-audit --no-fund --loglevel=error -FROM node:20.18.0-alpine AS builder +FROM node:24.16.0-alpine AS builder WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 COPY --from=deps /app/node_modules ./node_modules @@ -20,7 +20,7 @@ ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \ NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL RUN npm run build -FROM node:20.18.0-alpine AS runner +FROM node:24.16.0-alpine AS runner WORKDIR /app ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ diff --git a/app/admin/audit/audit-table.tsx b/app/admin/audit/audit-table.tsx index 50d18f7..0a28d3a 100644 --- a/app/admin/audit/audit-table.tsx +++ b/app/admin/audit/audit-table.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { formatDate } from '@/lib/format'; import type { AuditItem } from '@/lib/admin/list'; +import { SortHeader, downloadUrl, type SortOrder } from '../table-ui'; const PER_PAGE = 50; @@ -18,20 +19,28 @@ export function AuditTable({ const [page, setPage] = useState(1); const [action, setAction] = useState(''); const [targetType, setTargetType] = useState(''); + const [sort, setSort] = useState('created_at'); + const [order, setOrder] = useState('desc'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const queryParams = useCallback(() => { + const params = new URLSearchParams({ + page: String(page), + perPage: String(PER_PAGE), + sort, + order, + }); + if (action.trim()) params.set('action', action.trim()); + if (targetType.trim()) params.set('target_type', targetType.trim()); + return params; + }, [page, action, targetType, sort, order]); + const load = useCallback(async () => { setLoading(true); setError(null); try { - const params = new URLSearchParams({ - page: String(page), - perPage: String(PER_PAGE), - }); - if (action.trim()) params.set('action', action.trim()); - if (targetType.trim()) params.set('target_type', targetType.trim()); - const res = await fetch(`/api/admin/audit?${params.toString()}`, { + const res = await fetch(`/api/admin/audit?${queryParams().toString()}`, { credentials: 'same-origin', }); if (!res.ok) { @@ -49,7 +58,7 @@ export function AuditTable({ } finally { setLoading(false); } - }, [page, action, targetType]); + }, [queryParams]); // First page is server-rendered; skip the on-mount fetch to avoid racing the // SSR session-cookie refresh (which intermittently 401'd). @@ -63,6 +72,23 @@ export function AuditTable({ return () => clearTimeout(t); }, [load]); + function onSort(col: string) { + setPage(1); + if (col === sort) { + setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); + } else { + setSort(col); + setOrder('asc'); + } + } + + function exportCsv() { + const params = new URLSearchParams({ sort, order }); + if (action.trim()) params.set('action', action.trim()); + if (targetType.trim()) params.set('target_type', targetType.trim()); + downloadUrl(`/api/admin/audit/export?${params.toString()}`); + } + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); return ( @@ -93,6 +119,15 @@ export function AuditTable({ +
+ +
{error &&

{error}

} @@ -106,9 +141,27 @@ export function AuditTable({ - - - + + + diff --git a/app/admin/table-ui.tsx b/app/admin/table-ui.tsx new file mode 100644 index 0000000..3b50b34 --- /dev/null +++ b/app/admin/table-ui.tsx @@ -0,0 +1,64 @@ +'use client'; + +/** + * Small shared client helpers for the admin tables: a clickable sortable + * column header and a CSV-download trigger. Kept dependency-free and + * dark-theme consistent with globals.css. + */ + +export type SortOrder = 'asc' | 'desc'; + +export function SortHeader({ + label, + col, + sort, + order, + onSort, + className, +}: { + label: string; + col: string; + sort: string; + order: SortOrder; + onSort: (col: string) => void; + className?: string; +}) { + const active = sort === col; + const indicator = active ? (order === 'asc' ? '▲' : '▼') : '↕'; + return ( + + ); +} + +/** + * Triggers a browser download of a same-origin URL. The server sets + * Content-Disposition: attachment, so the cookie-authenticated GET streams the + * CSV straight to a file. + */ +export function downloadUrl(url: string): void { + const a = document.createElement('a'); + a.href = url; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); +} diff --git a/app/admin/tunnels/tunnels-table.tsx b/app/admin/tunnels/tunnels-table.tsx index 2122d39..e86bc77 100644 --- a/app/admin/tunnels/tunnels-table.tsx +++ b/app/admin/tunnels/tunnels-table.tsx @@ -3,9 +3,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { formatBytes, formatDate } from '@/lib/format'; import type { TunnelItem } from '@/lib/admin/list'; +import { SortHeader, downloadUrl, type SortOrder } from '../table-ui'; const PER_PAGE = 25; +type BulkResult = { ok: number; fail: number }; + export function TunnelsTable({ initialTunnels, initialTotal, @@ -18,22 +21,33 @@ export function TunnelsTable({ const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [status, setStatus] = useState(''); + const [sort, setSort] = useState('created_at'); + const [order, setOrder] = useState('desc'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busyId, setBusyId] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [bulkBusy, setBulkBusy] = useState(false); + const [bulkProgress, setBulkProgress] = useState(null); + + const queryParams = useCallback(() => { + const params = new URLSearchParams({ + page: String(page), + perPage: String(PER_PAGE), + sort, + order, + }); + if (search.trim()) params.set('search', search.trim()); + if (status) params.set('status', status); + return params; + }, [page, search, status, sort, order]); const load = useCallback(async () => { setLoading(true); setError(null); try { - const params = new URLSearchParams({ - page: String(page), - perPage: String(PER_PAGE), - }); - if (search.trim()) params.set('search', search.trim()); - if (status) params.set('status', status); - const res = await fetch(`/api/admin/tunnels?${params.toString()}`, { + const res = await fetch(`/api/admin/tunnels?${queryParams().toString()}`, { credentials: 'same-origin', }); if (!res.ok) { @@ -43,12 +57,13 @@ export function TunnelsTable({ const data = (await res.json()) as { tunnels: TunnelItem[]; total: number }; setTunnels(data.tunnels); setTotal(data.total); + setSelected(new Set()); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } - }, [page, search, status]); + }, [queryParams]); // First page is server-rendered; skip the on-mount fetch to avoid racing the // SSR session-cookie refresh (which intermittently 401'd). @@ -62,6 +77,16 @@ export function TunnelsTable({ return () => clearTimeout(t); }, [load]); + function onSort(col: string) { + setPage(1); + if (col === sort) { + setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); + } else { + setSort(col); + setOrder('asc'); + } + } + async function act( id: string, label: string, @@ -172,7 +197,82 @@ export function TunnelsTable({ ); } + function toggleRow(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + const pageIds = tunnels.map((t) => t.user_id); + const allSelected = + pageIds.length > 0 && pageIds.every((id) => selected.has(id)); + function toggleAll() { + setSelected((prev) => { + if (pageIds.every((id) => prev.has(id))) return new Set(); + return new Set(pageIds); + }); + } + + async function runBulk( + label: string, + perItem: (t: TunnelItem) => Promise, + confirmMsg?: string, + ) { + const targets = tunnels.filter((t) => selected.has(t.user_id)); + if (targets.length === 0) return; + if (confirmMsg && !window.confirm(confirmMsg)) return; + setBulkBusy(true); + setError(null); + setNotice(null); + const result: BulkResult = { ok: 0, fail: 0 }; + for (let i = 0; i < targets.length; i++) { + setBulkProgress(`${label}: ${i + 1}/${targets.length}…`); + try { + const ok = await perItem(targets[i]); + if (ok) result.ok++; + else result.fail++; + } catch { + result.fail++; + } + } + setBulkProgress(null); + setBulkBusy(false); + const parts = [`${result.ok} ${label.toLowerCase()}`]; + if (result.fail) parts.push(`${result.fail} failed`); + setNotice(parts.join(', ')); + await load(); + } + + async function setActiveOne(t: TunnelItem, active: boolean): Promise { + const res = await fetch(`/api/admin/tunnels/${t.user_id}/active`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_active: active }), + }); + return res.ok; + } + + async function deleteOne(t: TunnelItem): Promise { + const res = await fetch(`/api/admin/tunnels/${t.user_id}`, { + method: 'DELETE', + credentials: 'same-origin', + }); + return res.ok; + } + + function exportCsv() { + const params = new URLSearchParams({ sort, order }); + if (search.trim()) params.set('search', search.trim()); + if (status) params.set('status', status); + downloadUrl(`/api/admin/tunnels/export?${params.toString()}`); + } + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + const selectedCount = selected.size; return (
@@ -211,11 +311,67 @@ export function TunnelsTable({ +
+ +
{error &&

{error}

} {notice &&

{notice}

} + {selectedCount > 0 && ( +
+ {selectedCount} selected + + + + + {bulkProgress && {bulkProgress}} +
+ )} + {loading ? (

Loading…

) : tunnels.length === 0 ? ( @@ -225,17 +381,60 @@ export function TunnelsTable({
WhenActorActionTarget Details
onSort(col)} + role="button" + tabIndex={0} + aria-sort={active ? (order === 'asc' ? 'ascending' : 'descending') : 'none'} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSort(col); + } + }} + > + {label} + +
- + + - - - + + + {tunnels.map((t) => ( - + +
Subdomain + + OwnerStatusUsageLast seenActions
+ toggleRow(t.user_id)} + /> + {t.subdomain} {t.owner_email ?? '—'} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 2c3a7da..893e831 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,4 +1,5 @@ import { getUsersList } from '@/lib/admin/list'; +import { requireAdmin } from '@/lib/auth/admin-guard'; import { UsersTable } from './users-table'; export const dynamic = 'force-dynamic'; @@ -10,11 +11,18 @@ export default async function AdminUsersPage() { // Initial load runs on the server (the admin session is already validated by // the admin layout's requireAdmin()), so the first paint never races the // client session-cookie refresh. + const admin = await requireAdmin(); const { users, total } = await getUsersList({ page: 1, perPage: PER_PAGE, search: '', }); - return ; + return ( + + ); } diff --git a/app/admin/users/users-table.tsx b/app/admin/users/users-table.tsx index f69567f..c898162 100644 --- a/app/admin/users/users-table.tsx +++ b/app/admin/users/users-table.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { formatBytes, formatDate } from '@/lib/format'; import type { AdminUserItem } from '@/lib/admin/list'; +import { SortHeader, downloadUrl, type SortOrder } from '../table-ui'; const PER_PAGE = 25; @@ -11,30 +12,46 @@ function isBanned(u: AdminUserItem): boolean { return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now(); } +type BulkResult = { ok: number; fail: number; skipped: number }; + export function UsersTable({ initialUsers, initialTotal, + currentUserId, }: { initialUsers: AdminUserItem[]; initialTotal: number; + currentUserId: string; }) { const [users, setUsers] = useState(initialUsers); const [total, setTotal] = useState(initialTotal); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); + const [sort, setSort] = useState('created_at'); + const [order, setOrder] = useState('desc'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [bulkBusy, setBulkBusy] = useState(false); + const [bulkProgress, setBulkProgress] = useState(null); + + const queryParams = useCallback(() => { + const params = new URLSearchParams({ + page: String(page), + perPage: String(PER_PAGE), + sort, + order, + }); + if (search.trim()) params.set('search', search.trim()); + return params; + }, [page, search, sort, order]); const load = useCallback(async () => { setLoading(true); setError(null); try { - const params = new URLSearchParams({ - page: String(page), - perPage: String(PER_PAGE), - }); - if (search.trim()) params.set('search', search.trim()); - const res = await fetch(`/api/admin/users?${params.toString()}`, { + const res = await fetch(`/api/admin/users?${queryParams().toString()}`, { credentials: 'same-origin', }); if (!res.ok) { @@ -47,12 +64,13 @@ export function UsersTable({ }; setUsers(data.users); setTotal(data.total); + setSelected(new Set()); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } - }, [page, search]); + }, [queryParams]); // The first page is rendered from server-supplied data (see the parent server // component), so skip the initial on-mount fetch — that on-mount request used @@ -67,13 +85,106 @@ export function UsersTable({ return () => clearTimeout(t); }, [load]); + function onSort(col: string) { + setPage(1); + if (col === sort) { + setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); + } else { + setSort(col); + setOrder('asc'); + } + } + + function toggleRow(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + const pageIds = users.map((u) => u.id); + const allSelected = + pageIds.length > 0 && pageIds.every((id) => selected.has(id)); + function toggleAll() { + setSelected((prev) => { + if (pageIds.every((id) => prev.has(id))) return new Set(); + return new Set(pageIds); + }); + } + + async function runBulk( + label: string, + perItem: (u: AdminUserItem) => Promise<'ok' | 'fail' | 'skip'>, + confirmMsg?: string, + ) { + const targets = users.filter((u) => selected.has(u.id)); + if (targets.length === 0) return; + if (confirmMsg && !window.confirm(confirmMsg)) return; + setBulkBusy(true); + setError(null); + setNotice(null); + const result: BulkResult = { ok: 0, fail: 0, skipped: 0 }; + for (let i = 0; i < targets.length; i++) { + setBulkProgress(`${label}: ${i + 1}/${targets.length}…`); + try { + const r = await perItem(targets[i]); + if (r === 'ok') result.ok++; + else if (r === 'skip') result.skipped++; + else result.fail++; + } catch { + result.fail++; + } + } + setBulkProgress(null); + setBulkBusy(false); + const parts = [`${result.ok} ${label.toLowerCase()}`]; + if (result.skipped) parts.push(`${result.skipped} skipped (self)`); + if (result.fail) parts.push(`${result.fail} failed`); + setNotice(parts.join(', ')); + await load(); + } + + async function banOne( + u: AdminUserItem, + banned: boolean, + ): Promise<'ok' | 'fail' | 'skip'> { + if (u.id === currentUserId) return 'skip'; + const res = await fetch(`/api/admin/users/${u.id}/ban`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ banned }), + }); + return res.ok ? 'ok' : 'fail'; + } + + async function deleteOne( + u: AdminUserItem, + ): Promise<'ok' | 'fail' | 'skip'> { + if (u.id === currentUserId) return 'skip'; + const res = await fetch(`/api/admin/users/${u.id}`, { + method: 'DELETE', + credentials: 'same-origin', + }); + return res.ok ? 'ok' : 'fail'; + } + + function exportCsv() { + const params = new URLSearchParams({ sort, order }); + if (search.trim()) params.set('search', search.trim()); + downloadUrl(`/api/admin/users/export?${params.toString()}`); + } + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + const selectedCount = selected.size; return (

Users

-
+
Refresh +
+ +
{error &&

{error}

} + {notice &&

{notice}

} + + {selectedCount > 0 && ( +
+ {selectedCount} selected + + + + + {bulkProgress && {bulkProgress}} +
+ )} {loading ? (

Loading…

@@ -100,17 +266,61 @@ export function UsersTable({ - - + + + - + + {users.map((u) => ( - + + + ))} diff --git a/app/api/admin/audit/export/route.ts b/app/api/admin/audit/export/route.ts new file mode 100644 index 0000000..09f3517 --- /dev/null +++ b/app/api/admin/audit/export/route.ts @@ -0,0 +1,73 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { getAuditList } from '@/lib/admin/list'; +import { logAdminAction } from '@/lib/auth/audit'; +import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const url = new URL(req.url); + const action = url.searchParams.get('action') ?? ''; + const targetType = url.searchParams.get('target_type') ?? ''; + const sort = url.searchParams.get('sort'); + const order = url.searchParams.get('order'); + + try { + // Respect action/target_type filters + sort, full set (capped). Audit + // details already exclude secrets (no tokens are ever logged). + const { entries } = await getAuditList({ + page: 1, + perPage: EXPORT_MAX_ROWS, + action, + targetType, + sort, + order, + }); + + const header = [ + 'created_at', + 'actor_email', + 'action', + 'target_type', + 'target_id', + 'details', + ]; + const rows = entries.map((e) => [ + e.created_at, + e.actor_email ?? '', + e.action, + e.target_type ?? '', + e.target_id ?? '', + JSON.stringify(e.details ?? {}), + ]); + + const csv = toCsv(header, rows); + + await logAdminAction(auth.user, { + action: 'audit.export', + target_type: 'audit', + details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS }, + }); + + return new NextResponse(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="audit.csv"', + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, + }); + } catch (e) { + console.error('admin audit export failed', e); + return NextResponse.json( + { error: 'internal error' }, + { status: 500, headers: { 'Cache-Control': 'no-store' } }, + ); + } +} diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts index 93a2540..c7c1738 100644 --- a/app/api/admin/audit/route.ts +++ b/app/api/admin/audit/route.ts @@ -16,6 +16,8 @@ export async function GET(req: NextRequest) { const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100); const action = url.searchParams.get('action') ?? ''; const targetType = url.searchParams.get('target_type') ?? ''; + const sort = url.searchParams.get('sort'); + const order = url.searchParams.get('order'); try { const { entries, total } = await getAuditList({ @@ -23,6 +25,8 @@ export async function GET(req: NextRequest) { perPage, action, targetType, + sort, + order, }); return jsonNoStore({ entries, total, page, perPage }); } catch (e) { diff --git a/app/api/admin/tunnels/[id]/active/route.ts b/app/api/admin/tunnels/[id]/active/route.ts index 7bcc0d6..e99b6f2 100644 --- a/app/api/admin/tunnels/[id]/active/route.ts +++ b/app/api/admin/tunnels/[id]/active/route.ts @@ -3,7 +3,7 @@ import { requireAdminApi } from '@/lib/auth/admin-guard'; import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { logAdminAction } from '@/lib/auth/audit'; import { isUuid, parseBoolean } from '@/lib/admin/validators'; -import { redisSet } from '@/lib/redis'; +import { setTunnelActive } from '@/lib/redis'; import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; @@ -50,14 +50,16 @@ export async function POST( return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } - // Best-effort live kill-switch (never throws). - await redisSet(`tunnel:active:${data.subdomain}`, isActive ? '1' : '0'); + // Best-effort live kill-switch (never throws). Writes tunnel:active: + // = "1"/"0" with TTL so the edge gate drops/allows a live connection within + // ~1s. No-op when REDIS_URL is unset. + const redisOk = await setTunnelActive(data.subdomain, isActive); await logAdminAction(auth.user, { action: isActive ? 'tunnel.activate' : 'tunnel.deactivate', target_type: 'tunnel', target_id: id, - details: { subdomain: data.subdomain, is_active: isActive }, + details: { subdomain: data.subdomain, is_active: isActive, redis: redisOk }, }); return jsonNoStore({ ok: true, is_active: isActive }); diff --git a/app/api/admin/tunnels/[id]/reassign/route.ts b/app/api/admin/tunnels/[id]/reassign/route.ts index 7436986..917024f 100644 --- a/app/api/admin/tunnels/[id]/reassign/route.ts +++ b/app/api/admin/tunnels/[id]/reassign/route.ts @@ -5,6 +5,7 @@ import { logAdminAction } from '@/lib/auth/audit'; import { isUuid } from '@/lib/admin/validators'; import { validateSubdomain } from '@/lib/validation'; import { isSubdomainReserved } from '@/lib/admin/reserved'; +import { setTunnelActive } from '@/lib/redis'; import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; @@ -49,13 +50,22 @@ export async function POST( // Reject if taken by a different tunnel (keyed by owner user_id). const { data: existing } = await admin .from('tunnels') - .select('user_id') + .select('user_id, subdomain') .eq('subdomain', subdomain) - .maybeSingle<{ user_id: string }>(); + .maybeSingle<{ user_id: string; subdomain: string }>(); if (existing && existing.user_id !== id) { return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); } + // Capture the current subdomain so we can drop the OLD hostname's live + // connection once it is freed by the rename. + const { data: current } = await admin + .from('tunnels') + .select('subdomain') + .eq('user_id', id) + .maybeSingle<{ subdomain: string }>(); + const oldSubdomain = current?.subdomain ?? null; + const { data, error } = await admin .from('tunnels') .update({ subdomain }) @@ -74,11 +84,17 @@ export async function POST( return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } + // Best-effort: drop any live connection on the OLD subdomain so the former + // hostname stops resolving as active. No-op when REDIS_URL is unset. + if (oldSubdomain && oldSubdomain !== subdomain) { + await setTunnelActive(oldSubdomain, false); + } + await logAdminAction(auth.user, { action: 'tunnel.reassign', target_type: 'tunnel', target_id: id, - details: { subdomain }, + details: { subdomain, previous_subdomain: oldSubdomain }, }); return jsonNoStore({ ok: true, subdomain }); diff --git a/app/api/admin/tunnels/[id]/route.ts b/app/api/admin/tunnels/[id]/route.ts index c597232..5e2394b 100644 --- a/app/api/admin/tunnels/[id]/route.ts +++ b/app/api/admin/tunnels/[id]/route.ts @@ -3,7 +3,7 @@ import { requireAdminApi } from '@/lib/auth/admin-guard'; import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { logAdminAction } from '@/lib/auth/audit'; import { isUuid } from '@/lib/admin/validators'; -import { redisSet } from '@/lib/redis'; +import { setTunnelActive } from '@/lib/redis'; import { jsonNoStore } from '@/lib/admin/response'; export const runtime = 'nodejs'; @@ -36,14 +36,15 @@ export async function DELETE( return jsonNoStore({ error: 'tunnel not found' }, { status: 404 }); } - // Best-effort live kill-switch. - await redisSet(`tunnel:active:${data.subdomain}`, '0'); + // Best-effort live kill-switch: write "0" so any live connection on the + // freed subdomain drops within ~1s. No-op when REDIS_URL is unset. + const redisOk = await setTunnelActive(data.subdomain, false); await logAdminAction(auth.user, { action: 'tunnel.delete', target_type: 'tunnel', target_id: id, - details: { subdomain: data.subdomain }, + details: { subdomain: data.subdomain, redis: redisOk }, }); return jsonNoStore({ ok: true }); diff --git a/app/api/admin/tunnels/export/route.ts b/app/api/admin/tunnels/export/route.ts new file mode 100644 index 0000000..6b1038b --- /dev/null +++ b/app/api/admin/tunnels/export/route.ts @@ -0,0 +1,79 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { getTunnelsList } from '@/lib/admin/list'; +import { logAdminAction } from '@/lib/auth/audit'; +import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const url = new URL(req.url); + const search = url.searchParams.get('search') ?? ''; + const status = url.searchParams.get('status'); + const sort = url.searchParams.get('sort'); + const order = url.searchParams.get('order'); + + try { + // Respect search/status/sort but pull the full matching set (capped). + // SECURITY: the tunnel token is NEVER selected or exported. + const { tunnels } = await getTunnelsList({ + page: 1, + perPage: EXPORT_MAX_ROWS, + search, + status, + sort, + order, + }); + + const header = [ + 'user_id', + 'owner_email', + 'subdomain', + 'is_active', + 'bytes_used', + 'quota_bytes', + 'usage_pct', + 'last_seen_at', + 'created_at', + ]; + const rows = tunnels.map((t) => [ + t.user_id, + t.owner_email ?? '', + t.subdomain, + t.is_active ? 'true' : 'false', + t.bytes_used, + t.quota_bytes, + t.usage_pct.toFixed(1), + t.last_seen_at ?? '', + t.created_at, + ]); + + const csv = toCsv(header, rows); + + await logAdminAction(auth.user, { + action: 'tunnel.export', + target_type: 'tunnel', + details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS }, + }); + + return new NextResponse(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="tunnels.csv"', + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, + }); + } catch (e) { + console.error('admin tunnels export failed', e); + return NextResponse.json( + { error: 'internal error' }, + { status: 500, headers: { 'Cache-Control': 'no-store' } }, + ); + } +} diff --git a/app/api/admin/tunnels/route.ts b/app/api/admin/tunnels/route.ts index c95cfa1..b5b1a27 100644 --- a/app/api/admin/tunnels/route.ts +++ b/app/api/admin/tunnels/route.ts @@ -16,6 +16,8 @@ export async function GET(req: NextRequest) { const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100); const search = url.searchParams.get('search') ?? ''; const status = url.searchParams.get('status'); // active|inactive|over_quota + const sort = url.searchParams.get('sort'); + const order = url.searchParams.get('order'); try { const { tunnels, total } = await getTunnelsList({ @@ -23,6 +25,8 @@ export async function GET(req: NextRequest) { perPage, search, status, + sort, + order, }); return jsonNoStore({ tunnels, total, page, perPage }); } catch (e) { diff --git a/app/api/admin/users/export/route.ts b/app/api/admin/users/export/route.ts new file mode 100644 index 0000000..68e2d44 --- /dev/null +++ b/app/api/admin/users/export/route.ts @@ -0,0 +1,85 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { getUsersList } from '@/lib/admin/list'; +import { logAdminAction } from '@/lib/auth/audit'; +import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function isBanned(banned_until: string | null): boolean { + return !!banned_until && new Date(banned_until).getTime() > Date.now(); +} + +export async function GET(req: NextRequest) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const url = new URL(req.url); + const search = url.searchParams.get('search') ?? ''; + const sort = url.searchParams.get('sort'); + const order = url.searchParams.get('order'); + + try { + // Respect search/sort but pull the full matching set (capped). NOTE: never + // export secrets — the users list carries no token. + const { users } = await getUsersList({ + page: 1, + perPage: EXPORT_MAX_ROWS, + search, + sort, + order, + }); + + const header = [ + 'email', + 'role', + 'status', + 'tunnel_subdomain', + 'tunnel_active', + 'bytes_used', + 'quota_bytes', + 'created_at', + 'last_sign_in_at', + ]; + const rows = users.map((u) => [ + u.email ?? '', + u.role, + isBanned(u.banned_until) + ? 'banned' + : u.email_confirmed_at + ? 'confirmed' + : 'unconfirmed', + u.tunnel?.subdomain ?? '', + u.tunnel ? (u.tunnel.is_active ? 'true' : 'false') : '', + u.tunnel?.bytes_used ?? '', + u.tunnel?.quota_bytes ?? '', + u.created_at, + u.last_sign_in_at ?? '', + ]); + + const csv = toCsv(header, rows); + + await logAdminAction(auth.user, { + action: 'user.export', + target_type: 'user', + details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS }, + }); + + return new NextResponse(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="users.csv"', + 'Cache-Control': 'no-store', + Pragma: 'no-cache', + }, + }); + } catch (e) { + console.error('admin users export failed', e); + return NextResponse.json( + { error: 'internal error' }, + { status: 500, headers: { 'Cache-Control': 'no-store' } }, + ); + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index a6a577c..cff90f8 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -15,9 +15,17 @@ export async function GET(req: NextRequest) { const page = parsePageParam(url.searchParams.get('page'), 1); const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100); const search = url.searchParams.get('search') ?? ''; + const sort = url.searchParams.get('sort'); + const order = url.searchParams.get('order'); try { - const { users, total } = await getUsersList({ page, perPage, search }); + const { users, total } = await getUsersList({ + page, + perPage, + search, + sort, + order, + }); return jsonNoStore({ users, total, page, perPage }); } catch (e) { console.error('admin users list failed', e); diff --git a/app/globals.css b/app/globals.css index d51d6fc..8061c10 100644 --- a/app/globals.css +++ b/app/globals.css @@ -365,3 +365,66 @@ button:disabled, select { font-size: 1rem; } + +/* Sortable table headers */ +.th-sort { + cursor: pointer; + user-select: none; + white-space: nowrap; +} +.th-sort:hover { + color: var(--fg); +} +.th-sort .sort-ind { + margin-left: 0.3rem; + opacity: 0.5; + font-size: 0.7rem; +} +.th-sort.sorted .sort-ind { + opacity: 1; + color: var(--accent); +} + +/* Checkbox / selection column */ +.admin-table th.col-check, +.admin-table td.col-check { + width: 1%; + white-space: nowrap; + text-align: center; +} +.admin-table input[type='checkbox'] { + width: auto; + margin: 0; + cursor: pointer; + accent-color: var(--accent); +} +.admin-table tr.row-selected td { + background: rgba(59, 130, 246, 0.08); +} + +/* Bulk action bar */ +.bulk-bar { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin: 0 0 1rem; + padding: 0.6rem 0.85rem; + background: var(--card); + border: 1px solid var(--accent); + border-radius: 8px; +} +.bulk-bar .bulk-count { + font-weight: 600; + margin-right: 0.25rem; +} +.bulk-bar .spacer { + flex: 1 1 auto; +} + +.toolbar-actions { + margin-left: auto; + display: flex; + gap: 0.5rem; +} + diff --git a/lib/admin/csv.ts b/lib/admin/csv.ts new file mode 100644 index 0000000..3415bb6 --- /dev/null +++ b/lib/admin/csv.ts @@ -0,0 +1,38 @@ +/** + * Minimal, dependency-free CSV serialization for the admin export endpoints. + * RFC-4180 style: fields containing a comma, double-quote, CR or LF are wrapped + * in double-quotes with embedded quotes doubled. Everything is coerced to a + * string first; null/undefined become empty fields. + */ + +export function csvField(v: unknown): string { + let s: string; + if (v === null || v === undefined) s = ''; + else if (typeof v === 'string') s = v; + else if (typeof v === 'object') s = JSON.stringify(v); + else s = String(v); + if (/[",\r\n]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +export function csvRow(values: unknown[]): string { + return values.map(csvField).join(','); +} + +/** + * Build a full CSV document (header + rows). A leading row is always the + * provided header. Lines are CRLF-terminated for maximal spreadsheet + * compatibility. + */ +export function toCsv(header: string[], rows: unknown[][]): string { + const lines = [csvRow(header), ...rows.map(csvRow)]; + return lines.join('\r\n') + '\r\n'; +} + +/** + * Max rows any single export will emit. Keeps a runaway export bounded; the + * caller notes the cap in the report when hit. + */ +export const EXPORT_MAX_ROWS = 10000; diff --git a/lib/admin/list.ts b/lib/admin/list.ts index f3fe2bb..61bbbdd 100644 --- a/lib/admin/list.ts +++ b/lib/admin/list.ts @@ -1,5 +1,15 @@ 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 @@ -66,23 +76,56 @@ type TunnelRow = TunnelJoinRow & { 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; - if (search) { - // Search must match across ALL users, not just the current listUsers page. + // 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, then paginate the - // filtered set. + // 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({ @@ -93,15 +136,18 @@ export async function getUsersList(opts: { const us = data.users; if (us.length === 0) break; for (const u of us) { - if ((u.email ?? '').toLowerCase().includes(search)) matched.push(u); + 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 path: cheap single-page lookup (unchanged behavior). + // 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; @@ -146,14 +192,54 @@ export async function getUsersList(opts: { 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 @@ -171,11 +257,12 @@ export async function getTunnelsList(opts: { // 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 }); + 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 { @@ -183,7 +270,9 @@ export async function getTunnelsList(opts: { 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); + // 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); @@ -226,10 +315,14 @@ export async function getAuditList(opts: { 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 @@ -243,7 +336,7 @@ export async function getAuditList(opts: { const from = (page - 1) * perPage; const to = from + perPage - 1; - query = query.order('created_at', { ascending: false }).range(from, to); + query = query.order(sort, { ascending }).range(from, to); const { data, error, count } = await query; if (error) throw new Error(error.message); diff --git a/lib/admin/sort.ts b/lib/admin/sort.ts new file mode 100644 index 0000000..6f87483 --- /dev/null +++ b/lib/admin/sort.ts @@ -0,0 +1,45 @@ +/** + * Tiny helpers to parse + whitelist server-side sort parameters for the admin + * list/export endpoints. Anything not on the per-endpoint allow-list falls + * back to that endpoint's default column, so untrusted `sort`/`order` query + * params can never reach PostgREST verbatim. + */ + +export type SortOrder = 'asc' | 'desc'; + +export function parseOrder( + v: string | null | undefined, + fallback: SortOrder = 'desc', +): SortOrder { + return v === 'asc' || v === 'desc' ? v : fallback; +} + +export function parseSort( + v: string | null | undefined, + allowed: readonly T[], + fallback: T, +): T { + return v && (allowed as readonly string[]).includes(v) ? (v as T) : fallback; +} + +export const USER_SORTS = [ + 'email', + 'created_at', + 'last_sign_in_at', + 'role', +] as const; +export type UserSort = (typeof USER_SORTS)[number]; + +export const TUNNEL_SORTS = [ + 'subdomain', + 'bytes_used', + 'quota_bytes', + 'is_active', + 'created_at', + 'last_seen_at', + 'usage_pct', +] as const; +export type TunnelSort = (typeof TUNNEL_SORTS)[number]; + +export const AUDIT_SORTS = ['created_at', 'action', 'actor_email'] as const; +export type AuditSort = (typeof AUDIT_SORTS)[number]; diff --git a/lib/redis.ts b/lib/redis.ts index c198853..a1eb458 100644 --- a/lib/redis.ts +++ b/lib/redis.ts @@ -41,10 +41,11 @@ function encodeCommand(args: string[]): string { } /** - * Best-effort SET. Resolves true on apparent success, false otherwise. - * Never rejects. + * Best-effort execution of a single RESP command (preceded by optional + * AUTH/SELECT). Resolves true when the final reply is a non-error (`+...` or + * `:...`), false otherwise. Never rejects. */ -export function redisSet(key: string, value: string): Promise { +function runCommand(args: string[]): Promise { const url = process.env.REDIS_URL; if (!url) return Promise.resolve(false); const target = parseRedisUrl(url); @@ -68,7 +69,7 @@ export function redisSet(key: string, value: string): Promise { if (target.db !== undefined && target.db > 0) { commands.push(encodeCommand(['SELECT', String(target.db)])); } - commands.push(encodeCommand(['SET', key, value])); + commands.push(encodeCommand(args)); const socket = net.createConnection( { host: target.host, port: target.port }, @@ -85,16 +86,86 @@ export function redisSet(key: string, value: string): Promise { socket.on('error', () => done(false)); let buf = ''; - let expectedReplies = commands.length; + const expectedReplies = commands.length; socket.on('data', (chunk) => { buf += chunk.toString('utf8'); // Count complete simple replies (lines terminated by \r\n). const lines = buf.split('\r\n').filter((l) => l.length > 0); if (lines.length >= expectedReplies) { const last = lines[lines.length - 1]; - // +OK for SET success. - done(last.startsWith('+OK') || last.startsWith('+')); + // +OK / +... (simple string) or :N (integer, e.g. DEL count) = success. + done(last.startsWith('+') || last.startsWith(':')); } }); }); } + +/** + * Best-effort SET. When `ttlSeconds` is a positive integer the command is + * issued as `SET key val EX `, otherwise a plain `SET key val`. Resolves + * true on apparent success, false otherwise. Never rejects. Backward + * compatible: existing two-arg callers are unaffected. + */ +export function redisSet( + key: string, + value: string, + ttlSeconds?: number, +): Promise { + const args = ['SET', key, value]; + if ( + typeof ttlSeconds === 'number' && + Number.isFinite(ttlSeconds) && + ttlSeconds > 0 + ) { + args.push('EX', String(Math.floor(ttlSeconds))); + } + return runCommand(args); +} + +/** + * Best-effort DEL. Resolves true when Redis acknowledges the command (reply is + * an integer, even 0), false otherwise. Never rejects. + */ +export function redisDel(key: string): Promise { + return runCommand(['DEL', key]); +} + +/** + * TTL (seconds) written alongside the tunnel:active flag. Must match the edge + * `tunnel-active` forward_auth gate's TTL_SECONDS (currently 30) so the flag + * naturally expires in lock-step with the gate's cache. Overridable via + * TUNNEL_ACTIVE_TTL; defaults to 30. + */ +function tunnelActiveTtl(): number { + const raw = process.env.TUNNEL_ACTIVE_TTL; + if (raw) { + const n = Number(raw); + if (Number.isFinite(n) && n > 0) return Math.floor(n); + } + return 30; +} + +/** + * Live kill-switch primitive. Writes the SAME key the edge gate reads: + * SET tunnel:active: <"1"|"0"> EX + * "1" = ALLOW, "0" = DENY. A currently-connected tunnel is dropped within ~1s + * when "0" is written; "1" re-allows it. No-op (returns false) when REDIS_URL + * is unset — behavior then falls back to the gate's Postgres `is_active` read, + * exactly as before this wiring existed. Never throws. + */ +export async function setTunnelActive( + subdomain: string, + active: boolean, +): Promise { + if (!process.env.REDIS_URL) return false; + if (!subdomain) return false; + try { + return await redisSet( + `tunnel:active:${subdomain}`, + active ? '1' : '0', + tunnelActiveTtl(), + ); + } catch { + return false; + } +} diff --git a/package-lock.json b/package-lock.json index ab84b89..6f12389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,9 @@ "@types/react": "18.3.11", "@types/react-dom": "18.3.0", "typescript": "5.6.2" + }, + "engines": { + "node": ">=20" } }, "node_modules/@next/env": { @@ -27,6 +30,36 @@ "integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==", "license": "MIT" }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz", + "integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz", + "integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/swc-linux-arm64-gnu": { "version": "14.2.15", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz", @@ -59,6 +92,81 @@ "node": ">= 10" } }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz", + "integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz", + "integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz", + "integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz", + "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz", + "integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@supabase/auth-js": { "version": "2.65.0", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.0.tgz", @@ -542,111 +650,6 @@ "optional": true } } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz", - "integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz", - "integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz", - "integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz", - "integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz", - "integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz", - "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz", - "integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index 1778394..16a3dff 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "linumiq-web", "version": "1.0.0", "private": true, + "engines": { + "node": ">=20" + }, "scripts": { "dev": "next dev", "build": "next build",
EmailRole + + Status Tunnel UsageCreated
+ toggleRow(u.id)} + /> + {u.email ?? u.id} @@ -140,6 +350,7 @@ export function UsersTable({ )}` : '—'} {formatDate(u.last_sign_in_at)} {formatDate(u.created_at)}