diff --git a/.gitignore b/.gitignore index 9cf7120..8f7c3e4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ yarn-error.log* # Dev environment secrets (never commit) .env.production + +# Next.js build output +.next/ +out/ +*.tsbuildinfo +next-env.d.ts diff --git a/app/admin/admin-nav.tsx b/app/admin/admin-nav.tsx new file mode 100644 index 0000000..fca4cd9 --- /dev/null +++ b/app/admin/admin-nav.tsx @@ -0,0 +1,34 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +const LINKS = [ + { href: '/admin', label: 'Overview', exact: true }, + { href: '/admin/users', label: 'Users', exact: false }, + { href: '/admin/tunnels', label: 'Tunnels', exact: false }, + { href: '/admin/reserved', label: 'Reserved', exact: false }, + { href: '/admin/audit', label: 'Audit Log', exact: false }, +]; + +export function AdminNav() { + const pathname = usePathname(); + return ( + + ); +} diff --git a/app/admin/audit/page.tsx b/app/admin/audit/page.tsx new file mode 100644 index 0000000..e976e4e --- /dev/null +++ b/app/admin/audit/page.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { formatDate } from '@/lib/format'; + +type AuditEntry = { + 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; +}; + +const PER_PAGE = 50; + +export default function AdminAuditPage() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [action, setAction] = useState(''); + const [targetType, setTargetType] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + 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()}`); + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(b.error ?? `Request failed (${res.status})`); + } + const data = (await res.json()) as { + entries: AuditEntry[]; + total: number; + }; + setEntries(data.entries); + setTotal(data.total); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [page, action, targetType]); + + useEffect(() => { + const t = setTimeout(load, 250); + return () => clearTimeout(t); + }, [load]); + + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + return ( +
+

Audit log

+ +
+ { + setPage(1); + setAction(e.target.value); + }} + style={{ maxWidth: 240 }} + /> + { + setPage(1); + setTargetType(e.target.value); + }} + style={{ maxWidth: 240 }} + /> + +
+ + {error &&

{error}

} + + {loading ? ( +

Loading…

+ ) : entries.length === 0 ? ( +

No audit entries.

+ ) : ( +
+ + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + ))} + +
WhenActorActionTargetDetails
{formatDate(e.created_at)} + {e.actor_email ?? e.actor_id ?? '—'} + {e.action} + {e.target_type ? `${e.target_type}:` : ''} + {e.target_id ?? ''} + + + {JSON.stringify(e.details ?? {})} + +
+
+ )} + +
+ + + Page {page} of {totalPages} ({total} total) + + +
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..37f4ced --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; +import { requireAdmin } from '@/lib/auth/admin-guard'; +import { AdminNav } from './admin-nav'; + +export const dynamic = 'force-dynamic'; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await requireAdmin(); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..61564c2 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,117 @@ +import Link from 'next/link'; +import { computeMetrics } from '@/lib/admin/metrics'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { formatBytes, formatDate } from '@/lib/format'; + +export const dynamic = 'force-dynamic'; + +type OverQuotaRow = { + id: string; + subdomain: string; + bytes_used: number; + quota_bytes: number; +}; + +export default async function AdminOverviewPage() { + const metrics = await computeMetrics(); + const admin = getSupabaseAdmin(); + + // Recent signups (latest 5 users). + const { data: recentUsersData } = await admin.auth.admin.listUsers({ + page: 1, + perPage: 5, + }); + const recentUsers = recentUsersData?.users ?? []; + + // Over-quota tunnels (compute in memory). + const { data: tunnelsData } = await admin + .from('tunnels') + .select('id, subdomain, bytes_used, quota_bytes'); + const overQuota = ((tunnelsData ?? []) as OverQuotaRow[]) + .filter((t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes) + .slice(0, 5); + + const kpis: { label: string; value: string }[] = [ + { label: 'Total users', value: String(metrics.totalUsers) }, + { label: 'Total tunnels', value: String(metrics.totalTunnels) }, + { label: 'Active tunnels', value: String(metrics.activeTunnels) }, + { label: 'Inactive tunnels', value: String(metrics.inactiveTunnels) }, + { label: 'Over quota', value: String(metrics.overQuota) }, + { label: 'Active last 24h', value: String(metrics.recentlyActive) }, + { label: 'Signups (7d)', value: String(metrics.signups7d) }, + { label: 'Signups (30d)', value: String(metrics.signups30d) }, + { label: 'Bandwidth used', value: formatBytes(metrics.bytesUsedTotal) }, + { label: 'Total quota', value: formatBytes(metrics.quotaTotal) }, + ]; + + return ( +
+

Overview

+ +
+ {kpis.map((k) => ( +
+
{k.value}
+
{k.label}
+
+ ))} +
+ +
+
+

Recent signups

+ {recentUsers.length === 0 ? ( +

No users yet.

+ ) : ( + + + + + + + + + {recentUsers.map((u) => ( + + + + + ))} + +
EmailJoined
+ + {u.email ?? u.id} + + {formatDate(u.created_at)}
+ )} +
+ +
+

Over-quota tunnels

+ {overQuota.length === 0 ? ( +

None over quota.

+ ) : ( + + + + + + + + + {overQuota.map((t) => ( + + + + + ))} + +
SubdomainUsage
{t.subdomain} + {formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)} +
+ )} +
+
+
+ ); +} diff --git a/app/admin/reserved/page.tsx b/app/admin/reserved/page.tsx new file mode 100644 index 0000000..33e4ce6 --- /dev/null +++ b/app/admin/reserved/page.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { formatDate } from '@/lib/format'; + +type Reserved = { name: string; created_at: string }; + +export default function AdminReservedPage() { + const [reserved, setReserved] = useState([]); + const [hardcoded, setHardcoded] = useState([]); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/admin/reserved'); + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(b.error ?? `Request failed (${res.status})`); + } + const data = (await res.json()) as { + reserved: Reserved[]; + hardcoded: string[]; + }; + setReserved(data.reserved); + setHardcoded(data.hardcoded); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + async function add(e: React.FormEvent) { + e.preventDefault(); + const value = name.trim().toLowerCase(); + if (!value) return; + setBusy(true); + setError(null); + setNotice(null); + try { + const res = await fetch('/api/admin/reserved', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: value }), + }); + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(b.error ?? `Request failed (${res.status})`); + } + setName(''); + setNotice(`Reserved '${value}'`); + await load(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + + async function remove(n: string) { + if (!window.confirm(`Remove reserved subdomain '${n}'?`)) return; + setBusy(true); + setError(null); + setNotice(null); + try { + const res = await fetch( + `/api/admin/reserved?name=${encodeURIComponent(n)}`, + { method: 'DELETE' }, + ); + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(b.error ?? `Request failed (${res.status})`); + } + setNotice(`Removed '${n}'`); + await load(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + + return ( +
+

Reserved subdomains

+ +
+

Add reserved subdomain

+
+
+ setName(e.target.value.toLowerCase())} + placeholder="e.g. status" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + style={{ maxWidth: 280 }} + /> + +
+
+ {error &&

{error}

} + {notice &&

{notice}

} +
+ +
+

