fb4880a1d9
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
176 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|