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

This commit is contained in:
Gerhard Scheikl
2026-05-31 11:46:14 +02:00
parent fb4880a1d9
commit b6c4d94990
19 changed files with 1676 additions and 840 deletions
+1 -2
View File
@@ -9,7 +9,6 @@ import { UserActions } from './user-actions';
export const dynamic = 'force-dynamic';
type TunnelRow = {
id: string;
subdomain: string;
is_active: boolean;
bytes_used: number;
@@ -54,7 +53,7 @@ export default async function AdminUserDetailPage({
const { data: tunnel } = await admin
.from('tunnels')
.select(
'id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at',
'subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at',
)
.eq('user_id', params.id)
.maybeSingle<TunnelRow>();
+14 -165
View File
@@ -1,170 +1,19 @@
'use client';
import { getUsersList } from '@/lib/admin/list';
import { UsersTable } from './users-table';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { formatBytes, formatDate } from '@/lib/format';
type AdminUser = {
id: string;
email: string | null;
role: string;
banned_until: string | null;
email_confirmed_at: string | null;
created_at: string;
last_sign_in_at: string | null;
tunnel: {
subdomain: string;
is_active: boolean;
bytes_used: number;
quota_bytes: number;
} | null;
};
export const dynamic = 'force-dynamic';
const PER_PAGE = 25;
function isBanned(u: AdminUser): boolean {
return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now();
}
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(page),
perPage: String(PER_PAGE),
});
if (search.trim()) params.set('search', search.trim());
const res = await fetch(`/api/admin/users?${params.toString()}`);
if (!res.ok) {
const b = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(b.error ?? `Request failed (${res.status})`);
}
const data = (await res.json()) as { users: AdminUser[]; total: number };
setUsers(data.users);
setTotal(data.total);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [page, search]);
useEffect(() => {
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
return (
<div>
<h1>Users</h1>
<div className="row" style={{ marginBottom: '1rem' }}>
<input
type="text"
placeholder="Search by email…"
value={search}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
style={{ maxWidth: 320 }}
/>
<button className="secondary btn-sm" onClick={load} type="button">
Refresh
</button>
</div>
{error && <p className="error">{error}</p>}
{loading ? (
<p className="muted">Loading</p>
) : users.length === 0 ? (
<p className="muted">No users found.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Tunnel</th>
<th>Usage</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<Link href={`/admin/users/${u.id}`}>
{u.email ?? u.id}
</Link>
</td>
<td>
{u.role === 'admin' ? (
<span className="badge badge-admin">admin</span>
) : (
<span className="badge">user</span>
)}
</td>
<td>
{isBanned(u) ? (
<span className="badge badge-banned">banned</span>
) : u.email_confirmed_at ? (
<span className="badge badge-ok">confirmed</span>
) : (
<span className="badge">unconfirmed</span>
)}
</td>
<td>{u.tunnel ? u.tunnel.subdomain : '—'}</td>
<td>
{u.tunnel
? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes(
u.tunnel.quota_bytes,
)}`
: '—'}
</td>
<td>{formatDate(u.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="row" style={{ marginTop: '1rem' }}>
<button
className="secondary btn-sm"
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span className="muted">
Page {page} of {totalPages} ({total} total)
</span>
<button
className="secondary btn-sm"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
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 <UsersTable initialUsers={users} initialTotal={total} />;
}
+174
View File
@@ -0,0 +1,174 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { formatBytes, formatDate } from '@/lib/format';
import type { AdminUserItem } from '@/lib/admin/list';
const PER_PAGE = 25;
function isBanned(u: AdminUserItem): boolean {
return !!u.banned_until && new Date(u.banned_until).getTime() > Date.now();
}
export function UsersTable({
initialUsers,
initialTotal,
}: {
initialUsers: AdminUserItem[];
initialTotal: number;
}) {
const [users, setUsers] = useState<AdminUserItem[]>(initialUsers);
const [total, setTotal] = useState(initialTotal);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(page),
perPage: String(PER_PAGE),
});
if (search.trim()) params.set('search', search.trim());
const res = await fetch(`/api/admin/users?${params.toString()}`, {
credentials: 'same-origin',
});
if (!res.ok) {
const b = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(b.error ?? `Request failed (${res.status})`);
}
const data = (await res.json()) as {
users: AdminUserItem[];
total: number;
};
setUsers(data.users);
setTotal(data.total);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [page, search]);
// The first page is rendered from server-supplied data (see the parent server
// component), so skip the initial on-mount fetch — that on-mount request used
// to race the SSR session-cookie refresh and intermittently 401.
const didMount = useRef(false);
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
return (
<div>
<h1>Users</h1>
<div className="row" style={{ marginBottom: '1rem' }}>
<input
type="text"
placeholder="Search by email…"
value={search}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
style={{ maxWidth: 320 }}
/>
<button className="secondary btn-sm" onClick={load} type="button">
Refresh
</button>
</div>
{error && <p className="error">{error}</p>}
{loading ? (
<p className="muted">Loading</p>
) : users.length === 0 ? (
<p className="muted">No users found.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Tunnel</th>
<th>Usage</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<Link href={`/admin/users/${u.id}`}>
{u.email ?? u.id}
</Link>
</td>
<td>
{u.role === 'admin' ? (
<span className="badge badge-admin">admin</span>
) : (
<span className="badge">user</span>
)}
</td>
<td>
{isBanned(u) ? (
<span className="badge badge-banned">banned</span>
) : u.email_confirmed_at ? (
<span className="badge badge-ok">confirmed</span>
) : (
<span className="badge">unconfirmed</span>
)}
</td>
<td>{u.tunnel ? u.tunnel.subdomain : '—'}</td>
<td>
{u.tunnel
? `${formatBytes(u.tunnel.bytes_used)} / ${formatBytes(
u.tunnel.quota_bytes,
)}`
: '—'}
</td>
<td>{formatDate(u.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="row" style={{ marginTop: '1rem' }}>
<button
className="secondary btn-sm"
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span className="muted">
Page {page} of {totalPages} ({total} total)
</span>
<button
className="secondary btn-sm"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
}