Database reserved

+ {loading ? ( +

Loading…

+ ) : reserved.length === 0 ? ( +

None reserved in the database.

+ ) : ( +
+ + + + + + + + + + {reserved.map((r) => ( + + + + + + ))} + +
NameAdded
{r.name}{formatDate(r.created_at)} + +
+
+ )} +
+ +
+

Built-in reserved

+

+ These are hardcoded in the app and always reserved (cannot be removed + here). +

+
+ {hardcoded.map((h) => ( + + {h} + + ))} +
+
+
+ ); +} diff --git a/app/admin/tunnels/page.tsx b/app/admin/tunnels/page.tsx new file mode 100644 index 0000000..e5c0c0d --- /dev/null +++ b/app/admin/tunnels/page.tsx @@ -0,0 +1,346 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { formatBytes, formatDate } from '@/lib/format'; + +type Tunnel = { + id: string; + 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; +}; + +const PER_PAGE = 25; + +export default function AdminTunnelsPage() { + const [tunnels, setTunnels] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [status, setStatus] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [busyId, setBusyId] = useState(null); + + 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()}`); + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(b.error ?? `Request failed (${res.status})`); + } + const data = (await res.json()) as { tunnels: Tunnel[]; total: number }; + setTunnels(data.tunnels); + setTotal(data.total); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [page, search, status]); + + useEffect(() => { + const t = setTimeout(load, 250); + return () => clearTimeout(t); + }, [load]); + + async function act( + id: string, + label: string, + url: string, + init: RequestInit, + confirmMsg?: string, + ): Promise { + if (confirmMsg && !window.confirm(confirmMsg)) return null; + setBusyId(id); + setError(null); + setNotice(null); + try { + const res = await fetch(url, init); + const body = (await res.json().catch(() => ({}))) as { + error?: string; + [k: string]: unknown; + }; + if (!res.ok) throw new Error(body.error ?? `Request failed (${res.status})`); + setNotice(`${label} succeeded`); + await load(); + return body; + } catch (e) { + setError((e as Error).message); + return null; + } finally { + setBusyId(null); + } + } + + const jsonInit = (body: unknown): RequestInit => ({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + async function onToggleActive(t: Tunnel) { + await act( + t.id, + t.is_active ? 'Deactivate' : 'Activate', + `/api/admin/tunnels/${t.id}/active`, + jsonInit({ is_active: !t.is_active }), + ); + } + + async function onRegenerate(t: Tunnel) { + const body = (await act( + t.id, + 'Regenerate token', + `/api/admin/tunnels/${t.id}/regenerate-token`, + { method: 'POST' }, + `Regenerate the token for ${t.subdomain}? The old token stops working.`, + )) as { token?: string } | null; + if (body?.token) { + window.prompt('New token (copy it now):', body.token); + } + } + + async function onResetUsage(t: Tunnel) { + await act( + t.id, + 'Reset usage', + `/api/admin/tunnels/${t.id}/reset-usage`, + { method: 'POST' }, + `Reset bandwidth usage for ${t.subdomain} to zero?`, + ); + } + + async function onSetQuota(t: Tunnel) { + const input = window.prompt( + `New quota in GiB for ${t.subdomain}:`, + String(Math.round(t.quota_bytes / 1024 ** 3)), + ); + if (input === null) return; + const gib = Number(input); + if (!Number.isFinite(gib) || gib <= 0) { + setError('Quota must be a positive number of GiB'); + return; + } + await act( + t.id, + 'Set quota', + `/api/admin/tunnels/${t.id}/quota`, + jsonInit({ quota_bytes: Math.round(gib * 1024 ** 3) }), + ); + } + + async function onReassign(t: Tunnel) { + const input = window.prompt( + `New subdomain for ${t.owner_email ?? t.subdomain}:`, + t.subdomain, + ); + if (input === null) return; + await act( + t.id, + 'Reassign', + `/api/admin/tunnels/${t.id}/reassign`, + jsonInit({ subdomain: input.trim().toLowerCase() }), + ); + } + + async function onDelete(t: Tunnel) { + await act( + t.id, + 'Delete tunnel', + `/api/admin/tunnels/${t.id}`, + { method: 'DELETE' }, + `Delete the tunnel ${t.subdomain}? This frees the subdomain.`, + ); + } + + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + return ( +
+

Tunnels

+ +
+ { + setPage(1); + setSearch(e.target.value); + }} + style={{ maxWidth: 260 }} + /> + + +
+ + {error &&

{error}

} + {notice &&

{notice}

} + + {loading ? ( +

Loading…

+ ) : tunnels.length === 0 ? ( +

No tunnels found.

+ ) : ( +
+ + + + + + + + + + + + + {tunnels.map((t) => ( + + + + + + + + + ))} + +
SubdomainOwnerStatusUsageLast seenActions
{t.subdomain} + {t.owner_email ?? '—'} + + {t.is_active ? ( + active + ) : ( + inactive + )} + +
+ {formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)} +
+
+
= 100 + ? 'var(--danger)' + : 'var(--accent)', + }} + /> +
+
{formatDate(t.last_seen_at)} +
+ + + + + + +
+
+
+ )} + +
+ + + Page {page} of {totalPages} ({total} total) + + +
+
+ ); +} diff --git a/app/admin/users/[id]/page.tsx b/app/admin/users/[id]/page.tsx new file mode 100644 index 0000000..d9622a7 --- /dev/null +++ b/app/admin/users/[id]/page.tsx @@ -0,0 +1,179 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { isUuid } from '@/lib/admin/validators'; +import { formatBytes, formatDate } from '@/lib/format'; +import { UserActions } from './user-actions'; + +export const dynamic = 'force-dynamic'; + +type TunnelRow = { + id: string; + subdomain: string; + is_active: boolean; + bytes_used: number; + quota_bytes: number; + last_seen_at: string | null; + created_at: string; +}; + +type AuditRow = { + id: number; + actor_email: string | null; + action: string; + target_type: string | null; + target_id: string | null; + details: Record; + created_at: string; +}; + +export default async function AdminUserDetailPage({ + params, +}: { + params: { id: string }; +}) { + if (!isUuid(params.id)) notFound(); + + const admin = getSupabaseAdmin(); + const supabase = createSupabaseServerClient(); + const { + data: { user: currentUser }, + } = await supabase.auth.getUser(); + + const { data: userRes, error } = await admin.auth.admin.getUserById( + params.id, + ); + if (error || !userRes.user) notFound(); + const u = userRes.user; + const role = (u.app_metadata?.role as string | undefined) ?? 'user'; + const bannedUntil = + (u as unknown as { banned_until?: string | null }).banned_until ?? null; + const banned = !!bannedUntil && new Date(bannedUntil).getTime() > Date.now(); + + const { data: tunnel } = await admin + .from('tunnels') + .select( + 'id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at', + ) + .eq('user_id', params.id) + .maybeSingle(); + + const { data: audit } = await admin + .from('admin_audit_log') + .select( + 'id, actor_email, action, target_type, target_id, details, created_at', + ) + .eq('target_id', params.id) + .order('created_at', { ascending: false }) + .limit(25); + + const isSelf = currentUser?.id === params.id; + + return ( +
+

+ ← Users +

+

{u.email ?? u.id}

+ +
+

Account

+
+
User ID
+
{u.id}
+
Role
+
+ {role === 'admin' ? ( + admin + ) : ( + user + )} +
+
Status
+
+ {banned ? ( + banned + ) : u.email_confirmed_at ? ( + confirmed + ) : ( + unconfirmed + )} +
+
Created
+
{formatDate(u.created_at)}
+
Last sign-in
+
{formatDate(u.last_sign_in_at)}
+
+ + +
+ +
+

Tunnel

+ {tunnel ? ( +
+
Subdomain
+
{tunnel.subdomain}.linumiq.net
+
Status
+
{tunnel.is_active ? 'Active' : 'Inactive'}
+
Usage
+
+ {formatBytes(tunnel.bytes_used)} /{' '} + {formatBytes(tunnel.quota_bytes)} +
+
Last seen
+
{formatDate(tunnel.last_seen_at)}
+
Created
+
{formatDate(tunnel.created_at)}
+
Manage
+
+ Go to tunnels → +
+
+ ) : ( +

No tunnel claimed.

+ )} +
+ +
+

Audit history

+ {audit && audit.length > 0 ? ( +
+ + + + + + + + + + + {(audit as AuditRow[]).map((a) => ( + + + + + + + ))} + +
WhenActionByDetails
{formatDate(a.created_at)}{a.action}{a.actor_email ?? '—'} + + {JSON.stringify(a.details ?? {})} + +
+
+ ) : ( +

No audit entries.

+ )} +
+
+ ); +} diff --git a/app/admin/users/[id]/user-actions.tsx b/app/admin/users/[id]/user-actions.tsx new file mode 100644 index 0000000..0887f5e --- /dev/null +++ b/app/admin/users/[id]/user-actions.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +type Props = { + userId: string; + role: string; + banned: boolean; + isSelf: boolean; +}; + +export function UserActions({ userId, role, banned, isSelf }: Props) { + const router = useRouter(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + async function call( + label: string, + url: string, + init: RequestInit, + confirmMsg?: string, + ) { + if (confirmMsg && !window.confirm(confirmMsg)) return; + setBusy(label); + setError(null); + setSuccess(null); + try { + const res = await fetch(url, init); + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(b.error ?? `Request failed (${res.status})`); + } + setSuccess(`${label} succeeded`); + router.refresh(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(null); + } + } + + const jsonInit = (body: unknown): RequestInit => ({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return ( +
+ {error &&

{error}

} + {success &&

{success}

} +
+ {role === 'admin' ? ( + + ) : ( + + )} + + + + +
+
+ ); +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..c72ba2a --- /dev/null +++ b/app/admin/users/page.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { formatBytes, formatDate } from '@/lib/format'; + +type AdminUser = { + 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; +}; + +const PER_PAGE = 25; + +function isBanned(u: AdminUser): boolean { + return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now(); +} + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + 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()}`); + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(b.error ?? `Request failed (${res.status})`); + } + const data = (await res.json()) as { users: AdminUser[]; total: number }; + setUsers(data.users); + setTotal(data.total); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { + const t = setTimeout(load, 250); + return () => clearTimeout(t); + }, [load]); + + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + return ( +
+

Users

+ +
+ { + setPage(1); + setSearch(e.target.value); + }} + style={{ maxWidth: 320 }} + /> + +
+ + {error &&

{error}

} + + {loading ? ( +

Loading…

+ ) : users.length === 0 ? ( +

No users found.

+ ) : ( +
+ + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
EmailRoleStatusTunnelUsageCreated
+ + {u.email ?? u.id} + + + {u.role === 'admin' ? ( + admin + ) : ( + user + )} + + {isBanned(u) ? ( + banned + ) : u.email_confirmed_at ? ( + confirmed + ) : ( + unconfirmed + )} + {u.tunnel ? u.tunnel.subdomain : '—'} + {u.tunnel + ? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes( + u.tunnel.quota_bytes, + )}` + : '—'} + {formatDate(u.created_at)}
+
+ )} + +
+ + + Page {page} of {totalPages} ({total} total) + + +
+
+ ); +} diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts new file mode 100644 index 0000000..33b8566 --- /dev/null +++ b/app/api/admin/audit/route.ts @@ -0,0 +1,45 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; + +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 page = parsePageParam(url.searchParams.get('page'), 1); + const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100); + const action = (url.searchParams.get('action') ?? '').trim(); + const targetType = (url.searchParams.get('target_type') ?? '').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) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ + entries: data ?? [], + total: count ?? (data?.length ?? 0), + page, + perPage, + }); +} diff --git a/app/api/admin/metrics/route.ts b/app/api/admin/metrics/route.ts new file mode 100644 index 0000000..4874d23 --- /dev/null +++ b/app/api/admin/metrics/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { computeMetrics } from '@/lib/admin/metrics'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const metrics = await computeMetrics(); + return NextResponse.json(metrics); +} diff --git a/app/api/admin/reserved/route.ts b/app/api/admin/reserved/route.ts new file mode 100644 index 0000000..4bfd747 --- /dev/null +++ b/app/api/admin/reserved/route.ts @@ -0,0 +1,95 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { logAdminAction } from '@/lib/auth/audit'; +import { RESERVED_SUBDOMAINS } from '@/lib/validation'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const NAME_RE = /^[a-z0-9-]{1,63}$/; + +export async function GET() { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('reserved_subdomains') + .select('name, created_at') + .order('name', { ascending: true }); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ + reserved: data ?? [], + hardcoded: Array.from(RESERVED_SUBDOMAINS).sort(), + }); +} + +export async function POST(req: NextRequest) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + let body: { name?: unknown }; + try { + body = (await req.json()) as { name?: unknown }; + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + } + if (typeof body.name !== 'string') { + return NextResponse.json({ error: 'name must be a string' }, { status: 400 }); + } + const name = body.name.trim().toLowerCase(); + if (!NAME_RE.test(name)) { + return NextResponse.json( + { error: 'name must be 1–63 chars, lowercase a–z, 0–9, hyphen' }, + { status: 400 }, + ); + } + + const admin = getSupabaseAdmin(); + const { error } = await admin + .from('reserved_subdomains') + .upsert({ name }, { onConflict: 'name' }); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + await logAdminAction(auth.user, { + action: 'reserved.add', + target_type: 'reserved_subdomain', + target_id: name, + }); + + return NextResponse.json({ ok: true, name }); +} + +export async function DELETE(req: NextRequest) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const url = new URL(req.url); + const name = (url.searchParams.get('name') ?? '').trim().toLowerCase(); + if (!name) { + return NextResponse.json({ error: 'name is required' }, { status: 400 }); + } + + const admin = getSupabaseAdmin(); + const { error } = await admin + .from('reserved_subdomains') + .delete() + .eq('name', name); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + await logAdminAction(auth.user, { + action: 'reserved.remove', + target_type: 'reserved_subdomain', + target_id: name, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/admin/tunnels/[id]/active/route.ts b/app/api/admin/tunnels/[id]/active/route.ts new file mode 100644 index 0000000..9107ee2 --- /dev/null +++ b/app/api/admin/tunnels/[id]/active/route.ts @@ -0,0 +1,62 @@ +import { NextResponse, type NextRequest } from 'next/server'; +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'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + } + + let body: { is_active?: unknown }; + try { + body = (await req.json()) as { is_active?: unknown }; + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + } + const isActive = parseBoolean(body.is_active); + if (isActive === null) { + return NextResponse.json( + { error: 'is_active must be a boolean' }, + { status: 400 }, + ); + } + + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('tunnels') + .update({ is_active: isActive }) + .eq('id', id) + .select('subdomain') + .maybeSingle<{ subdomain: string }>(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + if (!data) { + return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + } + + // Best-effort live kill-switch (never throws). + await redisSet(`tunnel:active:${data.subdomain}`, isActive ? '1' : '0'); + + await logAdminAction(auth.user, { + action: isActive ? 'tunnel.activate' : 'tunnel.deactivate', + target_type: 'tunnel', + target_id: id, + details: { subdomain: data.subdomain, is_active: isActive }, + }); + + return NextResponse.json({ ok: true, is_active: isActive }); +} diff --git a/app/api/admin/tunnels/[id]/quota/route.ts b/app/api/admin/tunnels/[id]/quota/route.ts new file mode 100644 index 0000000..75754b5 --- /dev/null +++ b/app/api/admin/tunnels/[id]/quota/route.ts @@ -0,0 +1,61 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { logAdminAction } from '@/lib/auth/audit'; +import { isUuid, parsePositiveInt } from '@/lib/admin/validators'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// 100 TiB ceiling — generous but guards against absurd values. +const MAX_QUOTA = 100 * 1024 ** 4; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + } + + let body: { quota_bytes?: unknown }; + try { + body = (await req.json()) as { quota_bytes?: unknown }; + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + } + const parsed = parsePositiveInt(body.quota_bytes, MAX_QUOTA); + if (!parsed.ok) { + return NextResponse.json( + { error: `quota_bytes ${parsed.error}` }, + { status: 400 }, + ); + } + + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('tunnels') + .update({ quota_bytes: parsed.value }) + .eq('id', id) + .select('subdomain') + .maybeSingle<{ subdomain: string }>(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + if (!data) { + return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + } + + await logAdminAction(auth.user, { + action: 'tunnel.quota', + target_type: 'tunnel', + target_id: id, + details: { subdomain: data.subdomain, quota_bytes: parsed.value }, + }); + + return NextResponse.json({ ok: true, quota_bytes: parsed.value }); +} diff --git a/app/api/admin/tunnels/[id]/reassign/route.ts b/app/api/admin/tunnels/[id]/reassign/route.ts new file mode 100644 index 0000000..050d269 --- /dev/null +++ b/app/api/admin/tunnels/[id]/reassign/route.ts @@ -0,0 +1,83 @@ +import { NextResponse, type NextRequest } from 'next/server'; +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 { validateSubdomain } from '@/lib/validation'; +import { isSubdomainReserved } from '@/lib/admin/reserved'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + } + + let body: { subdomain?: unknown }; + try { + body = (await req.json()) as { subdomain?: unknown }; + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + } + + // Same validation as the user-facing claim flow (format + hardcoded reserved). + const v = validateSubdomain(body.subdomain); + if (!v.ok) { + return NextResponse.json({ error: v.error }, { status: 400 }); + } + const subdomain = v.value; + + // Also reject anything reserved in the DB table. + if (await isSubdomainReserved(subdomain)) { + return NextResponse.json( + { error: `'${subdomain}' is reserved` }, + { status: 400 }, + ); + } + + const admin = getSupabaseAdmin(); + + // Reject if taken by a different tunnel. + const { data: existing } = await admin + .from('tunnels') + .select('id') + .eq('subdomain', subdomain) + .maybeSingle<{ id: string }>(); + if (existing && existing.id !== id) { + return NextResponse.json({ error: 'subdomain taken' }, { status: 409 }); + } + + const { data, error } = await admin + .from('tunnels') + .update({ subdomain }) + .eq('id', id) + .select('subdomain') + .maybeSingle<{ subdomain: string }>(); + if (error) { + const code = (error as { code?: string }).code; + if (code === '23505') { + return NextResponse.json({ error: 'subdomain taken' }, { status: 409 }); + } + return NextResponse.json({ error: error.message }, { status: 500 }); + } + if (!data) { + return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + } + + await logAdminAction(auth.user, { + action: 'tunnel.reassign', + target_type: 'tunnel', + target_id: id, + details: { subdomain }, + }); + + return NextResponse.json({ ok: true, subdomain }); +} diff --git a/app/api/admin/tunnels/[id]/regenerate-token/route.ts b/app/api/admin/tunnels/[id]/regenerate-token/route.ts new file mode 100644 index 0000000..6cd4a09 --- /dev/null +++ b/app/api/admin/tunnels/[id]/regenerate-token/route.ts @@ -0,0 +1,47 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { randomBytes } from 'node:crypto'; +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'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + } + + const token = randomBytes(32).toString('hex'); + + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('tunnels') + .update({ token }) + .eq('id', id) + .select('subdomain, token') + .maybeSingle<{ subdomain: string; token: string }>(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + if (!data) { + return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + } + + await logAdminAction(auth.user, { + action: 'tunnel.regenerate_token', + target_type: 'tunnel', + target_id: id, + details: { subdomain: data.subdomain }, + }); + + return NextResponse.json({ ok: true, token: data.token }); +} diff --git a/app/api/admin/tunnels/[id]/reset-usage/route.ts b/app/api/admin/tunnels/[id]/reset-usage/route.ts new file mode 100644 index 0000000..2da9ac6 --- /dev/null +++ b/app/api/admin/tunnels/[id]/reset-usage/route.ts @@ -0,0 +1,44 @@ +import { NextResponse, type NextRequest } from 'next/server'; +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'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + } + + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('tunnels') + .update({ bytes_used: 0 }) + .eq('id', id) + .select('subdomain') + .maybeSingle<{ subdomain: string }>(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + if (!data) { + return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + } + + await logAdminAction(auth.user, { + action: 'tunnel.reset_usage', + target_type: 'tunnel', + target_id: id, + details: { subdomain: data.subdomain }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/admin/tunnels/[id]/route.ts b/app/api/admin/tunnels/[id]/route.ts new file mode 100644 index 0000000..ace4404 --- /dev/null +++ b/app/api/admin/tunnels/[id]/route.ts @@ -0,0 +1,48 @@ +import { NextResponse, type NextRequest } from 'next/server'; +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'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function DELETE( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid tunnel id' }, { status: 400 }); + } + + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('tunnels') + .delete() + .eq('id', id) + .select('subdomain') + .maybeSingle<{ subdomain: string }>(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + if (!data) { + return NextResponse.json({ error: 'tunnel not found' }, { status: 404 }); + } + + // Best-effort live kill-switch. + await redisSet(`tunnel:active:${data.subdomain}`, '0'); + + await logAdminAction(auth.user, { + action: 'tunnel.delete', + target_type: 'tunnel', + target_id: id, + details: { subdomain: data.subdomain }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/admin/tunnels/route.ts b/app/api/admin/tunnels/route.ts new file mode 100644 index 0000000..5da2a29 --- /dev/null +++ b/app/api/admin/tunnels/route.ts @@ -0,0 +1,98 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type TunnelRow = { + id: string; + user_id: string; + subdomain: string; + is_active: boolean; + bytes_used: number; + quota_bytes: number; + last_seen_at: string | null; + created_at: string; +}; + +export async function GET(req: NextRequest) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const url = new URL(req.url); + const page = parsePageParam(url.searchParams.get('page'), 1); + const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100); + const search = (url.searchParams.get('search') ?? '').trim().toLowerCase(); + const status = url.searchParams.get('status'); // active|inactive|over_quota + + const admin = getSupabaseAdmin(); + + const cols = + 'id, 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) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + 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) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + 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 = rows.map((t, i) => ({ + id: t.id, + 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 NextResponse.json({ tunnels, total, page, perPage }); +} diff --git a/app/api/admin/users/[id]/ban/route.ts b/app/api/admin/users/[id]/ban/route.ts new file mode 100644 index 0000000..5a243a3 --- /dev/null +++ b/app/api/admin/users/[id]/ban/route.ts @@ -0,0 +1,61 @@ +import { NextResponse, type NextRequest } from 'next/server'; +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'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// ~100 years. +const BAN_DURATION = '876000h'; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid user id' }, { status: 400 }); + } + if (id === auth.user.id) { + return NextResponse.json( + { error: 'you cannot ban your own account' }, + { status: 400 }, + ); + } + + let body: { banned?: unknown }; + try { + body = (await req.json()) as { banned?: unknown }; + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + } + const banned = parseBoolean(body.banned); + if (banned === null) { + return NextResponse.json( + { error: 'banned must be a boolean' }, + { status: 400 }, + ); + } + + const admin = getSupabaseAdmin(); + const { error } = await admin.auth.admin.updateUserById(id, { + ban_duration: banned ? BAN_DURATION : 'none', + } as { ban_duration: string }); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + await logAdminAction(auth.user, { + action: banned ? 'user.ban' : 'user.unban', + target_type: 'user', + target_id: id, + details: { banned }, + }); + + return NextResponse.json({ ok: true, banned }); +} diff --git a/app/api/admin/users/[id]/role/route.ts b/app/api/admin/users/[id]/role/route.ts new file mode 100644 index 0000000..8b24f09 --- /dev/null +++ b/app/api/admin/users/[id]/role/route.ts @@ -0,0 +1,67 @@ +import { NextResponse, type NextRequest } from 'next/server'; +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'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid user id' }, { status: 400 }); + } + if (id === auth.user.id) { + return NextResponse.json( + { error: 'you cannot change your own role' }, + { status: 400 }, + ); + } + + let body: { role?: unknown }; + try { + body = (await req.json()) as { role?: unknown }; + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + } + if (body.role !== 'admin' && body.role !== 'user') { + return NextResponse.json( + { error: "role must be 'admin' or 'user'" }, + { status: 400 }, + ); + } + const role = body.role; + + const admin = getSupabaseAdmin(); + + // Merge with existing app_metadata so we don't clobber other keys. + const { data: existing, error: getErr } = + await admin.auth.admin.getUserById(id); + if (getErr || !existing.user) { + return NextResponse.json({ error: 'user not found' }, { status: 404 }); + } + const merged = { ...(existing.user.app_metadata ?? {}), role }; + + const { error } = await admin.auth.admin.updateUserById(id, { + app_metadata: merged, + }); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + await logAdminAction(auth.user, { + action: 'user.role', + target_type: 'user', + target_id: id, + details: { role }, + }); + + return NextResponse.json({ ok: true, role }); +} diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..35e2e73 --- /dev/null +++ b/app/api/admin/users/[id]/route.ts @@ -0,0 +1,117 @@ +import { NextResponse, type NextRequest } from 'next/server'; +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'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type TunnelRow = { + user_id: string; + subdomain: string; + token: string; + is_active: boolean; + bytes_used: number; + quota_bytes: number; + last_seen_at: string | null; + created_at: string; +}; + +export async function GET( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid user id' }, { status: 400 }); + } + + const admin = getSupabaseAdmin(); + + const { data: userRes, error: userErr } = + await admin.auth.admin.getUserById(id); + if (userErr || !userRes.user) { + return NextResponse.json({ error: 'user not found' }, { status: 404 }); + } + const u = userRes.user; + + const { data: tunnel } = await admin + .from('tunnels') + .select( + 'user_id, subdomain, token, is_active, bytes_used, quota_bytes, last_seen_at, created_at', + ) + .eq('user_id', id) + .maybeSingle(); + + const { data: audit } = await admin + .from('admin_audit_log') + .select('id, actor_email, action, target_type, target_id, details, created_at') + .eq('target_id', id) + .order('created_at', { ascending: false }) + .limit(25); + + return NextResponse.json({ + user: { + 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: tunnel + ? { + subdomain: tunnel.subdomain, + is_active: tunnel.is_active, + bytes_used: tunnel.bytes_used, + quota_bytes: tunnel.quota_bytes, + last_seen_at: tunnel.last_seen_at, + created_at: tunnel.created_at, + } + : null, + audit: audit ?? [], + }); +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const { id } = params; + if (!isUuid(id)) { + return NextResponse.json({ error: 'invalid user id' }, { status: 400 }); + } + if (id === auth.user.id) { + return NextResponse.json( + { error: 'you cannot delete your own account' }, + { status: 400 }, + ); + } + + const admin = getSupabaseAdmin(); + + // Remove the tunnel row first (FK to auth.users). + await admin.from('tunnels').delete().eq('user_id', id); + + const { error } = await admin.auth.admin.deleteUser(id); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + await logAdminAction(auth.user, { + action: 'user.delete', + target_type: 'user', + target_id: id, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..3a99889 --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,78 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { requireAdminApi } from '@/lib/auth/admin-guard'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type TunnelRow = { + user_id: string; + subdomain: string; + is_active: boolean; + bytes_used: number; + quota_bytes: number; +}; + +export async function GET(req: NextRequest) { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const url = new URL(req.url); + const page = parsePageParam(url.searchParams.get('page'), 1); + const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100); + const search = (url.searchParams.get('search') ?? '').trim().toLowerCase(); + + const admin = getSupabaseAdmin(); + + const { data, error } = await admin.auth.admin.listUsers({ page, perPage }); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + let users = data.users; + const total = (data as unknown as { total?: number }).total ?? users.length; + + if (search) { + users = users.filter((u) => + (u.email ?? '').toLowerCase().includes(search), + ); + } + + // Join tunnel rows for this page's users in a single query. + const ids = users.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 TunnelRow[]) { + tunnelMap.set(t.user_id, t); + } + } + + const result = users.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 NextResponse.json({ users: result, total, page, perPage }); +} diff --git a/app/globals.css b/app/globals.css index 0c3f73b..d51d6fc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -165,3 +165,203 @@ button.secondary, gap: 0.5rem; align-items: center; } + +/* ----------------------------------------------------------------------- */ +/* Admin interface */ +/* ----------------------------------------------------------------------- */ + +.admin-shell { + display: flex; + min-height: calc(100vh - 65px); + align-items: stretch; +} + +.admin-sidebar { + width: 220px; + flex: 0 0 220px; + border-right: 1px solid var(--border); + padding: 1.5rem 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.admin-brand { + font-weight: 700; + font-size: 1.1rem; + letter-spacing: 0.02em; +} + +.admin-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.admin-nav-link { + display: block; + padding: 0.5rem 0.75rem; + border-radius: 6px; + color: var(--fg); +} +.admin-nav-link:hover { + background: var(--card); + text-decoration: none; +} +.admin-nav-link.active { + background: var(--accent); + color: var(--accent-fg); +} + +.admin-sidebar-footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + border-top: 1px solid var(--border); + padding-top: 1rem; +} +.admin-back { + font-size: 0.875rem; +} + +.admin-content { + flex: 1 1 auto; + padding: 2rem; + min-width: 0; +} + +.admin-cols { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} +@media (max-width: 800px) { + .admin-shell { + flex-direction: column; + } + .admin-sidebar { + width: auto; + flex: none; + border-right: none; + border-bottom: 1px solid var(--border); + } + .admin-sidebar-footer { + margin-top: 0; + } + .admin-cols { + grid-template-columns: 1fr; + } + .admin-content { + padding: 1.25rem; + } +} + +/* KPI cards */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.75rem; + margin: 1rem 0 1.5rem; +} +.kpi-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} +.kpi-value { + font-size: 1.5rem; + font-weight: 700; +} +.kpi-label { + color: var(--muted); + font-size: 0.8rem; + margin-top: 0.25rem; +} + +/* Tables */ +.admin-table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 8px; +} +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} +.admin-table th, +.admin-table td { + text-align: left; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.admin-table th { + color: var(--muted); + font-weight: 600; + background: var(--card); + white-space: nowrap; +} +.admin-table tr:last-child td { + border-bottom: none; +} +.admin-table tbody tr:hover { + background: rgba(255, 255, 255, 0.02); +} +.admin-table code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + word-break: break-all; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + border: 1px solid var(--border); + background: var(--bg); + color: var(--muted); + white-space: nowrap; +} +.badge-admin { + background: rgba(59, 130, 246, 0.15); + border-color: var(--accent); + color: #93c5fd; +} +.badge-banned { + background: rgba(239, 68, 68, 0.15); + border-color: var(--danger); + color: #fca5a5; +} +.badge-ok { + background: rgba(34, 197, 94, 0.15); + border-color: var(--success); + color: #86efac; +} + +/* Button variants */ +.btn-sm { + padding: 0.35rem 0.6rem; + font-size: 0.8rem; +} +.btn-danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; +} +.btn-danger:hover { + opacity: 0.9; +} +button:disabled, +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +select { + font-size: 1rem; +} diff --git a/app/layout.tsx b/app/layout.tsx index c91ce40..16ae673 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,6 +19,7 @@ export default async function RootLayout({ const { data: { user }, } = await supabase.auth.getUser(); + const isAdmin = user?.app_metadata?.role === 'admin'; return ( @@ -32,6 +33,7 @@ export default async function RootLayout({ <> Dashboard Billing + {isAdmin && Admin}