Files
linumiq_net-web_app/app/admin/reserved/page.tsx
T
Gerhard Scheikl fb4880a1d9 feat(admin): comprehensive admin interface (users, tunnels, metrics, audit, reserved subdomains)
Adds an authenticated admin surface gated by auth.users.app_metadata.role==='admin'.

- lib/auth/admin-guard.ts: requireAdmin() (pages) + requireAdminApi() (routes)
- middleware.ts: defense-in-depth /admin and /api/admin guarding
- API: users (list/detail/role/ban/delete), tunnels (list + active/quota/reset/reassign/regenerate-token/delete), metrics, audit log, reserved subdomains
- Self-lockout prevention (no self demote/ban/delete)
- Best-effort Redis kill-switch via dependency-free net-socket client (REDIS_URL)
- admin_audit_log + reserved_subdomains migration (RLS on, service-role only)
- Admin UI (overview, users, tunnels, reserved, audit) + conditional nav link
2026-05-31 10:58:23 +02:00

176 lines
5.0 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import { formatDate } from '@/lib/format';
type Reserved = { name: string; created_at: string };
export default function AdminReservedPage() {
const [reserved, setReserved] = useState<Reserved[]>([]);
const [hardcoded, setHardcoded] = useState<string[]>([]);
const [name, setName] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/reserved');
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 {
reserved: Reserved[];
hardcoded: string[];
};
setReserved(data.reserved);
setHardcoded(data.hardcoded);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function add(e: React.FormEvent) {
e.preventDefault();
const value = name.trim().toLowerCase();
if (!value) return;
setBusy(true);
setError(null);
setNotice(null);
try {
const res = await fetch('/api/admin/reserved', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: value }),
});
if (!res.ok) {
const b = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(b.error ?? `Request failed (${res.status})`);
}
setName('');
setNotice(`Reserved '${value}'`);
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function remove(n: string) {
if (!window.confirm(`Remove reserved subdomain '${n}'?`)) return;
setBusy(true);
setError(null);
setNotice(null);
try {
const res = await fetch(
`/api/admin/reserved?name=${encodeURIComponent(n)}`,
{ method: 'DELETE' },
);
if (!res.ok) {
const b = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(b.error ?? `Request failed (${res.status})`);
}
setNotice(`Removed '${n}'`);
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<div>
<h1>Reserved subdomains</h1>
<div className="card">
<h2>Add reserved subdomain</h2>
<form onSubmit={add}>
<div className="row">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value.toLowerCase())}
placeholder="e.g. status"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
style={{ maxWidth: 280 }}
/>
<button type="submit" className="btn-sm" disabled={busy || !name}>
Add
</button>
</div>
</form>
{error && <p className="error">{error}</p>}
{notice && <p className="success">{notice}</p>}
</div>
<div className="card">
<h2>Database reserved</h2>
{loading ? (
<p className="muted">Loading</p>
) : reserved.length === 0 ? (
<p className="muted">None reserved in the database.</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Added</th>
<th></th>
</tr>
</thead>
<tbody>
{reserved.map((r) => (
<tr key={r.name}>
<td>{r.name}</td>
<td>{formatDate(r.created_at)}</td>
<td>
<button
type="button"
className="btn-danger btn-sm"
disabled={busy}
onClick={() => remove(r.name)}
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card">
<h2>Built-in reserved</h2>
<p className="muted">
These are hardcoded in the app and always reserved (cannot be removed
here).
</p>
<div className="row" style={{ flexWrap: 'wrap', gap: 6 }}>
{hardcoded.map((h) => (
<span key={h} className="badge">
{h}
</span>
))}
</div>
</div>
</div>
);
}