From b6c4d9499007d5bea1cfeecb66f3052709fdca96 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Sun, 31 May 2026 11:46:14 +0200 Subject: [PATCH] fix(admin): key tunnels by user_id, server-side initial list load, full-scan user search --- app/admin/audit/audit-table.tsx | 163 +++++ app/admin/audit/page.tsx | 165 +---- app/admin/page.tsx | 6 +- app/admin/tunnels/page.tsx | 351 +--------- app/admin/tunnels/tunnels-table.tsx | 349 ++++++++++ app/admin/users/[id]/page.tsx | 3 +- app/admin/users/page.tsx | 179 +---- app/admin/users/users-table.tsx | 174 +++++ app/api/admin/audit/route.ts | 41 +- app/api/admin/tunnels/[id]/active/route.ts | 2 +- app/api/admin/tunnels/[id]/quota/route.ts | 2 +- app/api/admin/tunnels/[id]/reassign/route.ts | 10 +- .../tunnels/[id]/regenerate-token/route.ts | 2 +- .../admin/tunnels/[id]/reset-usage/route.ts | 2 +- app/api/admin/tunnels/[id]/route.ts | 2 +- app/api/admin/tunnels/route.ts | 92 +-- app/api/admin/users/route.ts | 68 +- lib/admin/list.ts | 253 +++++++ package-lock.json | 652 ++++++++++++++++++ 19 files changed, 1676 insertions(+), 840 deletions(-) create mode 100644 app/admin/audit/audit-table.tsx create mode 100644 app/admin/tunnels/tunnels-table.tsx create mode 100644 app/admin/users/users-table.tsx create mode 100644 lib/admin/list.ts create mode 100644 package-lock.json diff --git a/app/admin/audit/audit-table.tsx b/app/admin/audit/audit-table.tsx new file mode 100644 index 0000000..50d18f7 --- /dev/null +++ b/app/admin/audit/audit-table.tsx @@ -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(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(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 ( +
+

Audit log

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

{error}

} + + {loading ? ( +

Loading…

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

No audit entries.

+ ) : ( +
+ + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + ))} + +
WhenActorActionTargetDetails
{formatDate(e.created_at)} + {e.actor_email ?? e.actor_id ?? '—'} + {e.action} + {e.target_type ? `${e.target_type}:` : ''} + {e.target_id ?? ''} + + + {JSON.stringify(e.details ?? {})} + +
+
+ )} + +
+ + + Page {page} of {totalPages} ({total} total) + + +
+
+ ); +} diff --git a/app/admin/audit/page.tsx b/app/admin/audit/page.tsx index e976e4e..ce83ad0 100644 --- a/app/admin/audit/page.tsx +++ b/app/admin/audit/page.tsx @@ -1,158 +1,19 @@ -'use client'; +import { getAuditList } from '@/lib/admin/list'; +import { AuditTable } from './audit-table'; -import { useCallback, useEffect, useState } from 'react'; -import { formatDate } from '@/lib/format'; - -type AuditEntry = { - id: number; - actor_id: string | null; - actor_email: string | null; - action: string; - target_type: string | null; - target_id: string | null; - details: Record; - created_at: string; -}; +export const dynamic = 'force-dynamic'; const PER_PAGE = 50; -export default function AdminAuditPage() { - const [entries, setEntries] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [action, setAction] = useState(''); - const [targetType, setTargetType] = useState(''); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); +export default async function AdminAuditPage() { + // Initial load on the server (admin session already validated by the layout) + // so the first paint never races the client session-cookie refresh. + const { entries, total } = await getAuditList({ + page: 1, + perPage: PER_PAGE, + action: '', + targetType: '', + }); - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const params = new URLSearchParams({ - page: String(page), - perPage: String(PER_PAGE), - }); - if (action.trim()) params.set('action', action.trim()); - if (targetType.trim()) params.set('target_type', targetType.trim()); - const res = await fetch(`/api/admin/audit?${params.toString()}`); - if (!res.ok) { - const b = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(b.error ?? `Request failed (${res.status})`); - } - const data = (await res.json()) as { - entries: AuditEntry[]; - total: number; - }; - setEntries(data.entries); - setTotal(data.total); - } catch (e) { - setError((e as Error).message); - } finally { - setLoading(false); - } - }, [page, action, targetType]); - - useEffect(() => { - const t = setTimeout(load, 250); - return () => clearTimeout(t); - }, [load]); - - const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); - - return ( -
-

Audit log

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

{error}

} - - {loading ? ( -

Loading…

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

No audit entries.

- ) : ( -
- - - - - - - - - - - - {entries.map((e) => ( - - - - - - - - ))} - -
WhenActorActionTargetDetails
{formatDate(e.created_at)} - {e.actor_email ?? e.actor_id ?? '—'} - {e.action} - {e.target_type ? `${e.target_type}:` : ''} - {e.target_id ?? ''} - - - {JSON.stringify(e.details ?? {})} - -
-
- )} - -
- - - Page {page} of {totalPages} ({total} total) - - -
-
- ); + return ; } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 61564c2..4e1f3ed 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -6,7 +6,7 @@ import { formatBytes, formatDate } from '@/lib/format'; export const dynamic = 'force-dynamic'; type OverQuotaRow = { - id: string; + user_id: string; subdomain: string; bytes_used: number; quota_bytes: number; @@ -26,7 +26,7 @@ export default async function AdminOverviewPage() { // Over-quota tunnels (compute in memory). const { data: tunnelsData } = await admin .from('tunnels') - .select('id, subdomain, bytes_used, quota_bytes'); + .select('user_id, subdomain, bytes_used, quota_bytes'); const overQuota = ((tunnelsData ?? []) as OverQuotaRow[]) .filter((t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes) .slice(0, 5); @@ -100,7 +100,7 @@ export default async function AdminOverviewPage() { {overQuota.map((t) => ( - + {t.subdomain} {formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)} diff --git a/app/admin/tunnels/page.tsx b/app/admin/tunnels/page.tsx index e5c0c0d..e8efb4c 100644 --- a/app/admin/tunnels/page.tsx +++ b/app/admin/tunnels/page.tsx @@ -1,346 +1,19 @@ -'use client'; +import { getTunnelsList } from '@/lib/admin/list'; +import { TunnelsTable } from './tunnels-table'; -import { useCallback, useEffect, useState } from 'react'; -import { formatBytes, formatDate } from '@/lib/format'; - -type Tunnel = { - id: string; - user_id: string; - owner_email: string | null; - subdomain: string; - is_active: boolean; - bytes_used: number; - quota_bytes: number; - usage_pct: number; - last_seen_at: string | null; - created_at: string; -}; +export const dynamic = 'force-dynamic'; const PER_PAGE = 25; -export default function AdminTunnelsPage() { - const [tunnels, setTunnels] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [status, setStatus] = useState(''); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - const [busyId, setBusyId] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const params = new URLSearchParams({ - page: String(page), - perPage: String(PER_PAGE), - }); - if (search.trim()) params.set('search', search.trim()); - if (status) params.set('status', status); - const res = await fetch(`/api/admin/tunnels?${params.toString()}`); - if (!res.ok) { - const b = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(b.error ?? `Request failed (${res.status})`); - } - const data = (await res.json()) as { tunnels: Tunnel[]; total: number }; - setTunnels(data.tunnels); - setTotal(data.total); - } catch (e) { - setError((e as Error).message); - } finally { - setLoading(false); - } - }, [page, search, status]); - - useEffect(() => { - const t = setTimeout(load, 250); - return () => clearTimeout(t); - }, [load]); - - async function act( - id: string, - label: string, - url: string, - init: RequestInit, - confirmMsg?: string, - ): Promise { - if (confirmMsg && !window.confirm(confirmMsg)) return null; - setBusyId(id); - setError(null); - setNotice(null); - try { - const res = await fetch(url, init); - const body = (await res.json().catch(() => ({}))) as { - error?: string; - [k: string]: unknown; - }; - if (!res.ok) throw new Error(body.error ?? `Request failed (${res.status})`); - setNotice(`${label} succeeded`); - await load(); - return body; - } catch (e) { - setError((e as Error).message); - return null; - } finally { - setBusyId(null); - } - } - - const jsonInit = (body: unknown): RequestInit => ({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), +export default async function AdminTunnelsPage() { + // Initial load on the server (admin session already validated by the layout) + // so the first paint never races the client session-cookie refresh. + const { tunnels, total } = await getTunnelsList({ + page: 1, + perPage: PER_PAGE, + search: '', + status: null, }); - async function onToggleActive(t: Tunnel) { - await act( - t.id, - t.is_active ? 'Deactivate' : 'Activate', - `/api/admin/tunnels/${t.id}/active`, - jsonInit({ is_active: !t.is_active }), - ); - } - - async function onRegenerate(t: Tunnel) { - const body = (await act( - t.id, - 'Regenerate token', - `/api/admin/tunnels/${t.id}/regenerate-token`, - { method: 'POST' }, - `Regenerate the token for ${t.subdomain}? The old token stops working.`, - )) as { token?: string } | null; - if (body?.token) { - window.prompt('New token (copy it now):', body.token); - } - } - - async function onResetUsage(t: Tunnel) { - await act( - t.id, - 'Reset usage', - `/api/admin/tunnels/${t.id}/reset-usage`, - { method: 'POST' }, - `Reset bandwidth usage for ${t.subdomain} to zero?`, - ); - } - - async function onSetQuota(t: Tunnel) { - const input = window.prompt( - `New quota in GiB for ${t.subdomain}:`, - String(Math.round(t.quota_bytes / 1024 ** 3)), - ); - if (input === null) return; - const gib = Number(input); - if (!Number.isFinite(gib) || gib <= 0) { - setError('Quota must be a positive number of GiB'); - return; - } - await act( - t.id, - 'Set quota', - `/api/admin/tunnels/${t.id}/quota`, - jsonInit({ quota_bytes: Math.round(gib * 1024 ** 3) }), - ); - } - - async function onReassign(t: Tunnel) { - const input = window.prompt( - `New subdomain for ${t.owner_email ?? t.subdomain}:`, - t.subdomain, - ); - if (input === null) return; - await act( - t.id, - 'Reassign', - `/api/admin/tunnels/${t.id}/reassign`, - jsonInit({ subdomain: input.trim().toLowerCase() }), - ); - } - - async function onDelete(t: Tunnel) { - await act( - t.id, - 'Delete tunnel', - `/api/admin/tunnels/${t.id}`, - { method: 'DELETE' }, - `Delete the tunnel ${t.subdomain}? This frees the subdomain.`, - ); - } - - const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); - - return ( -
-

Tunnels

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

{error}

} - {notice &&

{notice}

} - - {loading ? ( -

Loading…

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

No tunnels found.

- ) : ( -
- - - - - - - - - - - - - {tunnels.map((t) => ( - - - - - - - - - ))} - -
SubdomainOwnerStatusUsageLast seenActions
{t.subdomain} - {t.owner_email ?? '—'} - - {t.is_active ? ( - active - ) : ( - inactive - )} - -
- {formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)} -
-
-
= 100 - ? 'var(--danger)' - : 'var(--accent)', - }} - /> -
-
{formatDate(t.last_seen_at)} -
- - - - - - -
-
-
- )} - -
- - - Page {page} of {totalPages} ({total} total) - - -
-
- ); + return ; } diff --git a/app/admin/tunnels/tunnels-table.tsx b/app/admin/tunnels/tunnels-table.tsx new file mode 100644 index 0000000..2122d39 --- /dev/null +++ b/app/admin/tunnels/tunnels-table.tsx @@ -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(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(null); + const [notice, setNotice] = useState(null); + const [busyId, setBusyId] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + page: String(page), + perPage: String(PER_PAGE), + }); + if (search.trim()) params.set('search', search.trim()); + if (status) params.set('status', status); + const res = await fetch(`/api/admin/tunnels?${params.toString()}`, { + 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 { + 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 ( +
+

Tunnels

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

{error}

} + {notice &&

{notice}

} + + {loading ? ( +

Loading…

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

No tunnels found.

+ ) : ( +
+ + + + + + + + + + + + + {tunnels.map((t) => ( + + + + + + + + + ))} + +
SubdomainOwnerStatusUsageLast seenActions
{t.subdomain} + {t.owner_email ?? '—'} + + {t.is_active ? ( + active + ) : ( + inactive + )} + +
+ {formatBytes(t.bytes_used)} / {formatBytes(t.quota_bytes)} +
+
+
= 100 + ? 'var(--danger)' + : 'var(--accent)', + }} + /> +
+
{formatDate(t.last_seen_at)} +
+ + + + + + +
+
+
+ )} + +
+ + + Page {page} of {totalPages} ({total} total) + + +
+
+ ); +} diff --git a/app/admin/users/[id]/page.tsx b/app/admin/users/[id]/page.tsx index d9622a7..b3fa7d5 100644 --- a/app/admin/users/[id]/page.tsx +++ b/app/admin/users/[id]/page.tsx @@ -9,7 +9,6 @@ import { UserActions } from './user-actions'; export const dynamic = 'force-dynamic'; type TunnelRow = { - id: string; subdomain: string; is_active: boolean; bytes_used: number; @@ -54,7 +53,7 @@ export default async function AdminUserDetailPage({ const { data: tunnel } = await admin .from('tunnels') .select( - 'id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at', + 'subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at', ) .eq('user_id', params.id) .maybeSingle(); diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index c72ba2a..93a4c99 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,170 +1,19 @@ -'use client'; +import { getUsersList } from '@/lib/admin/list'; +import { UsersTable } from './users-table'; -import { useCallback, useEffect, useState } from 'react'; -import Link from 'next/link'; -import { formatBytes, formatDate } from '@/lib/format'; - -type AdminUser = { - id: string; - email: string | null; - role: string; - banned_until: string | null; - email_confirmed_at: string | null; - created_at: string; - last_sign_in_at: string | null; - tunnel: { - subdomain: string; - is_active: boolean; - bytes_used: number; - quota_bytes: number; - } | null; -}; +export const dynamic = 'force-dynamic'; const PER_PAGE = 25; -function isBanned(u: AdminUser): boolean { - return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now(); -} - -export default function AdminUsersPage() { - const [users, setUsers] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const params = new URLSearchParams({ - page: String(page), - perPage: String(PER_PAGE), - }); - if (search.trim()) params.set('search', search.trim()); - const res = await fetch(`/api/admin/users?${params.toString()}`); - if (!res.ok) { - const b = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(b.error ?? `Request failed (${res.status})`); - } - const data = (await res.json()) as { users: AdminUser[]; total: number }; - setUsers(data.users); - setTotal(data.total); - } catch (e) { - setError((e as Error).message); - } finally { - setLoading(false); - } - }, [page, search]); - - useEffect(() => { - const t = setTimeout(load, 250); - return () => clearTimeout(t); - }, [load]); - - const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); - - return ( -
-

Users

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

{error}

} - - {loading ? ( -

Loading…

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

No users found.

- ) : ( -
- - - - - - - - - - - - - {users.map((u) => ( - - - - - - - - - ))} - -
EmailRoleStatusTunnelUsageCreated
- - {u.email ?? u.id} - - - {u.role === 'admin' ? ( - admin - ) : ( - user - )} - - {isBanned(u) ? ( - banned - ) : u.email_confirmed_at ? ( - confirmed - ) : ( - unconfirmed - )} - {u.tunnel ? u.tunnel.subdomain : '—'} - {u.tunnel - ? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes( - u.tunnel.quota_bytes, - )}` - : '—'} - {formatDate(u.created_at)}
-
- )} - -
- - - Page {page} of {totalPages} ({total} total) - - -
-
- ); +export default async function AdminUsersPage() { + // Initial load runs on the server (the admin session is already validated by + // the admin layout's requireAdmin()), so the first paint never races the + // client session-cookie refresh. + const { users, total } = await getUsersList({ + page: 1, + perPage: PER_PAGE, + search: '', + }); + + return ; } diff --git a/app/admin/users/users-table.tsx b/app/admin/users/users-table.tsx new file mode 100644 index 0000000..f69567f --- /dev/null +++ b/app/admin/users/users-table.tsx @@ -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(initialUsers); + const [total, setTotal] = useState(initialTotal); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + page: String(page), + perPage: String(PER_PAGE), + }); + if (search.trim()) params.set('search', search.trim()); + const res = await fetch(`/api/admin/users?${params.toString()}`, { + 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 ( +
+

Users

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

{error}

} + + {loading ? ( +

Loading…

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

No users found.

+ ) : ( +
+ + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
EmailRoleStatusTunnelUsageCreated
+ + {u.email ?? u.id} + + + {u.role === 'admin' ? ( + admin + ) : ( + user + )} + + {isBanned(u) ? ( + banned + ) : u.email_confirmed_at ? ( + confirmed + ) : ( + unconfirmed + )} + {u.tunnel ? u.tunnel.subdomain : '—'} + {u.tunnel + ? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes( + u.tunnel.quota_bytes, + )}` + : '—'} + {formatDate(u.created_at)}
+
+ )} + +
+ + + Page {page} of {totalPages} ({total} total) + + +
+
+ ); +} diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts index 33b8566..898f0fc 100644 --- a/app/api/admin/audit/route.ts +++ b/app/api/admin/audit/route.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; -import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; +import { getAuditList } from '@/lib/admin/list'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -13,33 +13,18 @@ export async function GET(req: NextRequest) { const url = new URL(req.url); const page = parsePageParam(url.searchParams.get('page'), 1); const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100); - const action = (url.searchParams.get('action') ?? '').trim(); - const targetType = (url.searchParams.get('target_type') ?? '').trim(); + const action = url.searchParams.get('action') ?? ''; + const targetType = url.searchParams.get('target_type') ?? ''; - const admin = getSupabaseAdmin(); - - let query = admin - .from('admin_audit_log') - .select( - 'id, actor_id, actor_email, action, target_type, target_id, details, created_at', - { count: 'exact' }, - ); - if (action) query = query.eq('action', action); - if (targetType) query = query.eq('target_type', targetType); - - const from = (page - 1) * perPage; - const to = from + perPage - 1; - query = query.order('created_at', { ascending: false }).range(from, to); - - const { data, error, count } = await query; - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + try { + const { entries, total } = await getAuditList({ + page, + perPage, + action, + targetType, + }); + return NextResponse.json({ entries, total, page, perPage }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 500 }); } - - return NextResponse.json({ - entries: data ?? [], - total: count ?? (data?.length ?? 0), - page, - perPage, - }); } diff --git a/app/api/admin/tunnels/[id]/active/route.ts b/app/api/admin/tunnels/[id]/active/route.ts index 9107ee2..6fb4261 100644 --- a/app/api/admin/tunnels/[id]/active/route.ts +++ b/app/api/admin/tunnels/[id]/active/route.ts @@ -38,7 +38,7 @@ export async function POST( const { data, error } = await admin .from('tunnels') .update({ is_active: isActive }) - .eq('id', id) + .eq('user_id', id) .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { diff --git a/app/api/admin/tunnels/[id]/quota/route.ts b/app/api/admin/tunnels/[id]/quota/route.ts index 75754b5..cc16fc9 100644 --- a/app/api/admin/tunnels/[id]/quota/route.ts +++ b/app/api/admin/tunnels/[id]/quota/route.ts @@ -40,7 +40,7 @@ export async function POST( const { data, error } = await admin .from('tunnels') .update({ quota_bytes: parsed.value }) - .eq('id', id) + .eq('user_id', id) .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { diff --git a/app/api/admin/tunnels/[id]/reassign/route.ts b/app/api/admin/tunnels/[id]/reassign/route.ts index 050d269..46d961b 100644 --- a/app/api/admin/tunnels/[id]/reassign/route.ts +++ b/app/api/admin/tunnels/[id]/reassign/route.ts @@ -45,20 +45,20 @@ export async function POST( const admin = getSupabaseAdmin(); - // Reject if taken by a different tunnel. + // Reject if taken by a different tunnel (keyed by owner user_id). const { data: existing } = await admin .from('tunnels') - .select('id') + .select('user_id') .eq('subdomain', subdomain) - .maybeSingle<{ id: string }>(); - if (existing && existing.id !== id) { + .maybeSingle<{ user_id: string }>(); + if (existing && existing.user_id !== id) { return NextResponse.json({ error: 'subdomain taken' }, { status: 409 }); } const { data, error } = await admin .from('tunnels') .update({ subdomain }) - .eq('id', id) + .eq('user_id', id) .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { diff --git a/app/api/admin/tunnels/[id]/regenerate-token/route.ts b/app/api/admin/tunnels/[id]/regenerate-token/route.ts index 6cd4a09..6fa01db 100644 --- a/app/api/admin/tunnels/[id]/regenerate-token/route.ts +++ b/app/api/admin/tunnels/[id]/regenerate-token/route.ts @@ -26,7 +26,7 @@ export async function POST( const { data, error } = await admin .from('tunnels') .update({ token }) - .eq('id', id) + .eq('user_id', id) .select('subdomain, token') .maybeSingle<{ subdomain: string; token: string }>(); if (error) { diff --git a/app/api/admin/tunnels/[id]/reset-usage/route.ts b/app/api/admin/tunnels/[id]/reset-usage/route.ts index 2da9ac6..c1cb516 100644 --- a/app/api/admin/tunnels/[id]/reset-usage/route.ts +++ b/app/api/admin/tunnels/[id]/reset-usage/route.ts @@ -23,7 +23,7 @@ export async function POST( const { data, error } = await admin .from('tunnels') .update({ bytes_used: 0 }) - .eq('id', id) + .eq('user_id', id) .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { diff --git a/app/api/admin/tunnels/[id]/route.ts b/app/api/admin/tunnels/[id]/route.ts index ace4404..c8204dd 100644 --- a/app/api/admin/tunnels/[id]/route.ts +++ b/app/api/admin/tunnels/[id]/route.ts @@ -24,7 +24,7 @@ export async function DELETE( const { data, error } = await admin .from('tunnels') .delete() - .eq('id', id) + .eq('user_id', id) .select('subdomain') .maybeSingle<{ subdomain: string }>(); if (error) { diff --git a/app/api/admin/tunnels/route.ts b/app/api/admin/tunnels/route.ts index 5da2a29..6562173 100644 --- a/app/api/admin/tunnels/route.ts +++ b/app/api/admin/tunnels/route.ts @@ -1,22 +1,11 @@ import { NextResponse, type NextRequest } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; -import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; +import { getTunnelsList } from '@/lib/admin/list'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -type TunnelRow = { - id: string; - user_id: string; - subdomain: string; - is_active: boolean; - bytes_used: number; - quota_bytes: number; - last_seen_at: string | null; - created_at: string; -}; - export async function GET(req: NextRequest) { const auth = await requireAdminApi(); if (!auth.ok) return auth.response; @@ -24,75 +13,18 @@ export async function GET(req: NextRequest) { const url = new URL(req.url); const page = parsePageParam(url.searchParams.get('page'), 1); const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100); - const search = (url.searchParams.get('search') ?? '').trim().toLowerCase(); + const search = url.searchParams.get('search') ?? ''; const status = url.searchParams.get('status'); // active|inactive|over_quota - const admin = getSupabaseAdmin(); - - const cols = - 'id, user_id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at'; - - let rows: TunnelRow[]; - let total: number; - const from = (page - 1) * perPage; - const to = from + perPage - 1; - - if (status === 'over_quota') { - // Column-to-column comparison is not expressible via PostgREST filters, - // so fetch matching rows and paginate in memory. - let q = admin.from('tunnels').select(cols); - if (search) q = q.ilike('subdomain', `%${search}%`); - const { data, error } = await q.order('created_at', { ascending: false }); - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - const all = ((data ?? []) as TunnelRow[]).filter( - (t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes, - ); - total = all.length; - rows = all.slice(from, from + perPage); - } else { - let query = admin.from('tunnels').select(cols, { count: 'exact' }); - if (search) query = query.ilike('subdomain', `%${search}%`); - if (status === 'active') query = query.eq('is_active', true); - else if (status === 'inactive') query = query.eq('is_active', false); - query = query.order('created_at', { ascending: false }).range(from, to); - - const { data, error, count } = await query; - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - rows = (data ?? []) as TunnelRow[]; - total = count ?? rows.length; + try { + const { tunnels, total } = await getTunnelsList({ + page, + perPage, + search, + status, + }); + return NextResponse.json({ tunnels, total, page, perPage }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 500 }); } - - // Resolve owner emails (per-row getUserById; acceptable for current scale). - const emails = await Promise.all( - rows.map(async (t) => { - try { - const { data: u } = await admin.auth.admin.getUserById(t.user_id); - return u.user?.email ?? null; - } catch { - return null; - } - }), - ); - - const tunnels = rows.map((t, i) => ({ - id: t.id, - user_id: t.user_id, - owner_email: emails[i], - subdomain: t.subdomain, - is_active: t.is_active, - bytes_used: t.bytes_used, - quota_bytes: t.quota_bytes, - usage_pct: - t.quota_bytes > 0 - ? Math.min(100, (t.bytes_used / t.quota_bytes) * 100) - : 0, - last_seen_at: t.last_seen_at, - created_at: t.created_at, - })); - - return NextResponse.json({ tunnels, total, page, perPage }); } diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 3a99889..081759e 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,19 +1,11 @@ import { NextResponse, type NextRequest } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; -import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { parsePageParam, parsePerPageParam } from '@/lib/admin/validators'; +import { getUsersList } from '@/lib/admin/list'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -type TunnelRow = { - user_id: string; - subdomain: string; - is_active: boolean; - bytes_used: number; - quota_bytes: number; -}; - export async function GET(req: NextRequest) { const auth = await requireAdminApi(); if (!auth.ok) return auth.response; @@ -21,58 +13,12 @@ export async function GET(req: NextRequest) { const url = new URL(req.url); const page = parsePageParam(url.searchParams.get('page'), 1); const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100); - const search = (url.searchParams.get('search') ?? '').trim().toLowerCase(); + const search = url.searchParams.get('search') ?? ''; - const admin = getSupabaseAdmin(); - - const { data, error } = await admin.auth.admin.listUsers({ page, perPage }); - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + try { + const { users, total } = await getUsersList({ page, perPage, search }); + return NextResponse.json({ users, total, page, perPage }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 500 }); } - - let users = data.users; - const total = (data as unknown as { total?: number }).total ?? users.length; - - if (search) { - users = users.filter((u) => - (u.email ?? '').toLowerCase().includes(search), - ); - } - - // Join tunnel rows for this page's users in a single query. - const ids = users.map((u) => u.id); - const tunnelMap = new Map(); - if (ids.length > 0) { - const { data: tunnels } = await admin - .from('tunnels') - .select('user_id, subdomain, is_active, bytes_used, quota_bytes') - .in('user_id', ids); - for (const t of (tunnels ?? []) as TunnelRow[]) { - tunnelMap.set(t.user_id, t); - } - } - - const result = users.map((u) => { - const t = tunnelMap.get(u.id) ?? null; - return { - id: u.id, - email: u.email ?? null, - role: (u.app_metadata?.role as string | undefined) ?? 'user', - banned_until: (u as unknown as { banned_until?: string | null }) - .banned_until ?? null, - email_confirmed_at: u.email_confirmed_at ?? null, - created_at: u.created_at, - last_sign_in_at: u.last_sign_in_at ?? null, - tunnel: t - ? { - subdomain: t.subdomain, - is_active: t.is_active, - bytes_used: t.bytes_used, - quota_bytes: t.quota_bytes, - } - : null, - }; - }); - - return NextResponse.json({ users: result, total, page, perPage }); } diff --git a/lib/admin/list.ts b/lib/admin/list.ts new file mode 100644 index 0000000..f3fe2bb --- /dev/null +++ b/lib/admin/list.ts @@ -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; + 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(); + 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 }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ab84b89 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +}