fix(admin): key tunnels by user_id, server-side initial list load, full-scan user search

This commit is contained in:
Gerhard Scheikl
2026-05-31 11:46:14 +02:00
parent fb4880a1d9
commit b6c4d94990
19 changed files with 1676 additions and 840 deletions
+163
View File
@@ -0,0 +1,163 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatDate } from '@/lib/format';
import type { AuditItem } from '@/lib/admin/list';
const PER_PAGE = 50;
export function AuditTable({
initialEntries,
initialTotal,
}: {
initialEntries: AuditItem[];
initialTotal: number;
}) {
const [entries, setEntries] = useState<AuditItem[]>(initialEntries);
const [total, setTotal] = useState(initialTotal);
const [page, setPage] = useState(1);
const [action, setAction] = useState('');
const [targetType, setTargetType] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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()}`, {
credentials: 'same-origin',
});
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: AuditItem[];
total: number;
};
setEntries(data.entries);
setTotal(data.total);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [page, action, targetType]);
// First page is server-rendered; skip the on-mount fetch to avoid racing the
// SSR session-cookie refresh (which intermittently 401'd).
const didMount = useRef(false);
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
return (
<div>
<h1>Audit log</h1>
<div className="row" style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Filter action (e.g. tunnel.delete)"
value={action}
onChange={(e) => {
setPage(1);
setAction(e.target.value);
}}
style={{ maxWidth: 240 }}
/>
<input
type="text"
placeholder="Filter target_type (user/tunnel…)"
value={targetType}
onChange={(e) => {
setPage(1);
setTargetType(e.target.value);
}}
style={{ maxWidth: 240 }}
/>
<button className="secondary btn-sm" type="button" onClick={load}>
Refresh
</button>
</div>
{error && <p className="error">{error}</p>}
{loading ? (
<p className="muted">Loading</p>
) : entries.length === 0 ? (
<p className="muted">No audit entries.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id}>
<td>{formatDate(e.created_at)}</td>
<td style={{ wordBreak: 'break-all' }}>
{e.actor_email ?? e.actor_id ?? '—'}
</td>
<td>{e.action}</td>
<td style={{ wordBreak: 'break-all' }}>
{e.target_type ? `${e.target_type}:` : ''}
{e.target_id ?? ''}
</td>
<td>
<code className="muted">
{JSON.stringify(e.details ?? {})}
</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="row" style={{ marginTop: '1rem' }}>
<button
className="secondary btn-sm"
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span className="muted">
Page {page} of {totalPages} ({total} total)
</span>
<button
className="secondary btn-sm"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
}
+12 -151
View File
@@ -1,158 +1,19 @@
'use client';
import { getAuditList } from '@/lib/admin/list';
import { AuditTable } from './audit-table';
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<string, unknown>;
created_at: string;
};
export const dynamic = 'force-dynamic';
const PER_PAGE = 50;
export default function AdminAuditPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
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<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(page),
perPage: String(PER_PAGE),
export default async function AdminAuditPage() {
// Initial load on the server (admin session already validated by the layout)
// so the first paint never races the client session-cookie refresh.
const { entries, total } = await getAuditList({
page: 1,
perPage: PER_PAGE,
action: '',
targetType: '',
});
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 (
<div>
<h1>Audit log</h1>
<div className="row" style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Filter action (e.g. tunnel.delete)"
value={action}
onChange={(e) => {
setPage(1);
setAction(e.target.value);
}}
style={{ maxWidth: 240 }}
/>
<input
type="text"
placeholder="Filter target_type (user/tunnel…)"
value={targetType}
onChange={(e) => {
setPage(1);
setTargetType(e.target.value);
}}
style={{ maxWidth: 240 }}
/>
<button className="secondary btn-sm" type="button" onClick={load}>
Refresh
</button>
</div>
{error && <p className="error">{error}</p>}
{loading ? (
<p className="muted">Loading</p>
) : entries.length === 0 ? (
<p className="muted">No audit entries.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id}>
<td>{formatDate(e.created_at)}</td>
<td style={{ wordBreak: 'break-all' }}>
{e.actor_email ?? e.actor_id ?? '—'}
</td>
<td>{e.action}</td>
<td style={{ wordBreak: 'break-all' }}>
{e.target_type ? `${e.target_type}:` : ''}
{e.target_id ?? ''}
</td>
<td>
<code className="muted">
{JSON.stringify(e.details ?? {})}
</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="row" style={{ marginTop: '1rem' }}>
<button
className="secondary btn-sm"
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span className="muted">
Page {page} of {totalPages} ({total} total)
</span>
<button
className="secondary btn-sm"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
return <AuditTable initialEntries={entries} initialTotal={total} />;
}
+3 -3
View File
@@ -6,7 +6,7 @@ import { formatBytes, formatDate } from '@/lib/format';
export const dynamic = 'force-dynamic';
type OverQuotaRow = {
id: string;
user_id: string;
subdomain: string;
bytes_used: number;
quota_bytes: number;
@@ -26,7 +26,7 @@ export default async function AdminOverviewPage() {
// Over-quota tunnels (compute in memory).
const { data: tunnelsData } = await admin
.from('tunnels')
.select('id, subdomain, bytes_used, quota_bytes');
.select('user_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);
@@ -100,7 +100,7 @@ export default async function AdminOverviewPage() {
</thead>
<tbody>
{overQuota.map((t) => (
<tr key={t.id}>
<tr key={t.user_id}>
<td>{t.subdomain}</td>
<td>
{formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)}
+12 -339
View File
@@ -1,346 +1,19 @@
'use client';
import { getTunnelsList } from '@/lib/admin/list';
import { TunnelsTable } from './tunnels-table';
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;
};
export const dynamic = 'force-dynamic';
const PER_PAGE = 25;
export default function AdminTunnelsPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
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<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(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<unknown | null> {
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),
export default async function AdminTunnelsPage() {
// Initial load on the server (admin session already validated by the layout)
// so the first paint never races the client session-cookie refresh.
const { tunnels, total } = await getTunnelsList({
page: 1,
perPage: PER_PAGE,
search: '',
status: null,
});
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 (
<div>
<h1>Tunnels</h1>
<div className="row" style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Search subdomain…"
value={search}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
style={{ maxWidth: 260 }}
/>
<select
value={status}
onChange={(e) => {
setPage(1);
setStatus(e.target.value);
}}
style={{
padding: '0.6rem 0.75rem',
background: 'var(--bg)',
color: 'var(--fg)',
border: '1px solid var(--border)',
borderRadius: 6,
}}
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="over_quota">Over quota</option>
</select>
<button className="secondary btn-sm" type="button" onClick={load}>
Refresh
</button>
</div>
{error && <p className="error">{error}</p>}
{notice && <p className="success">{notice}</p>}
{loading ? (
<p className="muted">Loading</p>
) : tunnels.length === 0 ? (
<p className="muted">No tunnels found.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Subdomain</th>
<th>Owner</th>
<th>Status</th>
<th>Usage</th>
<th>Last seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{tunnels.map((t) => (
<tr key={t.id}>
<td>{t.subdomain}</td>
<td style={{ wordBreak: 'break-all' }}>
{t.owner_email ?? '—'}
</td>
<td>
{t.is_active ? (
<span className="badge badge-ok">active</span>
) : (
<span className="badge">inactive</span>
)}
</td>
<td style={{ minWidth: 140 }}>
<div>
{formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)}
</div>
<div className="progress" style={{ marginTop: 4 }}>
<div
style={{
width: `${Math.min(100, t.usage_pct).toFixed(1)}%`,
background:
t.usage_pct >= 100
? 'var(--danger)'
: 'var(--accent)',
}}
/>
</div>
</td>
<td>{formatDate(t.last_seen_at)}</td>
<td>
<div className="row" style={{ flexWrap: 'wrap', gap: 4 }}>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.id}
onClick={() => onToggleActive(t)}
>
{t.is_active ? 'Deactivate' : 'Activate'}
</button>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.id}
onClick={() => onSetQuota(t)}
>
Quota
</button>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.id}
onClick={() => onResetUsage(t)}
>
Reset
</button>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.id}
onClick={() => onReassign(t)}
>
Reassign
</button>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.id}
onClick={() => onRegenerate(t)}
>
Token
</button>
<button
type="button"
className="btn-danger btn-sm"
disabled={busyId === t.id}
onClick={() => onDelete(t)}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="row" style={{ marginTop: '1rem' }}>
<button
className="secondary btn-sm"
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span className="muted">
Page {page} of {totalPages} ({total} total)
</span>
<button
className="secondary btn-sm"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
return <TunnelsTable initialTunnels={tunnels} initialTotal={total} />;
}
+349
View File
@@ -0,0 +1,349 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatBytes, formatDate } from '@/lib/format';
import type { TunnelItem } from '@/lib/admin/list';
const PER_PAGE = 25;
export function TunnelsTable({
initialTunnels,
initialTotal,
}: {
initialTunnels: TunnelItem[];
initialTotal: number;
}) {
const [tunnels, setTunnels] = useState<TunnelItem[]>(initialTunnels);
const [total, setTotal] = useState(initialTotal);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(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()}`, {
credentials: 'same-origin',
});
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: TunnelItem[]; total: number };
setTunnels(data.tunnels);
setTotal(data.total);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [page, search, status]);
// First page is server-rendered; skip the on-mount fetch to avoid racing the
// SSR session-cookie refresh (which intermittently 401'd).
const didMount = useRef(false);
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
async function act(
id: string,
label: string,
url: string,
init: RequestInit,
confirmMsg?: string,
): Promise<unknown | null> {
if (confirmMsg && !window.confirm(confirmMsg)) return null;
setBusyId(id);
setError(null);
setNotice(null);
try {
const res = await fetch(url, { credentials: 'same-origin', ...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: TunnelItem) {
await act(
t.user_id,
t.is_active ? 'Deactivate' : 'Activate',
`/api/admin/tunnels/${t.user_id}/active`,
jsonInit({ is_active: !t.is_active }),
);
}
async function onRegenerate(t: TunnelItem) {
const body = (await act(
t.user_id,
'Regenerate token',
`/api/admin/tunnels/${t.user_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: TunnelItem) {
await act(
t.user_id,
'Reset usage',
`/api/admin/tunnels/${t.user_id}/reset-usage`,
{ method: 'POST' },
`Reset bandwidth usage for ${t.subdomain} to zero?`,
);
}
async function onSetQuota(t: TunnelItem) {
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.user_id,
'Set quota',
`/api/admin/tunnels/${t.user_id}/quota`,
jsonInit({ quota_bytes: Math.round(gib * 1024 ** 3) }),
);
}
async function onReassign(t: TunnelItem) {
const input = window.prompt(
`New subdomain for ${t.owner_email ?? t.subdomain}:`,
t.subdomain,
);
if (input === null) return;
await act(
t.user_id,
'Reassign',
`/api/admin/tunnels/${t.user_id}/reassign`,
jsonInit({ subdomain: input.trim().toLowerCase() }),
);
}
async function onDelete(t: TunnelItem) {
await act(
t.user_id,
'Delete tunnel',
`/api/admin/tunnels/${t.user_id}`,
{ method: 'DELETE' },
`Delete the tunnel ${t.subdomain}? This frees the subdomain.`,
);
}
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
return (
<div>
<h1>Tunnels</h1>
<div className="row" style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Search subdomain…"
value={search}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
style={{ maxWidth: 260 }}
/>
<select
value={status}
onChange={(e) => {
setPage(1);
setStatus(e.target.value);
}}
style={{
padding: '0.6rem 0.75rem',
background: 'var(--bg)',
color: 'var(--fg)',
border: '1px solid var(--border)',
borderRadius: 6,
}}
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="over_quota">Over quota</option>
</select>
<button className="secondary btn-sm" type="button" onClick={load}>
Refresh
</button>
</div>
{error && <p className="error">{error}</p>}
{notice && <p className="success">{notice}</p>}
{loading ? (
<p className="muted">Loading</p>
) : tunnels.length === 0 ? (
<p className="muted">No tunnels found.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Subdomain</th>
<th>Owner</th>
<th>Status</th>
<th>Usage</th>
<th>Last seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{tunnels.map((t) => (
<tr key={t.user_id}>
<td>{t.subdomain}</td>
<td style={{ wordBreak: 'break-all' }}>
{t.owner_email ?? '—'}
</td>
<td>
{t.is_active ? (
<span className="badge badge-ok">active</span>
) : (
<span className="badge">inactive</span>
)}
</td>
<td style={{ minWidth: 140 }}>
<div>
{formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)}
</div>
<div className="progress" style={{ marginTop: 4 }}>
<div
style={{
width: `${Math.min(100, t.usage_pct).toFixed(1)}%`,
background:
t.usage_pct >= 100
? 'var(--danger)'
: 'var(--accent)',
}}
/>
</div>
</td>
<td>{formatDate(t.last_seen_at)}</td>
<td>
<div className="row" style={{ flexWrap: 'wrap', gap: 4 }}>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.user_id}
onClick={() => onToggleActive(t)}
>
{t.is_active ? 'Deactivate' : 'Activate'}
</button>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.user_id}
onClick={() => onSetQuota(t)}
>
Quota
</button>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.user_id}
onClick={() => onResetUsage(t)}
>
Reset
</button>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.user_id}
onClick={() => onReassign(t)}
>
Reassign
</button>
<button
type="button"
className="secondary btn-sm"
disabled={busyId === t.user_id}
onClick={() => onRegenerate(t)}
>
Token
</button>
<button
type="button"
className="btn-danger btn-sm"
disabled={busyId === t.user_id}
onClick={() => onDelete(t)}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="row" style={{ marginTop: '1rem' }}>
<button
className="secondary btn-sm"
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span className="muted">
Page {page} of {totalPages} ({total} total)
</span>
<button
className="secondary btn-sm"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
}
+1 -2
View File
@@ -9,7 +9,6 @@ import { UserActions } from './user-actions';
export const dynamic = 'force-dynamic';
type TunnelRow = {
id: string;
subdomain: string;
is_active: boolean;
bytes_used: number;
@@ -54,7 +53,7 @@ export default async function AdminUserDetailPage({
const { data: tunnel } = await admin
.from('tunnels')
.select(
'id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at',
'subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at',
)
.eq('user_id', params.id)
.maybeSingle<TunnelRow>();
+12 -163
View File
@@ -1,170 +1,19 @@
'use client';
import { getUsersList } from '@/lib/admin/list';
import { UsersTable } from './users-table';
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;
};
export const dynamic = 'force-dynamic';
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<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(page),
perPage: String(PER_PAGE),
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 { users, total } = await getUsersList({
page: 1,
perPage: PER_PAGE,
search: '',
});
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 (
<div>
<h1>Users</h1>
<div className="row" style={{ marginBottom: '1rem' }}>
<input
type="text"
placeholder="Search by email…"
value={search}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
style={{ maxWidth: 320 }}
/>
<button className="secondary btn-sm" onClick={load} type="button">
Refresh
</button>
</div>
{error && <p className="error">{error}</p>}
{loading ? (
<p className="muted">Loading</p>
) : users.length === 0 ? (
<p className="muted">No users found.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Tunnel</th>
<th>Usage</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<Link href={`/admin/users/${u.id}`}>
{u.email ?? u.id}
</Link>
</td>
<td>
{u.role === 'admin' ? (
<span className="badge badge-admin">admin</span>
) : (
<span className="badge">user</span>
)}
</td>
<td>
{isBanned(u) ? (
<span className="badge badge-banned">banned</span>
) : u.email_confirmed_at ? (
<span className="badge badge-ok">confirmed</span>
) : (
<span className="badge">unconfirmed</span>
)}
</td>
<td>{u.tunnel ? u.tunnel.subdomain : '—'}</td>
<td>
{u.tunnel
? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes(
u.tunnel.quota_bytes,
)}`
: '—'}
</td>
<td>{formatDate(u.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="row" style={{ marginTop: '1rem' }}>
<button
className="secondary btn-sm"
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span className="muted">
Page {page} of {totalPages} ({total} total)
</span>
<button
className="secondary btn-sm"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
return <UsersTable initialUsers={users} initialTotal={total} />;
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
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';
const PER_PAGE = 25;
function isBanned(u: AdminUserItem): boolean {
return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now();
}
export function UsersTable({
initialUsers,
initialTotal,
}: {
initialUsers: AdminUserItem[];
initialTotal: number;
}) {
const [users, setUsers] = useState<AdminUserItem[]>(initialUsers);
const [total, setTotal] = useState(initialTotal);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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()}`, {
credentials: 'same-origin',
});
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: AdminUserItem[];
total: number;
};
setUsers(data.users);
setTotal(data.total);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [page, search]);
// 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
// to race the SSR session-cookie refresh and intermittently 401.
const didMount = useRef(false);
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
return (
<div>
<h1>Users</h1>
<div className="row" style={{ marginBottom: '1rem' }}>
<input
type="text"
placeholder="Search by email…"
value={search}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
style={{ maxWidth: 320 }}
/>
<button className="secondary btn-sm" onClick={load} type="button">
Refresh
</button>
</div>
{error && <p className="error">{error}</p>}
{loading ? (
<p className="muted">Loading</p>
) : users.length === 0 ? (
<p className="muted">No users found.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Tunnel</th>
<th>Usage</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<Link href={`/admin/users/${u.id}`}>
{u.email ?? u.id}
</Link>
</td>
<td>
{u.role === 'admin' ? (
<span className="badge badge-admin">admin</span>
) : (
<span className="badge">user</span>
)}
</td>
<td>
{isBanned(u) ? (
<span className="badge badge-banned">banned</span>
) : u.email_confirmed_at ? (
<span className="badge badge-ok">confirmed</span>
) : (
<span className="badge">unconfirmed</span>
)}
</td>
<td>{u.tunnel ? u.tunnel.subdomain : '—'}</td>
<td>
{u.tunnel
? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes(
u.tunnel.quota_bytes,
)}`
: '—'}
</td>
<td>{formatDate(u.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="row" style={{ marginTop: '1rem' }}>
<button
className="secondary btn-sm"
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span className="muted">
Page {page} of {totalPages} ({total} total)
</span>
<button
className="secondary btn-sm"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
}
+11 -26
View File
@@ -1,7 +1,7 @@
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';
import { getAuditList } from '@/lib/admin/list';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -13,33 +13,18 @@ export async function GET(req: NextRequest) {
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 action = url.searchParams.get('action') ?? '';
const targetType = url.searchParams.get('target_type') ?? '';
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),
try {
const { entries, total } = await getAuditList({
page,
perPage,
action,
targetType,
});
return NextResponse.json({ entries, total, page, perPage });
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ export async function POST(
const { data, error } = await admin
.from('tunnels')
.update({ is_active: isActive })
.eq('id', id)
.eq('user_id', id)
.select('subdomain')
.maybeSingle<{ subdomain: string }>();
if (error) {
+1 -1
View File
@@ -40,7 +40,7 @@ export async function POST(
const { data, error } = await admin
.from('tunnels')
.update({ quota_bytes: parsed.value })
.eq('id', id)
.eq('user_id', id)
.select('subdomain')
.maybeSingle<{ subdomain: string }>();
if (error) {
+5 -5
View File
@@ -45,20 +45,20 @@ export async function POST(
const admin = getSupabaseAdmin();
// Reject if taken by a different tunnel.
// Reject if taken by a different tunnel (keyed by owner user_id).
const { data: existing } = await admin
.from('tunnels')
.select('id')
.select('user_id')
.eq('subdomain', subdomain)
.maybeSingle<{ id: string }>();
if (existing && existing.id !== id) {
.maybeSingle<{ user_id: string }>();
if (existing && existing.user_id !== id) {
return NextResponse.json({ error: 'subdomain taken' }, { status: 409 });
}
const { data, error } = await admin
.from('tunnels')
.update({ subdomain })
.eq('id', id)
.eq('user_id', id)
.select('subdomain')
.maybeSingle<{ subdomain: string }>();
if (error) {
@@ -26,7 +26,7 @@ export async function POST(
const { data, error } = await admin
.from('tunnels')
.update({ token })
.eq('id', id)
.eq('user_id', id)
.select('subdomain, token')
.maybeSingle<{ subdomain: string; token: string }>();
if (error) {
@@ -23,7 +23,7 @@ export async function POST(
const { data, error } = await admin
.from('tunnels')
.update({ bytes_used: 0 })
.eq('id', id)
.eq('user_id', id)
.select('subdomain')
.maybeSingle<{ subdomain: string }>();
if (error) {
+1 -1
View File
@@ -24,7 +24,7 @@ export async function DELETE(
const { data, error } = await admin
.from('tunnels')
.delete()
.eq('id', id)
.eq('user_id', id)
.select('subdomain')
.maybeSingle<{ subdomain: string }>();
if (error) {
+11 -79
View File
@@ -1,22 +1,11 @@
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';
import { getTunnelsList } from '@/lib/admin/list';
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;
@@ -24,75 +13,18 @@ export async function GET(req: NextRequest) {
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 search = url.searchParams.get('search') ?? '';
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,
}));
const { tunnels, total } = await getTunnelsList({
page,
perPage,
search,
status,
});
return NextResponse.json({ tunnels, total, page, perPage });
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
+7 -61
View File
@@ -1,19 +1,11 @@
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';
import { getUsersList } from '@/lib/admin/list';
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;
@@ -21,58 +13,12 @@ export async function GET(req: NextRequest) {
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 search = url.searchParams.get('search') ?? '';
const admin = getSupabaseAdmin();
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
try {
const { users, total } = await getUsersList({ page, perPage, search });
return NextResponse.json({ users, total, page, perPage });
} catch (e) {
return NextResponse.json({ error: (e as 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<string, TunnelRow>();
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 });
}
+253
View File
@@ -0,0 +1,253 @@
import type { User } from '@supabase/supabase-js';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
/**
* Shared admin list-query logic, used by BOTH the JSON route handlers and the
* server components that render the initial page of each list. Doing the first
* load on the server (where the admin session is already validated by
* `requireAdmin()`) avoids the on-mount client fetch racing the SSR session
* cookie refresh, which previously produced intermittent 401s.
*/
export type AdminUserItem = {
id: string;
email: string | null;
role: string;
banned_until: string | null;
email_confirmed_at: string | null;
created_at: string;
last_sign_in_at: string | null;
tunnel: {
subdomain: string;
is_active: boolean;
bytes_used: number;
quota_bytes: number;
} | null;
};
export type TunnelItem = {
user_id: string;
owner_email: string | null;
subdomain: string;
is_active: boolean;
bytes_used: number;
quota_bytes: number;
usage_pct: number;
last_seen_at: string | null;
created_at: string;
};
export type AuditItem = {
id: number;
actor_id: string | null;
actor_email: string | null;
action: string;
target_type: string | null;
target_id: string | null;
details: Record<string, unknown>;
created_at: string;
};
type TunnelJoinRow = {
user_id: string;
subdomain: string;
is_active: boolean;
bytes_used: number;
quota_bytes: number;
};
type TunnelRow = TunnelJoinRow & {
last_seen_at: string | null;
created_at: string;
};
// Bound the full-email-scan search: 50 pages * 1000 = up to 50k users. Beyond
// this we stop scanning (extremely unlikely for this deployment's scale).
const USER_SCAN_MAX_PAGES = 50;
const USER_SCAN_PER_PAGE = 1000;
export async function getUsersList(opts: {
page: number;
perPage: number;
search: string;
}): Promise<{ users: AdminUserItem[]; total: number }> {
const { page, perPage } = opts;
const search = opts.search.trim().toLowerCase();
const admin = getSupabaseAdmin();
let pageUsers: User[];
let total: number;
if (search) {
// Search must match across ALL users, not just the current listUsers page.
// Page through the user directory (bounded by USER_SCAN_MAX_PAGES),
// accumulate case-insensitive email substring matches, then paginate the
// filtered set.
const matched: User[] = [];
for (let p = 1; p <= USER_SCAN_MAX_PAGES; p++) {
const { data, error } = await admin.auth.admin.listUsers({
page: p,
perPage: USER_SCAN_PER_PAGE,
});
if (error) throw new Error(error.message);
const us = data.users;
if (us.length === 0) break;
for (const u of us) {
if ((u.email ?? '').toLowerCase().includes(search)) matched.push(u);
}
if (us.length < USER_SCAN_PER_PAGE) break;
}
total = matched.length;
const from = (page - 1) * perPage;
pageUsers = matched.slice(from, from + perPage);
} else {
// Common no-search path: cheap single-page lookup (unchanged behavior).
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
if (error) throw new Error(error.message);
pageUsers = data.users;
total = (data as unknown as { total?: number }).total ?? pageUsers.length;
}
// Join tunnel rows for this page's users in a single query.
const ids = pageUsers.map((u) => u.id);
const tunnelMap = new Map<string, TunnelJoinRow>();
if (ids.length > 0) {
const { data: tunnels } = await admin
.from('tunnels')
.select('user_id, subdomain, is_active, bytes_used, quota_bytes')
.in('user_id', ids);
for (const t of (tunnels ?? []) as TunnelJoinRow[]) {
tunnelMap.set(t.user_id, t);
}
}
const users: AdminUserItem[] = pageUsers.map((u) => {
const t = tunnelMap.get(u.id) ?? null;
return {
id: u.id,
email: u.email ?? null,
role: (u.app_metadata?.role as string | undefined) ?? 'user',
banned_until:
(u as unknown as { banned_until?: string | null }).banned_until ?? null,
email_confirmed_at: u.email_confirmed_at ?? null,
created_at: u.created_at,
last_sign_in_at: u.last_sign_in_at ?? null,
tunnel: t
? {
subdomain: t.subdomain,
is_active: t.is_active,
bytes_used: t.bytes_used,
quota_bytes: t.quota_bytes,
}
: null,
};
});
return { users, total };
}
export async function getTunnelsList(opts: {
page: number;
perPage: number;
search: string;
status: string | null;
}): Promise<{ tunnels: TunnelItem[]; total: number }> {
const { page, perPage, status } = opts;
const search = opts.search.trim().toLowerCase();
const admin = getSupabaseAdmin();
// The tunnels table's primary key is user_id (one tunnel per user); there is
// no `id` column. The user_id is the identifier exposed to the UI.
const cols =
'user_id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at';
let rows: TunnelRow[];
let total: number;
const from = (page - 1) * perPage;
const to = from + perPage - 1;
if (status === 'over_quota') {
// Column-to-column comparison is not expressible via PostgREST filters,
// so fetch matching rows and paginate in memory.
let q = admin.from('tunnels').select(cols);
if (search) q = q.ilike('subdomain', `%${search}%`);
const { data, error } = await q.order('created_at', { ascending: false });
if (error) throw new Error(error.message);
const all = ((data ?? []) as TunnelRow[]).filter(
(t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes,
);
total = all.length;
rows = all.slice(from, from + perPage);
} else {
let query = admin.from('tunnels').select(cols, { count: 'exact' });
if (search) query = query.ilike('subdomain', `%${search}%`);
if (status === 'active') query = query.eq('is_active', true);
else if (status === 'inactive') query = query.eq('is_active', false);
query = query.order('created_at', { ascending: false }).range(from, to);
const { data, error, count } = await query;
if (error) throw new Error(error.message);
rows = (data ?? []) as TunnelRow[];
total = count ?? rows.length;
}
// Resolve owner emails (per-row getUserById; acceptable for current scale).
const emails = await Promise.all(
rows.map(async (t) => {
try {
const { data: u } = await admin.auth.admin.getUserById(t.user_id);
return u.user?.email ?? null;
} catch {
return null;
}
}),
);
const tunnels: TunnelItem[] = rows.map((t, i) => ({
user_id: t.user_id,
owner_email: emails[i],
subdomain: t.subdomain,
is_active: t.is_active,
bytes_used: t.bytes_used,
quota_bytes: t.quota_bytes,
usage_pct:
t.quota_bytes > 0
? Math.min(100, (t.bytes_used / t.quota_bytes) * 100)
: 0,
last_seen_at: t.last_seen_at,
created_at: t.created_at,
}));
return { tunnels, total };
}
export async function getAuditList(opts: {
page: number;
perPage: number;
action: string;
targetType: string;
}): Promise<{ entries: AuditItem[]; total: number }> {
const { page, perPage } = opts;
const action = opts.action.trim();
const targetType = opts.targetType.trim();
const admin = getSupabaseAdmin();
let query = admin
.from('admin_audit_log')
.select(
'id, actor_id, actor_email, action, target_type, target_id, details, created_at',
{ count: 'exact' },
);
if (action) query = query.eq('action', action);
if (targetType) query = query.eq('target_type', targetType);
const from = (page - 1) * perPage;
const to = from + perPage - 1;
query = query.order('created_at', { ascending: false }).range(from, to);
const { data, error, count } = await query;
if (error) throw new Error(error.message);
const entries = (data ?? []) as AuditItem[];
return { entries, total: count ?? entries.length };
}
+652
View File
@@ -0,0 +1,652 @@
{
"name": "linumiq-web",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "linumiq-web",
"version": "1.0.0",
"dependencies": {
"@supabase/ssr": "0.5.2",
"@supabase/supabase-js": "2.45.4",
"next": "14.2.15",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.16.10",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.0",
"typescript": "5.6.2"
}
},
"node_modules/@next/env": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.15.tgz",
"integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==",
"license": "MIT"
},
"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",
"integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz",
"integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"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",
"integrity": "sha512-+wboHfZufAE2Y612OsKeVP4rVOeGZzzMLD/Ac3HrTQkkY4qXNjI6Af9gtmxwccE5nFvTiF114FEbIQ1hRq5uUw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.1.tgz",
"integrity": "sha512-8sZ2ibwHlf+WkHDUZJUXqqmPvWQ3UHN0W30behOJngVh/qHHekhJLCFbh0AjkE9/FqqXtf9eoVvmYgfCLk5tNA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.1.tgz",
"integrity": "sha512-EOSEZFm5pPuCPGCmLF1VOCS78DfkSz600PBuvBND/IZmMciJ1pmsS3ss6TkB6UkuvTybYiBh7gKOYyxoEO3USA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.10.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.2.tgz",
"integrity": "sha512-qyCQaNg90HmJstsvr2aJNxK2zgoKh9ZZA8oqb7UT2LCh3mj9zpa3Iwu167AuyNxsxrUE8eEJ2yH6wLCij4EApA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14",
"@types/phoenix": "^1.5.4",
"@types/ws": "^8.5.10",
"ws": "^8.14.2"
}
},
"node_modules/@supabase/ssr": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.5.2.tgz",
"integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^0.7.0"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.43.4"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.0.tgz",
"integrity": "sha512-iZenEdO6Mx9iTR6T7wC7sk6KKsoDPLq8rdu5VRy7+JiT1i8fnqfcOr6mfF2Eaqky9VQzhP8zZKQYjzozB65Rig==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.45.4",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.4.tgz",
"integrity": "sha512-E5p8/zOLaQ3a462MZnmnz03CrduA5ySH9hZyL03Y+QZLIOO4/Gs8Rdy4ZCKDHsN7x0xdanVEWWFN3pJFQr9/hg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.65.0",
"@supabase/functions-js": "2.4.1",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.16.1",
"@supabase/realtime-js": "2.10.2",
"@supabase/storage-js": "2.7.0"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.16.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz",
"integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz",
"integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.15.tgz",
"integrity": "sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.15",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.15",
"@next/swc-darwin-x64": "14.2.15",
"@next/swc-linux-arm64-gnu": "14.2.15",
"@next/swc-linux-arm64-musl": "14.2.15",
"@next/swc-linux-x64-gnu": "14.2.15",
"@next/swc-linux-x64-musl": "14.2.15",
"@next/swc-win32-arm64-msvc": "14.2.15",
"@next/swc-win32-ia32-msvc": "14.2.15",
"@next/swc-win32-x64-msvc": "14.2.15"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"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"
}
}
}
}