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
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user