fix(admin): key tunnels by user_id, server-side initial list load, full-scan user search
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+13
-152
@@ -1,158 +1,19 @@
|
|||||||
'use client';
|
import { getAuditList } from '@/lib/admin/list';
|
||||||
|
import { AuditTable } from './audit-table';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
export const dynamic = 'force-dynamic';
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PER_PAGE = 50;
|
const PER_PAGE = 50;
|
||||||
|
|
||||||
export default function AdminAuditPage() {
|
export default async function AdminAuditPage() {
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
// Initial load on the server (admin session already validated by the layout)
|
||||||
const [total, setTotal] = useState(0);
|
// so the first paint never races the client session-cookie refresh.
|
||||||
const [page, setPage] = useState(1);
|
const { entries, total } = await getAuditList({
|
||||||
const [action, setAction] = useState('');
|
page: 1,
|
||||||
const [targetType, setTargetType] = useState('');
|
perPage: PER_PAGE,
|
||||||
const [loading, setLoading] = useState(true);
|
action: '',
|
||||||
const [error, setError] = useState<string | null>(null);
|
targetType: '',
|
||||||
|
});
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
return <AuditTable initialEntries={entries} initialTotal={total} />;
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -6,7 +6,7 @@ import { formatBytes, formatDate } from '@/lib/format';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
type OverQuotaRow = {
|
type OverQuotaRow = {
|
||||||
id: string;
|
user_id: string;
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
bytes_used: number;
|
bytes_used: number;
|
||||||
quota_bytes: number;
|
quota_bytes: number;
|
||||||
@@ -26,7 +26,7 @@ export default async function AdminOverviewPage() {
|
|||||||
// Over-quota tunnels (compute in memory).
|
// Over-quota tunnels (compute in memory).
|
||||||
const { data: tunnelsData } = await admin
|
const { data: tunnelsData } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.select('id, subdomain, bytes_used, quota_bytes');
|
.select('user_id, subdomain, bytes_used, quota_bytes');
|
||||||
const overQuota = ((tunnelsData ?? []) as OverQuotaRow[])
|
const overQuota = ((tunnelsData ?? []) as OverQuotaRow[])
|
||||||
.filter((t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes)
|
.filter((t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
@@ -100,7 +100,7 @@ export default async function AdminOverviewPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{overQuota.map((t) => (
|
{overQuota.map((t) => (
|
||||||
<tr key={t.id}>
|
<tr key={t.user_id}>
|
||||||
<td>{t.subdomain}</td>
|
<td>{t.subdomain}</td>
|
||||||
<td>
|
<td>
|
||||||
{formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)}
|
{formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)}
|
||||||
|
|||||||
+12
-339
@@ -1,346 +1,19 @@
|
|||||||
'use client';
|
import { getTunnelsList } from '@/lib/admin/list';
|
||||||
|
import { TunnelsTable } from './tunnels-table';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
export const dynamic = 'force-dynamic';
|
||||||
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;
|
const PER_PAGE = 25;
|
||||||
|
|
||||||
export default function AdminTunnelsPage() {
|
export default async function AdminTunnelsPage() {
|
||||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
// Initial load on the server (admin session already validated by the layout)
|
||||||
const [total, setTotal] = useState(0);
|
// so the first paint never races the client session-cookie refresh.
|
||||||
const [page, setPage] = useState(1);
|
const { tunnels, total } = await getTunnelsList({
|
||||||
const [search, setSearch] = useState('');
|
page: 1,
|
||||||
const [status, setStatus] = useState('');
|
perPage: PER_PAGE,
|
||||||
const [loading, setLoading] = useState(true);
|
search: '',
|
||||||
const [error, setError] = useState<string | null>(null);
|
status: 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),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onToggleActive(t: Tunnel) {
|
return <TunnelsTable initialTunnels={tunnels} initialTotal={total} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import { UserActions } from './user-actions';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
type TunnelRow = {
|
type TunnelRow = {
|
||||||
id: string;
|
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
bytes_used: number;
|
bytes_used: number;
|
||||||
@@ -54,7 +53,7 @@ export default async function AdminUserDetailPage({
|
|||||||
const { data: tunnel } = await admin
|
const { data: tunnel } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.select(
|
.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)
|
.eq('user_id', params.id)
|
||||||
.maybeSingle<TunnelRow>();
|
.maybeSingle<TunnelRow>();
|
||||||
|
|||||||
+14
-165
@@ -1,170 +1,19 @@
|
|||||||
'use client';
|
import { getUsersList } from '@/lib/admin/list';
|
||||||
|
import { UsersTable } from './users-table';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
export const dynamic = 'force-dynamic';
|
||||||
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;
|
const PER_PAGE = 25;
|
||||||
|
|
||||||
function isBanned(u: AdminUser): boolean {
|
export default async function AdminUsersPage() {
|
||||||
return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now();
|
// 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.
|
||||||
export default function AdminUsersPage() {
|
const { users, total } = await getUsersList({
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
page: 1,
|
||||||
const [total, setTotal] = useState(0);
|
perPage: PER_PAGE,
|
||||||
const [page, setPage] = useState(1);
|
search: '',
|
||||||
const [search, setSearch] = useState('');
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
return <UsersTable initialUsers={users} initialTotal={total} />;
|
||||||
|
|
||||||
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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse, type NextRequest } from 'next/server';
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
||||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
|
||||||
import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators';
|
import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators';
|
||||||
|
import { getAuditList } from '@/lib/admin/list';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -13,33 +13,18 @@ export async function GET(req: NextRequest) {
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const page = parsePageParam(url.searchParams.get('page'), 1);
|
const page = parsePageParam(url.searchParams.get('page'), 1);
|
||||||
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100);
|
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100);
|
||||||
const action = (url.searchParams.get('action') ?? '').trim();
|
const action = url.searchParams.get('action') ?? '';
|
||||||
const targetType = (url.searchParams.get('target_type') ?? '').trim();
|
const targetType = url.searchParams.get('target_type') ?? '';
|
||||||
|
|
||||||
const admin = getSupabaseAdmin();
|
try {
|
||||||
|
const { entries, total } = await getAuditList({
|
||||||
let query = admin
|
page,
|
||||||
.from('admin_audit_log')
|
perPage,
|
||||||
.select(
|
action,
|
||||||
'id, actor_id, actor_email, action, target_type, target_id, details, created_at',
|
targetType,
|
||||||
{ count: 'exact' },
|
});
|
||||||
);
|
return NextResponse.json({ entries, total, page, perPage });
|
||||||
if (action) query = query.eq('action', action);
|
} catch (e) {
|
||||||
if (targetType) query = query.eq('target_type', targetType);
|
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function POST(
|
|||||||
const { data, error } = await admin
|
const { data, error } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.update({ is_active: isActive })
|
.update({ is_active: isActive })
|
||||||
.eq('id', id)
|
.eq('user_id', id)
|
||||||
.select('subdomain')
|
.select('subdomain')
|
||||||
.maybeSingle<{ subdomain: string }>();
|
.maybeSingle<{ subdomain: string }>();
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function POST(
|
|||||||
const { data, error } = await admin
|
const { data, error } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.update({ quota_bytes: parsed.value })
|
.update({ quota_bytes: parsed.value })
|
||||||
.eq('id', id)
|
.eq('user_id', id)
|
||||||
.select('subdomain')
|
.select('subdomain')
|
||||||
.maybeSingle<{ subdomain: string }>();
|
.maybeSingle<{ subdomain: string }>();
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -45,20 +45,20 @@ export async function POST(
|
|||||||
|
|
||||||
const admin = getSupabaseAdmin();
|
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
|
const { data: existing } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.select('id')
|
.select('user_id')
|
||||||
.eq('subdomain', subdomain)
|
.eq('subdomain', subdomain)
|
||||||
.maybeSingle<{ id: string }>();
|
.maybeSingle<{ user_id: string }>();
|
||||||
if (existing && existing.id !== id) {
|
if (existing && existing.user_id !== id) {
|
||||||
return NextResponse.json({ error: 'subdomain taken' }, { status: 409 });
|
return NextResponse.json({ error: 'subdomain taken' }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await admin
|
const { data, error } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.update({ subdomain })
|
.update({ subdomain })
|
||||||
.eq('id', id)
|
.eq('user_id', id)
|
||||||
.select('subdomain')
|
.select('subdomain')
|
||||||
.maybeSingle<{ subdomain: string }>();
|
.maybeSingle<{ subdomain: string }>();
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function POST(
|
|||||||
const { data, error } = await admin
|
const { data, error } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.update({ token })
|
.update({ token })
|
||||||
.eq('id', id)
|
.eq('user_id', id)
|
||||||
.select('subdomain, token')
|
.select('subdomain, token')
|
||||||
.maybeSingle<{ subdomain: string; token: string }>();
|
.maybeSingle<{ subdomain: string; token: string }>();
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function POST(
|
|||||||
const { data, error } = await admin
|
const { data, error } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.update({ bytes_used: 0 })
|
.update({ bytes_used: 0 })
|
||||||
.eq('id', id)
|
.eq('user_id', id)
|
||||||
.select('subdomain')
|
.select('subdomain')
|
||||||
.maybeSingle<{ subdomain: string }>();
|
.maybeSingle<{ subdomain: string }>();
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function DELETE(
|
|||||||
const { data, error } = await admin
|
const { data, error } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id)
|
.eq('user_id', id)
|
||||||
.select('subdomain')
|
.select('subdomain')
|
||||||
.maybeSingle<{ subdomain: string }>();
|
.maybeSingle<{ subdomain: string }>();
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { NextResponse, type NextRequest } from 'next/server';
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
||||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
|
||||||
import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators';
|
import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators';
|
||||||
|
import { getTunnelsList } from '@/lib/admin/list';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const auth = await requireAdminApi();
|
const auth = await requireAdminApi();
|
||||||
if (!auth.ok) return auth.response;
|
if (!auth.ok) return auth.response;
|
||||||
@@ -24,75 +13,18 @@ export async function GET(req: NextRequest) {
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const page = parsePageParam(url.searchParams.get('page'), 1);
|
const page = parsePageParam(url.searchParams.get('page'), 1);
|
||||||
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
|
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 status = url.searchParams.get('status'); // active|inactive|over_quota
|
||||||
|
|
||||||
const admin = getSupabaseAdmin();
|
try {
|
||||||
|
const { tunnels, total } = await getTunnelsList({
|
||||||
const cols =
|
page,
|
||||||
'id, user_id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at';
|
perPage,
|
||||||
|
search,
|
||||||
let rows: TunnelRow[];
|
status,
|
||||||
let total: number;
|
});
|
||||||
const from = (page - 1) * perPage;
|
return NextResponse.json({ tunnels, total, page, perPage });
|
||||||
const to = from + perPage - 1;
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import { NextResponse, type NextRequest } from 'next/server';
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
||||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
|
||||||
import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators';
|
import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators';
|
||||||
|
import { getUsersList } from '@/lib/admin/list';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const auth = await requireAdminApi();
|
const auth = await requireAdminApi();
|
||||||
if (!auth.ok) return auth.response;
|
if (!auth.ok) return auth.response;
|
||||||
@@ -21,58 +13,12 @@ export async function GET(req: NextRequest) {
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const page = parsePageParam(url.searchParams.get('page'), 1);
|
const page = parsePageParam(url.searchParams.get('page'), 1);
|
||||||
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
|
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();
|
try {
|
||||||
|
const { users, total } = await getUsersList({ page, perPage, search });
|
||||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
|
return NextResponse.json({ users, total, page, perPage });
|
||||||
if (error) {
|
} catch (e) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
Generated
+652
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user