feat(admin): live redis kill-switch on tunnel actions, sortable columns + CSV export + bulk actions, Node 24 LTS
WS1: pin all Docker stages to node:24.16.0-alpine; add engines node>=20. WS2: lib/redis.ts gains TTL-backed redisSet, redisDel, setTunnelActive (writes tunnel:active:<sub>=1/0 EX 30, TUNNEL_ACTIVE_TTL override, no-op without REDIS_URL); wired into tunnel active/delete/reassign routes. WS3: sortable columns, CSV export routes (token excluded), and bulk actions (self-account guard) across users/tunnels/audit admin tables.
This commit is contained in:
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
FROM node:20.18.0-alpine AS deps
|
FROM node:24.16.0-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install --no-audit --no-fund --loglevel=error
|
RUN npm install --no-audit --no-fund --loglevel=error
|
||||||
|
|
||||||
FROM node:20.18.0-alpine AS builder
|
FROM node:24.16.0-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
@@ -20,7 +20,7 @@ ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \
|
|||||||
NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20.18.0-alpine AS runner
|
FROM node:24.16.0-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { formatDate } from '@/lib/format';
|
import { formatDate } from '@/lib/format';
|
||||||
import type { AuditItem } from '@/lib/admin/list';
|
import type { AuditItem } from '@/lib/admin/list';
|
||||||
|
import { SortHeader, downloadUrl, type SortOrder } from '../table-ui';
|
||||||
|
|
||||||
const PER_PAGE = 50;
|
const PER_PAGE = 50;
|
||||||
|
|
||||||
@@ -18,20 +19,28 @@ export function AuditTable({
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [action, setAction] = useState('');
|
const [action, setAction] = useState('');
|
||||||
const [targetType, setTargetType] = useState('');
|
const [targetType, setTargetType] = useState('');
|
||||||
|
const [sort, setSort] = useState('created_at');
|
||||||
|
const [order, setOrder] = useState<SortOrder>('desc');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const queryParams = useCallback(() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
perPage: String(PER_PAGE),
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
if (action.trim()) params.set('action', action.trim());
|
||||||
|
if (targetType.trim()) params.set('target_type', targetType.trim());
|
||||||
|
return params;
|
||||||
|
}, [page, action, targetType, sort, order]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const res = await fetch(`/api/admin/audit?${queryParams().toString()}`, {
|
||||||
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',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -49,7 +58,7 @@ export function AuditTable({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, action, targetType]);
|
}, [queryParams]);
|
||||||
|
|
||||||
// First page is server-rendered; skip the on-mount fetch to avoid racing the
|
// First page is server-rendered; skip the on-mount fetch to avoid racing the
|
||||||
// SSR session-cookie refresh (which intermittently 401'd).
|
// SSR session-cookie refresh (which intermittently 401'd).
|
||||||
@@ -63,6 +72,23 @@ export function AuditTable({
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
function onSort(col: string) {
|
||||||
|
setPage(1);
|
||||||
|
if (col === sort) {
|
||||||
|
setOrder((o) => (o === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSort(col);
|
||||||
|
setOrder('asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
const params = new URLSearchParams({ sort, order });
|
||||||
|
if (action.trim()) params.set('action', action.trim());
|
||||||
|
if (targetType.trim()) params.set('target_type', targetType.trim());
|
||||||
|
downloadUrl(`/api/admin/audit/export?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,6 +119,15 @@ export function AuditTable({
|
|||||||
<button className="secondary btn-sm" type="button" onClick={load}>
|
<button className="secondary btn-sm" type="button" onClick={load}>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
|
<div className="toolbar-actions">
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
onClick={exportCsv}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
@@ -106,9 +141,27 @@ export function AuditTable({
|
|||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>When</th>
|
<SortHeader
|
||||||
<th>Actor</th>
|
label="When"
|
||||||
<th>Action</th>
|
col="created_at"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Actor"
|
||||||
|
col="actor_email"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Action"
|
||||||
|
col="action"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
<th>Target</th>
|
<th>Target</th>
|
||||||
<th>Details</th>
|
<th>Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small shared client helpers for the admin tables: a clickable sortable
|
||||||
|
* column header and a CSV-download trigger. Kept dependency-free and
|
||||||
|
* dark-theme consistent with globals.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export function SortHeader({
|
||||||
|
label,
|
||||||
|
col,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
onSort,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
col: string;
|
||||||
|
sort: string;
|
||||||
|
order: SortOrder;
|
||||||
|
onSort: (col: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = sort === col;
|
||||||
|
const indicator = active ? (order === 'asc' ? '▲' : '▼') : '↕';
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`th-sort${active ? ' sorted' : ''}${
|
||||||
|
className ? ` ${className}` : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSort(col)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-sort={active ? (order === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSort(col);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className="sort-ind" aria-hidden="true">
|
||||||
|
{indicator}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a browser download of a same-origin URL. The server sets
|
||||||
|
* Content-Disposition: attachment, so the cookie-authenticated GET streams the
|
||||||
|
* CSV straight to a file.
|
||||||
|
*/
|
||||||
|
export function downloadUrl(url: string): void {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { formatBytes, formatDate } from '@/lib/format';
|
import { formatBytes, formatDate } from '@/lib/format';
|
||||||
import type { TunnelItem } from '@/lib/admin/list';
|
import type { TunnelItem } from '@/lib/admin/list';
|
||||||
|
import { SortHeader, downloadUrl, type SortOrder } from '../table-ui';
|
||||||
|
|
||||||
const PER_PAGE = 25;
|
const PER_PAGE = 25;
|
||||||
|
|
||||||
|
type BulkResult = { ok: number; fail: number };
|
||||||
|
|
||||||
export function TunnelsTable({
|
export function TunnelsTable({
|
||||||
initialTunnels,
|
initialTunnels,
|
||||||
initialTotal,
|
initialTotal,
|
||||||
@@ -18,22 +21,33 @@ export function TunnelsTable({
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
|
const [sort, setSort] = useState('created_at');
|
||||||
|
const [order, setOrder] = useState<SortOrder>('desc');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
const [bulkProgress, setBulkProgress] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const queryParams = useCallback(() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
perPage: String(PER_PAGE),
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
if (search.trim()) params.set('search', search.trim());
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
return params;
|
||||||
|
}, [page, search, status, sort, order]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const res = await fetch(`/api/admin/tunnels?${queryParams().toString()}`, {
|
||||||
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',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -43,12 +57,13 @@ export function TunnelsTable({
|
|||||||
const data = (await res.json()) as { tunnels: TunnelItem[]; total: number };
|
const data = (await res.json()) as { tunnels: TunnelItem[]; total: number };
|
||||||
setTunnels(data.tunnels);
|
setTunnels(data.tunnels);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
|
setSelected(new Set());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search, status]);
|
}, [queryParams]);
|
||||||
|
|
||||||
// First page is server-rendered; skip the on-mount fetch to avoid racing the
|
// First page is server-rendered; skip the on-mount fetch to avoid racing the
|
||||||
// SSR session-cookie refresh (which intermittently 401'd).
|
// SSR session-cookie refresh (which intermittently 401'd).
|
||||||
@@ -62,6 +77,16 @@ export function TunnelsTable({
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
function onSort(col: string) {
|
||||||
|
setPage(1);
|
||||||
|
if (col === sort) {
|
||||||
|
setOrder((o) => (o === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSort(col);
|
||||||
|
setOrder('asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function act(
|
async function act(
|
||||||
id: string,
|
id: string,
|
||||||
label: string,
|
label: string,
|
||||||
@@ -172,7 +197,82 @@ export function TunnelsTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRow(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIds = tunnels.map((t) => t.user_id);
|
||||||
|
const allSelected =
|
||||||
|
pageIds.length > 0 && pageIds.every((id) => selected.has(id));
|
||||||
|
function toggleAll() {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (pageIds.every((id) => prev.has(id))) return new Set();
|
||||||
|
return new Set(pageIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBulk(
|
||||||
|
label: string,
|
||||||
|
perItem: (t: TunnelItem) => Promise<boolean>,
|
||||||
|
confirmMsg?: string,
|
||||||
|
) {
|
||||||
|
const targets = tunnels.filter((t) => selected.has(t.user_id));
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||||
|
setBulkBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
const result: BulkResult = { ok: 0, fail: 0 };
|
||||||
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
setBulkProgress(`${label}: ${i + 1}/${targets.length}…`);
|
||||||
|
try {
|
||||||
|
const ok = await perItem(targets[i]);
|
||||||
|
if (ok) result.ok++;
|
||||||
|
else result.fail++;
|
||||||
|
} catch {
|
||||||
|
result.fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBulkProgress(null);
|
||||||
|
setBulkBusy(false);
|
||||||
|
const parts = [`${result.ok} ${label.toLowerCase()}`];
|
||||||
|
if (result.fail) parts.push(`${result.fail} failed`);
|
||||||
|
setNotice(parts.join(', '));
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActiveOne(t: TunnelItem, active: boolean): Promise<boolean> {
|
||||||
|
const res = await fetch(`/api/admin/tunnels/${t.user_id}/active`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_active: active }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOne(t: TunnelItem): Promise<boolean> {
|
||||||
|
const res = await fetch(`/api/admin/tunnels/${t.user_id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
const params = new URLSearchParams({ sort, order });
|
||||||
|
if (search.trim()) params.set('search', search.trim());
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
downloadUrl(`/api/admin/tunnels/export?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||||
|
const selectedCount = selected.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -211,11 +311,67 @@ export function TunnelsTable({
|
|||||||
<button className="secondary btn-sm" type="button" onClick={load}>
|
<button className="secondary btn-sm" type="button" onClick={load}>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
|
<div className="toolbar-actions">
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
onClick={exportCsv}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
{notice && <p className="success">{notice}</p>}
|
{notice && <p className="success">{notice}</p>}
|
||||||
|
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="bulk-bar">
|
||||||
|
<span className="bulk-count">{selectedCount} selected</span>
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => runBulk('Activated', (t) => setActiveOne(t, true))}
|
||||||
|
>
|
||||||
|
Activate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() =>
|
||||||
|
runBulk('Deactivated', (t) => setActiveOne(t, false))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Deactivate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-danger btn-sm"
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() =>
|
||||||
|
runBulk(
|
||||||
|
'Deleted',
|
||||||
|
deleteOne,
|
||||||
|
`Delete ${selectedCount} selected tunnel(s)? This frees their subdomains.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{bulkProgress && <span className="muted">{bulkProgress}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted">Loading…</p>
|
<p className="muted">Loading…</p>
|
||||||
) : tunnels.length === 0 ? (
|
) : tunnels.length === 0 ? (
|
||||||
@@ -225,17 +381,60 @@ export function TunnelsTable({
|
|||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Subdomain</th>
|
<th className="col-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Select all on page"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<SortHeader
|
||||||
|
label="Subdomain"
|
||||||
|
col="subdomain"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
<th>Owner</th>
|
<th>Owner</th>
|
||||||
<th>Status</th>
|
<SortHeader
|
||||||
<th>Usage</th>
|
label="Status"
|
||||||
<th>Last seen</th>
|
col="is_active"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Usage"
|
||||||
|
col="usage_pct"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Last seen"
|
||||||
|
col="last_seen_at"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tunnels.map((t) => (
|
{tunnels.map((t) => (
|
||||||
<tr key={t.user_id}>
|
<tr
|
||||||
|
key={t.user_id}
|
||||||
|
className={selected.has(t.user_id) ? 'row-selected' : undefined}
|
||||||
|
>
|
||||||
|
<td className="col-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select ${t.subdomain}`}
|
||||||
|
checked={selected.has(t.user_id)}
|
||||||
|
onChange={() => toggleRow(t.user_id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>{t.subdomain}</td>
|
<td>{t.subdomain}</td>
|
||||||
<td style={{ wordBreak: 'break-all' }}>
|
<td style={{ wordBreak: 'break-all' }}>
|
||||||
{t.owner_email ?? '—'}
|
{t.owner_email ?? '—'}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getUsersList } from '@/lib/admin/list';
|
import { getUsersList } from '@/lib/admin/list';
|
||||||
|
import { requireAdmin } from '@/lib/auth/admin-guard';
|
||||||
import { UsersTable } from './users-table';
|
import { UsersTable } from './users-table';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -10,11 +11,18 @@ export default async function AdminUsersPage() {
|
|||||||
// Initial load runs on the server (the admin session is already validated by
|
// 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
|
// the admin layout's requireAdmin()), so the first paint never races the
|
||||||
// client session-cookie refresh.
|
// client session-cookie refresh.
|
||||||
|
const admin = await requireAdmin();
|
||||||
const { users, total } = await getUsersList({
|
const { users, total } = await getUsersList({
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: PER_PAGE,
|
perPage: PER_PAGE,
|
||||||
search: '',
|
search: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
return <UsersTable initialUsers={users} initialTotal={total} />;
|
return (
|
||||||
|
<UsersTable
|
||||||
|
initialUsers={users}
|
||||||
|
initialTotal={total}
|
||||||
|
currentUserId={admin.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+223
-12
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { formatBytes, formatDate } from '@/lib/format';
|
import { formatBytes, formatDate } from '@/lib/format';
|
||||||
import type { AdminUserItem } from '@/lib/admin/list';
|
import type { AdminUserItem } from '@/lib/admin/list';
|
||||||
|
import { SortHeader, downloadUrl, type SortOrder } from '../table-ui';
|
||||||
|
|
||||||
const PER_PAGE = 25;
|
const PER_PAGE = 25;
|
||||||
|
|
||||||
@@ -11,30 +12,46 @@ function isBanned(u: AdminUserItem): boolean {
|
|||||||
return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now();
|
return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BulkResult = { ok: number; fail: number; skipped: number };
|
||||||
|
|
||||||
export function UsersTable({
|
export function UsersTable({
|
||||||
initialUsers,
|
initialUsers,
|
||||||
initialTotal,
|
initialTotal,
|
||||||
|
currentUserId,
|
||||||
}: {
|
}: {
|
||||||
initialUsers: AdminUserItem[];
|
initialUsers: AdminUserItem[];
|
||||||
initialTotal: number;
|
initialTotal: number;
|
||||||
|
currentUserId: string;
|
||||||
}) {
|
}) {
|
||||||
const [users, setUsers] = useState<AdminUserItem[]>(initialUsers);
|
const [users, setUsers] = useState<AdminUserItem[]>(initialUsers);
|
||||||
const [total, setTotal] = useState(initialTotal);
|
const [total, setTotal] = useState(initialTotal);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [sort, setSort] = useState('created_at');
|
||||||
|
const [order, setOrder] = useState<SortOrder>('desc');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
const [bulkProgress, setBulkProgress] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const queryParams = useCallback(() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
perPage: String(PER_PAGE),
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
if (search.trim()) params.set('search', search.trim());
|
||||||
|
return params;
|
||||||
|
}, [page, search, sort, order]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const res = await fetch(`/api/admin/users?${queryParams().toString()}`, {
|
||||||
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',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -47,12 +64,13 @@ export function UsersTable({
|
|||||||
};
|
};
|
||||||
setUsers(data.users);
|
setUsers(data.users);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
|
setSelected(new Set());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search]);
|
}, [queryParams]);
|
||||||
|
|
||||||
// The first page is rendered from server-supplied data (see the parent server
|
// 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
|
// component), so skip the initial on-mount fetch — that on-mount request used
|
||||||
@@ -67,13 +85,106 @@ export function UsersTable({
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
function onSort(col: string) {
|
||||||
|
setPage(1);
|
||||||
|
if (col === sort) {
|
||||||
|
setOrder((o) => (o === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSort(col);
|
||||||
|
setOrder('asc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRow(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIds = users.map((u) => u.id);
|
||||||
|
const allSelected =
|
||||||
|
pageIds.length > 0 && pageIds.every((id) => selected.has(id));
|
||||||
|
function toggleAll() {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (pageIds.every((id) => prev.has(id))) return new Set();
|
||||||
|
return new Set(pageIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBulk(
|
||||||
|
label: string,
|
||||||
|
perItem: (u: AdminUserItem) => Promise<'ok' | 'fail' | 'skip'>,
|
||||||
|
confirmMsg?: string,
|
||||||
|
) {
|
||||||
|
const targets = users.filter((u) => selected.has(u.id));
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||||
|
setBulkBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
const result: BulkResult = { ok: 0, fail: 0, skipped: 0 };
|
||||||
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
setBulkProgress(`${label}: ${i + 1}/${targets.length}…`);
|
||||||
|
try {
|
||||||
|
const r = await perItem(targets[i]);
|
||||||
|
if (r === 'ok') result.ok++;
|
||||||
|
else if (r === 'skip') result.skipped++;
|
||||||
|
else result.fail++;
|
||||||
|
} catch {
|
||||||
|
result.fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBulkProgress(null);
|
||||||
|
setBulkBusy(false);
|
||||||
|
const parts = [`${result.ok} ${label.toLowerCase()}`];
|
||||||
|
if (result.skipped) parts.push(`${result.skipped} skipped (self)`);
|
||||||
|
if (result.fail) parts.push(`${result.fail} failed`);
|
||||||
|
setNotice(parts.join(', '));
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function banOne(
|
||||||
|
u: AdminUserItem,
|
||||||
|
banned: boolean,
|
||||||
|
): Promise<'ok' | 'fail' | 'skip'> {
|
||||||
|
if (u.id === currentUserId) return 'skip';
|
||||||
|
const res = await fetch(`/api/admin/users/${u.id}/ban`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ banned }),
|
||||||
|
});
|
||||||
|
return res.ok ? 'ok' : 'fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOne(
|
||||||
|
u: AdminUserItem,
|
||||||
|
): Promise<'ok' | 'fail' | 'skip'> {
|
||||||
|
if (u.id === currentUserId) return 'skip';
|
||||||
|
const res = await fetch(`/api/admin/users/${u.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
return res.ok ? 'ok' : 'fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
const params = new URLSearchParams({ sort, order });
|
||||||
|
if (search.trim()) params.set('search', search.trim());
|
||||||
|
downloadUrl(`/api/admin/users/export?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||||
|
const selectedCount = selected.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
|
|
||||||
<div className="row" style={{ marginBottom: '1rem' }}>
|
<div className="row" style={{ marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by email…"
|
placeholder="Search by email…"
|
||||||
@@ -87,9 +198,64 @@ export function UsersTable({
|
|||||||
<button className="secondary btn-sm" onClick={load} type="button">
|
<button className="secondary btn-sm" onClick={load} type="button">
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
|
<div className="toolbar-actions">
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
onClick={exportCsv}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
{notice && <p className="success">{notice}</p>}
|
||||||
|
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="bulk-bar">
|
||||||
|
<span className="bulk-count">{selectedCount} selected</span>
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => runBulk('Banned', (u) => banOne(u, true))}
|
||||||
|
>
|
||||||
|
Ban
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => runBulk('Unbanned', (u) => banOne(u, false))}
|
||||||
|
>
|
||||||
|
Unban
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-danger btn-sm"
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() =>
|
||||||
|
runBulk(
|
||||||
|
'Deleted',
|
||||||
|
deleteOne,
|
||||||
|
`Delete ${selectedCount} selected user(s)? This cannot be undone. Your own account is skipped.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary btn-sm"
|
||||||
|
type="button"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{bulkProgress && <span className="muted">{bulkProgress}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted">Loading…</p>
|
<p className="muted">Loading…</p>
|
||||||
@@ -100,17 +266,61 @@ export function UsersTable({
|
|||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Email</th>
|
<th className="col-check">
|
||||||
<th>Role</th>
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Select all on page"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<SortHeader
|
||||||
|
label="Email"
|
||||||
|
col="email"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Role"
|
||||||
|
col="role"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Tunnel</th>
|
<th>Tunnel</th>
|
||||||
<th>Usage</th>
|
<th>Usage</th>
|
||||||
<th>Created</th>
|
<SortHeader
|
||||||
|
label="Last sign-in"
|
||||||
|
col="last_sign_in_at"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label="Created"
|
||||||
|
col="created_at"
|
||||||
|
sort={sort}
|
||||||
|
order={order}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr key={u.id}>
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
className={selected.has(u.id) ? 'row-selected' : undefined}
|
||||||
|
>
|
||||||
|
<td className="col-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select ${u.email ?? u.id}`}
|
||||||
|
checked={selected.has(u.id)}
|
||||||
|
onChange={() => toggleRow(u.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Link href={`/admin/users/${u.id}`}>
|
<Link href={`/admin/users/${u.id}`}>
|
||||||
{u.email ?? u.id}
|
{u.email ?? u.id}
|
||||||
@@ -140,6 +350,7 @@ export function UsersTable({
|
|||||||
)}`
|
)}`
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{formatDate(u.last_sign_in_at)}</td>
|
||||||
<td>{formatDate(u.created_at)}</td>
|
<td>{formatDate(u.created_at)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
||||||
|
import { getAuditList } from '@/lib/admin/list';
|
||||||
|
import { logAdminAction } from '@/lib/auth/audit';
|
||||||
|
import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const action = url.searchParams.get('action') ?? '';
|
||||||
|
const targetType = url.searchParams.get('target_type') ?? '';
|
||||||
|
const sort = url.searchParams.get('sort');
|
||||||
|
const order = url.searchParams.get('order');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Respect action/target_type filters + sort, full set (capped). Audit
|
||||||
|
// details already exclude secrets (no tokens are ever logged).
|
||||||
|
const { entries } = await getAuditList({
|
||||||
|
page: 1,
|
||||||
|
perPage: EXPORT_MAX_ROWS,
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
'created_at',
|
||||||
|
'actor_email',
|
||||||
|
'action',
|
||||||
|
'target_type',
|
||||||
|
'target_id',
|
||||||
|
'details',
|
||||||
|
];
|
||||||
|
const rows = entries.map((e) => [
|
||||||
|
e.created_at,
|
||||||
|
e.actor_email ?? '',
|
||||||
|
e.action,
|
||||||
|
e.target_type ?? '',
|
||||||
|
e.target_id ?? '',
|
||||||
|
JSON.stringify(e.details ?? {}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = toCsv(header, rows);
|
||||||
|
|
||||||
|
await logAdminAction(auth.user, {
|
||||||
|
action: 'audit.export',
|
||||||
|
target_type: 'audit',
|
||||||
|
details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="audit.csv"',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('admin audit export failed', e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'internal error' },
|
||||||
|
{ status: 500, headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ export async function GET(req: NextRequest) {
|
|||||||
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100);
|
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100);
|
||||||
const action = url.searchParams.get('action') ?? '';
|
const action = url.searchParams.get('action') ?? '';
|
||||||
const targetType = url.searchParams.get('target_type') ?? '';
|
const targetType = url.searchParams.get('target_type') ?? '';
|
||||||
|
const sort = url.searchParams.get('sort');
|
||||||
|
const order = url.searchParams.get('order');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { entries, total } = await getAuditList({
|
const { entries, total } = await getAuditList({
|
||||||
@@ -23,6 +25,8 @@ export async function GET(req: NextRequest) {
|
|||||||
perPage,
|
perPage,
|
||||||
action,
|
action,
|
||||||
targetType,
|
targetType,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
});
|
});
|
||||||
return jsonNoStore({ entries, total, page, perPage });
|
return jsonNoStore({ entries, total, page, perPage });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { requireAdminApi } from '@/lib/auth/admin-guard';
|
|||||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||||
import { logAdminAction } from '@/lib/auth/audit';
|
import { logAdminAction } from '@/lib/auth/audit';
|
||||||
import { isUuid, parseBoolean } from '@/lib/admin/validators';
|
import { isUuid, parseBoolean } from '@/lib/admin/validators';
|
||||||
import { redisSet } from '@/lib/redis';
|
import { setTunnelActive } from '@/lib/redis';
|
||||||
import { jsonNoStore } from '@/lib/admin/response';
|
import { jsonNoStore } from '@/lib/admin/response';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -50,14 +50,16 @@ export async function POST(
|
|||||||
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
|
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort live kill-switch (never throws).
|
// Best-effort live kill-switch (never throws). Writes tunnel:active:<sub>
|
||||||
await redisSet(`tunnel:active:${data.subdomain}`, isActive ? '1' : '0');
|
// = "1"/"0" with TTL so the edge gate drops/allows a live connection within
|
||||||
|
// ~1s. No-op when REDIS_URL is unset.
|
||||||
|
const redisOk = await setTunnelActive(data.subdomain, isActive);
|
||||||
|
|
||||||
await logAdminAction(auth.user, {
|
await logAdminAction(auth.user, {
|
||||||
action: isActive ? 'tunnel.activate' : 'tunnel.deactivate',
|
action: isActive ? 'tunnel.activate' : 'tunnel.deactivate',
|
||||||
target_type: 'tunnel',
|
target_type: 'tunnel',
|
||||||
target_id: id,
|
target_id: id,
|
||||||
details: { subdomain: data.subdomain, is_active: isActive },
|
details: { subdomain: data.subdomain, is_active: isActive, redis: redisOk },
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonNoStore({ ok: true, is_active: isActive });
|
return jsonNoStore({ ok: true, is_active: isActive });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { logAdminAction } from '@/lib/auth/audit';
|
|||||||
import { isUuid } from '@/lib/admin/validators';
|
import { isUuid } from '@/lib/admin/validators';
|
||||||
import { validateSubdomain } from '@/lib/validation';
|
import { validateSubdomain } from '@/lib/validation';
|
||||||
import { isSubdomainReserved } from '@/lib/admin/reserved';
|
import { isSubdomainReserved } from '@/lib/admin/reserved';
|
||||||
|
import { setTunnelActive } from '@/lib/redis';
|
||||||
import { jsonNoStore } from '@/lib/admin/response';
|
import { jsonNoStore } from '@/lib/admin/response';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -49,13 +50,22 @@ export async function POST(
|
|||||||
// Reject if taken by a different tunnel (keyed by owner user_id).
|
// 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('user_id')
|
.select('user_id, subdomain')
|
||||||
.eq('subdomain', subdomain)
|
.eq('subdomain', subdomain)
|
||||||
.maybeSingle<{ user_id: string }>();
|
.maybeSingle<{ user_id: string; subdomain: string }>();
|
||||||
if (existing && existing.user_id !== id) {
|
if (existing && existing.user_id !== id) {
|
||||||
return jsonNoStore({ error: 'subdomain taken' }, { status: 409 });
|
return jsonNoStore({ error: 'subdomain taken' }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture the current subdomain so we can drop the OLD hostname's live
|
||||||
|
// connection once it is freed by the rename.
|
||||||
|
const { data: current } = await admin
|
||||||
|
.from('tunnels')
|
||||||
|
.select('subdomain')
|
||||||
|
.eq('user_id', id)
|
||||||
|
.maybeSingle<{ subdomain: string }>();
|
||||||
|
const oldSubdomain = current?.subdomain ?? null;
|
||||||
|
|
||||||
const { data, error } = await admin
|
const { data, error } = await admin
|
||||||
.from('tunnels')
|
.from('tunnels')
|
||||||
.update({ subdomain })
|
.update({ subdomain })
|
||||||
@@ -74,11 +84,17 @@ export async function POST(
|
|||||||
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
|
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: drop any live connection on the OLD subdomain so the former
|
||||||
|
// hostname stops resolving as active. No-op when REDIS_URL is unset.
|
||||||
|
if (oldSubdomain && oldSubdomain !== subdomain) {
|
||||||
|
await setTunnelActive(oldSubdomain, false);
|
||||||
|
}
|
||||||
|
|
||||||
await logAdminAction(auth.user, {
|
await logAdminAction(auth.user, {
|
||||||
action: 'tunnel.reassign',
|
action: 'tunnel.reassign',
|
||||||
target_type: 'tunnel',
|
target_type: 'tunnel',
|
||||||
target_id: id,
|
target_id: id,
|
||||||
details: { subdomain },
|
details: { subdomain, previous_subdomain: oldSubdomain },
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonNoStore({ ok: true, subdomain });
|
return jsonNoStore({ ok: true, subdomain });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { requireAdminApi } from '@/lib/auth/admin-guard';
|
|||||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||||
import { logAdminAction } from '@/lib/auth/audit';
|
import { logAdminAction } from '@/lib/auth/audit';
|
||||||
import { isUuid } from '@/lib/admin/validators';
|
import { isUuid } from '@/lib/admin/validators';
|
||||||
import { redisSet } from '@/lib/redis';
|
import { setTunnelActive } from '@/lib/redis';
|
||||||
import { jsonNoStore } from '@/lib/admin/response';
|
import { jsonNoStore } from '@/lib/admin/response';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -36,14 +36,15 @@ export async function DELETE(
|
|||||||
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
|
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort live kill-switch.
|
// Best-effort live kill-switch: write "0" so any live connection on the
|
||||||
await redisSet(`tunnel:active:${data.subdomain}`, '0');
|
// freed subdomain drops within ~1s. No-op when REDIS_URL is unset.
|
||||||
|
const redisOk = await setTunnelActive(data.subdomain, false);
|
||||||
|
|
||||||
await logAdminAction(auth.user, {
|
await logAdminAction(auth.user, {
|
||||||
action: 'tunnel.delete',
|
action: 'tunnel.delete',
|
||||||
target_type: 'tunnel',
|
target_type: 'tunnel',
|
||||||
target_id: id,
|
target_id: id,
|
||||||
details: { subdomain: data.subdomain },
|
details: { subdomain: data.subdomain, redis: redisOk },
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonNoStore({ ok: true });
|
return jsonNoStore({ ok: true });
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
||||||
|
import { getTunnelsList } from '@/lib/admin/list';
|
||||||
|
import { logAdminAction } from '@/lib/auth/audit';
|
||||||
|
import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const search = url.searchParams.get('search') ?? '';
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
const sort = url.searchParams.get('sort');
|
||||||
|
const order = url.searchParams.get('order');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Respect search/status/sort but pull the full matching set (capped).
|
||||||
|
// SECURITY: the tunnel token is NEVER selected or exported.
|
||||||
|
const { tunnels } = await getTunnelsList({
|
||||||
|
page: 1,
|
||||||
|
perPage: EXPORT_MAX_ROWS,
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
'user_id',
|
||||||
|
'owner_email',
|
||||||
|
'subdomain',
|
||||||
|
'is_active',
|
||||||
|
'bytes_used',
|
||||||
|
'quota_bytes',
|
||||||
|
'usage_pct',
|
||||||
|
'last_seen_at',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
const rows = tunnels.map((t) => [
|
||||||
|
t.user_id,
|
||||||
|
t.owner_email ?? '',
|
||||||
|
t.subdomain,
|
||||||
|
t.is_active ? 'true' : 'false',
|
||||||
|
t.bytes_used,
|
||||||
|
t.quota_bytes,
|
||||||
|
t.usage_pct.toFixed(1),
|
||||||
|
t.last_seen_at ?? '',
|
||||||
|
t.created_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = toCsv(header, rows);
|
||||||
|
|
||||||
|
await logAdminAction(auth.user, {
|
||||||
|
action: 'tunnel.export',
|
||||||
|
target_type: 'tunnel',
|
||||||
|
details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="tunnels.csv"',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('admin tunnels export failed', e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'internal error' },
|
||||||
|
{ status: 500, headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ export async function GET(req: NextRequest) {
|
|||||||
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
|
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
|
||||||
const search = url.searchParams.get('search') ?? '';
|
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 sort = url.searchParams.get('sort');
|
||||||
|
const order = url.searchParams.get('order');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { tunnels, total } = await getTunnelsList({
|
const { tunnels, total } = await getTunnelsList({
|
||||||
@@ -23,6 +25,8 @@ export async function GET(req: NextRequest) {
|
|||||||
perPage,
|
perPage,
|
||||||
search,
|
search,
|
||||||
status,
|
status,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
});
|
});
|
||||||
return jsonNoStore({ tunnels, total, page, perPage });
|
return jsonNoStore({ tunnels, total, page, perPage });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminApi } from '@/lib/auth/admin-guard';
|
||||||
|
import { getUsersList } from '@/lib/admin/list';
|
||||||
|
import { logAdminAction } from '@/lib/auth/audit';
|
||||||
|
import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function isBanned(banned_until: string | null): boolean {
|
||||||
|
return !!banned_until && new Date(banned_until).getTime() > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const auth = await requireAdminApi();
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const search = url.searchParams.get('search') ?? '';
|
||||||
|
const sort = url.searchParams.get('sort');
|
||||||
|
const order = url.searchParams.get('order');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Respect search/sort but pull the full matching set (capped). NOTE: never
|
||||||
|
// export secrets — the users list carries no token.
|
||||||
|
const { users } = await getUsersList({
|
||||||
|
page: 1,
|
||||||
|
perPage: EXPORT_MAX_ROWS,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
'email',
|
||||||
|
'role',
|
||||||
|
'status',
|
||||||
|
'tunnel_subdomain',
|
||||||
|
'tunnel_active',
|
||||||
|
'bytes_used',
|
||||||
|
'quota_bytes',
|
||||||
|
'created_at',
|
||||||
|
'last_sign_in_at',
|
||||||
|
];
|
||||||
|
const rows = users.map((u) => [
|
||||||
|
u.email ?? '',
|
||||||
|
u.role,
|
||||||
|
isBanned(u.banned_until)
|
||||||
|
? 'banned'
|
||||||
|
: u.email_confirmed_at
|
||||||
|
? 'confirmed'
|
||||||
|
: 'unconfirmed',
|
||||||
|
u.tunnel?.subdomain ?? '',
|
||||||
|
u.tunnel ? (u.tunnel.is_active ? 'true' : 'false') : '',
|
||||||
|
u.tunnel?.bytes_used ?? '',
|
||||||
|
u.tunnel?.quota_bytes ?? '',
|
||||||
|
u.created_at,
|
||||||
|
u.last_sign_in_at ?? '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = toCsv(header, rows);
|
||||||
|
|
||||||
|
await logAdminAction(auth.user, {
|
||||||
|
action: 'user.export',
|
||||||
|
target_type: 'user',
|
||||||
|
details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="users.csv"',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('admin users export failed', e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'internal error' },
|
||||||
|
{ status: 500, headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,17 @@ export async function GET(req: NextRequest) {
|
|||||||
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') ?? '';
|
const search = url.searchParams.get('search') ?? '';
|
||||||
|
const sort = url.searchParams.get('sort');
|
||||||
|
const order = url.searchParams.get('order');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { users, total } = await getUsersList({ page, perPage, search });
|
const { users, total } = await getUsersList({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
return jsonNoStore({ users, total, page, perPage });
|
return jsonNoStore({ users, total, page, perPage });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('admin users list failed', e);
|
console.error('admin users list failed', e);
|
||||||
|
|||||||
@@ -365,3 +365,66 @@ button:disabled,
|
|||||||
select {
|
select {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sortable table headers */
|
||||||
|
.th-sort {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.th-sort:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.th-sort .sort-ind {
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.th-sort.sorted .sort-ind {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox / selection column */
|
||||||
|
.admin-table th.col-check,
|
||||||
|
.admin-table td.col-check {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.admin-table input[type='checkbox'] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.admin-table tr.row-selected td {
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bulk action bar */
|
||||||
|
.bulk-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.bulk-bar .bulk-count {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.bulk-bar .spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Minimal, dependency-free CSV serialization for the admin export endpoints.
|
||||||
|
* RFC-4180 style: fields containing a comma, double-quote, CR or LF are wrapped
|
||||||
|
* in double-quotes with embedded quotes doubled. Everything is coerced to a
|
||||||
|
* string first; null/undefined become empty fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function csvField(v: unknown): string {
|
||||||
|
let s: string;
|
||||||
|
if (v === null || v === undefined) s = '';
|
||||||
|
else if (typeof v === 'string') s = v;
|
||||||
|
else if (typeof v === 'object') s = JSON.stringify(v);
|
||||||
|
else s = String(v);
|
||||||
|
if (/[",\r\n]/.test(s)) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csvRow(values: unknown[]): string {
|
||||||
|
return values.map(csvField).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a full CSV document (header + rows). A leading row is always the
|
||||||
|
* provided header. Lines are CRLF-terminated for maximal spreadsheet
|
||||||
|
* compatibility.
|
||||||
|
*/
|
||||||
|
export function toCsv(header: string[], rows: unknown[][]): string {
|
||||||
|
const lines = [csvRow(header), ...rows.map(csvRow)];
|
||||||
|
return lines.join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max rows any single export will emit. Keeps a runaway export bounded; the
|
||||||
|
* caller notes the cap in the report when hit.
|
||||||
|
*/
|
||||||
|
export const EXPORT_MAX_ROWS = 10000;
|
||||||
+102
-9
@@ -1,5 +1,15 @@
|
|||||||
import type { User } from '@supabase/supabase-js';
|
import type { User } from '@supabase/supabase-js';
|
||||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||||
|
import {
|
||||||
|
parseOrder,
|
||||||
|
parseSort,
|
||||||
|
USER_SORTS,
|
||||||
|
TUNNEL_SORTS,
|
||||||
|
AUDIT_SORTS,
|
||||||
|
type SortOrder,
|
||||||
|
type UserSort,
|
||||||
|
type TunnelSort,
|
||||||
|
} from '@/lib/admin/sort';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared admin list-query logic, used by BOTH the JSON route handlers and the
|
* Shared admin list-query logic, used by BOTH the JSON route handlers and the
|
||||||
@@ -66,23 +76,56 @@ type TunnelRow = TunnelJoinRow & {
|
|||||||
const USER_SCAN_MAX_PAGES = 50;
|
const USER_SCAN_MAX_PAGES = 50;
|
||||||
const USER_SCAN_PER_PAGE = 1000;
|
const USER_SCAN_PER_PAGE = 1000;
|
||||||
|
|
||||||
|
function userSortValue(u: User, sort: UserSort): string | number {
|
||||||
|
switch (sort) {
|
||||||
|
case 'email':
|
||||||
|
return (u.email ?? '').toLowerCase();
|
||||||
|
case 'role':
|
||||||
|
return ((u.app_metadata?.role as string | undefined) ?? 'user').toLowerCase();
|
||||||
|
case 'last_sign_in_at':
|
||||||
|
return u.last_sign_in_at ? Date.parse(u.last_sign_in_at) : 0;
|
||||||
|
case 'created_at':
|
||||||
|
default:
|
||||||
|
return u.created_at ? Date.parse(u.created_at) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortUsers(arr: User[], sort: UserSort, order: SortOrder): void {
|
||||||
|
const dir = order === 'asc' ? 1 : -1;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const av = userSortValue(a, sort);
|
||||||
|
const bv = userSortValue(b, sort);
|
||||||
|
if (av < bv) return -1 * dir;
|
||||||
|
if (av > bv) return 1 * dir;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUsersList(opts: {
|
export async function getUsersList(opts: {
|
||||||
page: number;
|
page: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
search: string;
|
search: string;
|
||||||
|
sort?: string | null;
|
||||||
|
order?: string | null;
|
||||||
}): Promise<{ users: AdminUserItem[]; total: number }> {
|
}): Promise<{ users: AdminUserItem[]; total: number }> {
|
||||||
const { page, perPage } = opts;
|
const { page, perPage } = opts;
|
||||||
const search = opts.search.trim().toLowerCase();
|
const search = opts.search.trim().toLowerCase();
|
||||||
|
const sort = parseSort(opts.sort, USER_SORTS, 'created_at');
|
||||||
|
const order = parseOrder(opts.order, 'desc');
|
||||||
const admin = getSupabaseAdmin();
|
const admin = getSupabaseAdmin();
|
||||||
|
|
||||||
let pageUsers: User[];
|
let pageUsers: User[];
|
||||||
let total: number;
|
let total: number;
|
||||||
|
|
||||||
if (search) {
|
// A non-default sort must order across ALL users (not just one listUsers
|
||||||
// Search must match across ALL users, not just the current listUsers page.
|
// page), so it shares the search path's full directory scan.
|
||||||
|
const isDefaultSort = sort === 'created_at' && order === 'desc';
|
||||||
|
const needScan = !!search || !isDefaultSort;
|
||||||
|
|
||||||
|
if (needScan) {
|
||||||
// Page through the user directory (bounded by USER_SCAN_MAX_PAGES),
|
// Page through the user directory (bounded by USER_SCAN_MAX_PAGES),
|
||||||
// accumulate case-insensitive email substring matches, then paginate the
|
// accumulate case-insensitive email substring matches, sort, then
|
||||||
// filtered set.
|
// paginate the filtered+sorted set.
|
||||||
const matched: User[] = [];
|
const matched: User[] = [];
|
||||||
for (let p = 1; p <= USER_SCAN_MAX_PAGES; p++) {
|
for (let p = 1; p <= USER_SCAN_MAX_PAGES; p++) {
|
||||||
const { data, error } = await admin.auth.admin.listUsers({
|
const { data, error } = await admin.auth.admin.listUsers({
|
||||||
@@ -93,15 +136,18 @@ export async function getUsersList(opts: {
|
|||||||
const us = data.users;
|
const us = data.users;
|
||||||
if (us.length === 0) break;
|
if (us.length === 0) break;
|
||||||
for (const u of us) {
|
for (const u of us) {
|
||||||
if ((u.email ?? '').toLowerCase().includes(search)) matched.push(u);
|
if (!search || (u.email ?? '').toLowerCase().includes(search)) {
|
||||||
|
matched.push(u);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (us.length < USER_SCAN_PER_PAGE) break;
|
if (us.length < USER_SCAN_PER_PAGE) break;
|
||||||
}
|
}
|
||||||
|
sortUsers(matched, sort, order);
|
||||||
total = matched.length;
|
total = matched.length;
|
||||||
const from = (page - 1) * perPage;
|
const from = (page - 1) * perPage;
|
||||||
pageUsers = matched.slice(from, from + perPage);
|
pageUsers = matched.slice(from, from + perPage);
|
||||||
} else {
|
} else {
|
||||||
// Common no-search path: cheap single-page lookup (unchanged behavior).
|
// Common no-search, default-sort path: cheap single-page lookup.
|
||||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
|
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
pageUsers = data.users;
|
pageUsers = data.users;
|
||||||
@@ -146,14 +192,54 @@ export async function getUsersList(opts: {
|
|||||||
return { users, total };
|
return { users, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tunnelSortValue(t: TunnelRow, sort: TunnelSort): string | number {
|
||||||
|
switch (sort) {
|
||||||
|
case 'subdomain':
|
||||||
|
return t.subdomain.toLowerCase();
|
||||||
|
case 'bytes_used':
|
||||||
|
return t.bytes_used;
|
||||||
|
case 'quota_bytes':
|
||||||
|
return t.quota_bytes;
|
||||||
|
case 'is_active':
|
||||||
|
return t.is_active ? 1 : 0;
|
||||||
|
case 'last_seen_at':
|
||||||
|
return t.last_seen_at ? Date.parse(t.last_seen_at) : 0;
|
||||||
|
case 'usage_pct':
|
||||||
|
return t.quota_bytes > 0 ? t.bytes_used / t.quota_bytes : 0;
|
||||||
|
case 'created_at':
|
||||||
|
default:
|
||||||
|
return t.created_at ? Date.parse(t.created_at) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTunnelRows(
|
||||||
|
arr: TunnelRow[],
|
||||||
|
sort: TunnelSort,
|
||||||
|
ascending: boolean,
|
||||||
|
): void {
|
||||||
|
const dir = ascending ? 1 : -1;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const av = tunnelSortValue(a, sort);
|
||||||
|
const bv = tunnelSortValue(b, sort);
|
||||||
|
if (av < bv) return -1 * dir;
|
||||||
|
if (av > bv) return 1 * dir;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTunnelsList(opts: {
|
export async function getTunnelsList(opts: {
|
||||||
page: number;
|
page: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
search: string;
|
search: string;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
|
sort?: string | null;
|
||||||
|
order?: string | null;
|
||||||
}): Promise<{ tunnels: TunnelItem[]; total: number }> {
|
}): Promise<{ tunnels: TunnelItem[]; total: number }> {
|
||||||
const { page, perPage, status } = opts;
|
const { page, perPage, status } = opts;
|
||||||
const search = opts.search.trim().toLowerCase();
|
const search = opts.search.trim().toLowerCase();
|
||||||
|
const sort = parseSort(opts.sort, TUNNEL_SORTS, 'created_at');
|
||||||
|
const order = parseOrder(opts.order, 'desc');
|
||||||
|
const ascending = order === 'asc';
|
||||||
const admin = getSupabaseAdmin();
|
const admin = getSupabaseAdmin();
|
||||||
|
|
||||||
// The tunnels table's primary key is user_id (one tunnel per user); there is
|
// The tunnels table's primary key is user_id (one tunnel per user); there is
|
||||||
@@ -171,11 +257,12 @@ export async function getTunnelsList(opts: {
|
|||||||
// so fetch matching rows and paginate in memory.
|
// so fetch matching rows and paginate in memory.
|
||||||
let q = admin.from('tunnels').select(cols);
|
let q = admin.from('tunnels').select(cols);
|
||||||
if (search) q = q.ilike('subdomain', `%${search}%`);
|
if (search) q = q.ilike('subdomain', `%${search}%`);
|
||||||
const { data, error } = await q.order('created_at', { ascending: false });
|
const { data, error } = await q;
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
const all = ((data ?? []) as TunnelRow[]).filter(
|
const all = ((data ?? []) as TunnelRow[]).filter(
|
||||||
(t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes,
|
(t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes,
|
||||||
);
|
);
|
||||||
|
sortTunnelRows(all, sort, ascending);
|
||||||
total = all.length;
|
total = all.length;
|
||||||
rows = all.slice(from, from + perPage);
|
rows = all.slice(from, from + perPage);
|
||||||
} else {
|
} else {
|
||||||
@@ -183,7 +270,9 @@ export async function getTunnelsList(opts: {
|
|||||||
if (search) query = query.ilike('subdomain', `%${search}%`);
|
if (search) query = query.ilike('subdomain', `%${search}%`);
|
||||||
if (status === 'active') query = query.eq('is_active', true);
|
if (status === 'active') query = query.eq('is_active', true);
|
||||||
else if (status === 'inactive') query = query.eq('is_active', false);
|
else if (status === 'inactive') query = query.eq('is_active', false);
|
||||||
query = query.order('created_at', { ascending: false }).range(from, to);
|
// usage_pct is computed; order by bytes_used as its closest DB proxy.
|
||||||
|
const dbCol = sort === 'usage_pct' ? 'bytes_used' : sort;
|
||||||
|
query = query.order(dbCol, { ascending }).range(from, to);
|
||||||
|
|
||||||
const { data, error, count } = await query;
|
const { data, error, count } = await query;
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
@@ -226,10 +315,14 @@ export async function getAuditList(opts: {
|
|||||||
perPage: number;
|
perPage: number;
|
||||||
action: string;
|
action: string;
|
||||||
targetType: string;
|
targetType: string;
|
||||||
|
sort?: string | null;
|
||||||
|
order?: string | null;
|
||||||
}): Promise<{ entries: AuditItem[]; total: number }> {
|
}): Promise<{ entries: AuditItem[]; total: number }> {
|
||||||
const { page, perPage } = opts;
|
const { page, perPage } = opts;
|
||||||
const action = opts.action.trim();
|
const action = opts.action.trim();
|
||||||
const targetType = opts.targetType.trim();
|
const targetType = opts.targetType.trim();
|
||||||
|
const sort = parseSort(opts.sort, AUDIT_SORTS, 'created_at');
|
||||||
|
const ascending = parseOrder(opts.order, 'desc') === 'asc';
|
||||||
const admin = getSupabaseAdmin();
|
const admin = getSupabaseAdmin();
|
||||||
|
|
||||||
let query = admin
|
let query = admin
|
||||||
@@ -243,7 +336,7 @@ export async function getAuditList(opts: {
|
|||||||
|
|
||||||
const from = (page - 1) * perPage;
|
const from = (page - 1) * perPage;
|
||||||
const to = from + perPage - 1;
|
const to = from + perPage - 1;
|
||||||
query = query.order('created_at', { ascending: false }).range(from, to);
|
query = query.order(sort, { ascending }).range(from, to);
|
||||||
|
|
||||||
const { data, error, count } = await query;
|
const { data, error, count } = await query;
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Tiny helpers to parse + whitelist server-side sort parameters for the admin
|
||||||
|
* list/export endpoints. Anything not on the per-endpoint allow-list falls
|
||||||
|
* back to that endpoint's default column, so untrusted `sort`/`order` query
|
||||||
|
* params can never reach PostgREST verbatim.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export function parseOrder(
|
||||||
|
v: string | null | undefined,
|
||||||
|
fallback: SortOrder = 'desc',
|
||||||
|
): SortOrder {
|
||||||
|
return v === 'asc' || v === 'desc' ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSort<T extends string>(
|
||||||
|
v: string | null | undefined,
|
||||||
|
allowed: readonly T[],
|
||||||
|
fallback: T,
|
||||||
|
): T {
|
||||||
|
return v && (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_SORTS = [
|
||||||
|
'email',
|
||||||
|
'created_at',
|
||||||
|
'last_sign_in_at',
|
||||||
|
'role',
|
||||||
|
] as const;
|
||||||
|
export type UserSort = (typeof USER_SORTS)[number];
|
||||||
|
|
||||||
|
export const TUNNEL_SORTS = [
|
||||||
|
'subdomain',
|
||||||
|
'bytes_used',
|
||||||
|
'quota_bytes',
|
||||||
|
'is_active',
|
||||||
|
'created_at',
|
||||||
|
'last_seen_at',
|
||||||
|
'usage_pct',
|
||||||
|
] as const;
|
||||||
|
export type TunnelSort = (typeof TUNNEL_SORTS)[number];
|
||||||
|
|
||||||
|
export const AUDIT_SORTS = ['created_at', 'action', 'actor_email'] as const;
|
||||||
|
export type AuditSort = (typeof AUDIT_SORTS)[number];
|
||||||
+78
-7
@@ -41,10 +41,11 @@ function encodeCommand(args: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort SET. Resolves true on apparent success, false otherwise.
|
* Best-effort execution of a single RESP command (preceded by optional
|
||||||
* Never rejects.
|
* AUTH/SELECT). Resolves true when the final reply is a non-error (`+...` or
|
||||||
|
* `:...`), false otherwise. Never rejects.
|
||||||
*/
|
*/
|
||||||
export function redisSet(key: string, value: string): Promise<boolean> {
|
function runCommand(args: string[]): Promise<boolean> {
|
||||||
const url = process.env.REDIS_URL;
|
const url = process.env.REDIS_URL;
|
||||||
if (!url) return Promise.resolve(false);
|
if (!url) return Promise.resolve(false);
|
||||||
const target = parseRedisUrl(url);
|
const target = parseRedisUrl(url);
|
||||||
@@ -68,7 +69,7 @@ export function redisSet(key: string, value: string): Promise<boolean> {
|
|||||||
if (target.db !== undefined && target.db > 0) {
|
if (target.db !== undefined && target.db > 0) {
|
||||||
commands.push(encodeCommand(['SELECT', String(target.db)]));
|
commands.push(encodeCommand(['SELECT', String(target.db)]));
|
||||||
}
|
}
|
||||||
commands.push(encodeCommand(['SET', key, value]));
|
commands.push(encodeCommand(args));
|
||||||
|
|
||||||
const socket = net.createConnection(
|
const socket = net.createConnection(
|
||||||
{ host: target.host, port: target.port },
|
{ host: target.host, port: target.port },
|
||||||
@@ -85,16 +86,86 @@ export function redisSet(key: string, value: string): Promise<boolean> {
|
|||||||
socket.on('error', () => done(false));
|
socket.on('error', () => done(false));
|
||||||
|
|
||||||
let buf = '';
|
let buf = '';
|
||||||
let expectedReplies = commands.length;
|
const expectedReplies = commands.length;
|
||||||
socket.on('data', (chunk) => {
|
socket.on('data', (chunk) => {
|
||||||
buf += chunk.toString('utf8');
|
buf += chunk.toString('utf8');
|
||||||
// Count complete simple replies (lines terminated by \r\n).
|
// Count complete simple replies (lines terminated by \r\n).
|
||||||
const lines = buf.split('\r\n').filter((l) => l.length > 0);
|
const lines = buf.split('\r\n').filter((l) => l.length > 0);
|
||||||
if (lines.length >= expectedReplies) {
|
if (lines.length >= expectedReplies) {
|
||||||
const last = lines[lines.length - 1];
|
const last = lines[lines.length - 1];
|
||||||
// +OK for SET success.
|
// +OK / +... (simple string) or :N (integer, e.g. DEL count) = success.
|
||||||
done(last.startsWith('+OK') || last.startsWith('+'));
|
done(last.startsWith('+') || last.startsWith(':'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort SET. When `ttlSeconds` is a positive integer the command is
|
||||||
|
* issued as `SET key val EX <ttl>`, otherwise a plain `SET key val`. Resolves
|
||||||
|
* true on apparent success, false otherwise. Never rejects. Backward
|
||||||
|
* compatible: existing two-arg callers are unaffected.
|
||||||
|
*/
|
||||||
|
export function redisSet(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
ttlSeconds?: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const args = ['SET', key, value];
|
||||||
|
if (
|
||||||
|
typeof ttlSeconds === 'number' &&
|
||||||
|
Number.isFinite(ttlSeconds) &&
|
||||||
|
ttlSeconds > 0
|
||||||
|
) {
|
||||||
|
args.push('EX', String(Math.floor(ttlSeconds)));
|
||||||
|
}
|
||||||
|
return runCommand(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort DEL. Resolves true when Redis acknowledges the command (reply is
|
||||||
|
* an integer, even 0), false otherwise. Never rejects.
|
||||||
|
*/
|
||||||
|
export function redisDel(key: string): Promise<boolean> {
|
||||||
|
return runCommand(['DEL', key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TTL (seconds) written alongside the tunnel:active flag. Must match the edge
|
||||||
|
* `tunnel-active` forward_auth gate's TTL_SECONDS (currently 30) so the flag
|
||||||
|
* naturally expires in lock-step with the gate's cache. Overridable via
|
||||||
|
* TUNNEL_ACTIVE_TTL; defaults to 30.
|
||||||
|
*/
|
||||||
|
function tunnelActiveTtl(): number {
|
||||||
|
const raw = process.env.TUNNEL_ACTIVE_TTL;
|
||||||
|
if (raw) {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (Number.isFinite(n) && n > 0) return Math.floor(n);
|
||||||
|
}
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live kill-switch primitive. Writes the SAME key the edge gate reads:
|
||||||
|
* SET tunnel:active:<subdomain> <"1"|"0"> EX <TTL>
|
||||||
|
* "1" = ALLOW, "0" = DENY. A currently-connected tunnel is dropped within ~1s
|
||||||
|
* when "0" is written; "1" re-allows it. No-op (returns false) when REDIS_URL
|
||||||
|
* is unset — behavior then falls back to the gate's Postgres `is_active` read,
|
||||||
|
* exactly as before this wiring existed. Never throws.
|
||||||
|
*/
|
||||||
|
export async function setTunnelActive(
|
||||||
|
subdomain: string,
|
||||||
|
active: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!process.env.REDIS_URL) return false;
|
||||||
|
if (!subdomain) return false;
|
||||||
|
try {
|
||||||
|
return await redisSet(
|
||||||
|
`tunnel:active:${subdomain}`,
|
||||||
|
active ? '1' : '0',
|
||||||
|
tunnelActiveTtl(),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+108
-105
@@ -19,6 +19,9 @@
|
|||||||
"@types/react": "18.3.11",
|
"@types/react": "18.3.11",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"typescript": "5.6.2"
|
"typescript": "5.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
@@ -27,6 +30,36 @@
|
|||||||
"integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==",
|
"integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
|
"version": "14.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz",
|
||||||
|
"integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
|
"version": "14.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz",
|
||||||
|
"integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "14.2.15",
|
"version": "14.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz",
|
||||||
@@ -59,6 +92,81 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
|
"version": "14.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz",
|
||||||
|
"integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
|
"version": "14.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz",
|
||||||
|
"integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
|
"version": "14.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz",
|
||||||
|
"integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||||
|
"version": "14.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz",
|
||||||
|
"integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
|
"version": "14.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz",
|
||||||
|
"integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.65.0",
|
"version": "2.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.0.tgz",
|
||||||
@@ -542,111 +650,6 @@
|
|||||||
"optional": true
|
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"name": "linumiq-web",
|
"name": "linumiq-web",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
Reference in New Issue
Block a user