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
180 lines
5.2 KiB
TypeScript
180 lines
5.2 KiB
TypeScript
import { notFound } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
|
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
|
import { isUuid } from '@/lib/admin/validators';
|
|
import { formatBytes, formatDate } from '@/lib/format';
|
|
import { UserActions } from './user-actions';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
type TunnelRow = {
|
|
id: string;
|
|
subdomain: string;
|
|
is_active: boolean;
|
|
bytes_used: number;
|
|
quota_bytes: number;
|
|
last_seen_at: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
type AuditRow = {
|
|
id: number;
|
|
actor_email: string | null;
|
|
action: string;
|
|
target_type: string | null;
|
|
target_id: string | null;
|
|
details: Record<string, unknown>;
|
|
created_at: string;
|
|
};
|
|
|
|
export default async function AdminUserDetailPage({
|
|
params,
|
|
}: {
|
|
params: { id: string };
|
|
}) {
|
|
if (!isUuid(params.id)) notFound();
|
|
|
|
const admin = getSupabaseAdmin();
|
|
const supabase = createSupabaseServerClient();
|
|
const {
|
|
data: { user: currentUser },
|
|
} = await supabase.auth.getUser();
|
|
|
|
const { data: userRes, error } = await admin.auth.admin.getUserById(
|
|
params.id,
|
|
);
|
|
if (error || !userRes.user) notFound();
|
|
const u = userRes.user;
|
|
const role = (u.app_metadata?.role as string | undefined) ?? 'user';
|
|
const bannedUntil =
|
|
(u as unknown as { banned_until?: string | null }).banned_until ?? null;
|
|
const banned = !!bannedUntil && new Date(bannedUntil).getTime() > Date.now();
|
|
|
|
const { data: tunnel } = await admin
|
|
.from('tunnels')
|
|
.select(
|
|
'id, subdomain, is_active, bytes_used, quota_bytes, last_seen_at, created_at',
|
|
)
|
|
.eq('user_id', params.id)
|
|
.maybeSingle<TunnelRow>();
|
|
|
|
const { data: audit } = await admin
|
|
.from('admin_audit_log')
|
|
.select(
|
|
'id, actor_email, action, target_type, target_id, details, created_at',
|
|
)
|
|
.eq('target_id', params.id)
|
|
.order('created_at', { ascending: false })
|
|
.limit(25);
|
|
|
|
const isSelf = currentUser?.id === params.id;
|
|
|
|
return (
|
|
<div>
|
|
<p className="muted">
|
|
<Link href="/admin/users">← Users</Link>
|
|
</p>
|
|
<h1 style={{ wordBreak: 'break-all' }}>{u.email ?? u.id}</h1>
|
|
|
|
<div className="card">
|
|
<h2>Account</h2>
|
|
<div className="kv">
|
|
<div className="k">User ID</div>
|
|
<div style={{ wordBreak: 'break-all' }}>{u.id}</div>
|
|
<div className="k">Role</div>
|
|
<div>
|
|
{role === 'admin' ? (
|
|
<span className="badge badge-admin">admin</span>
|
|
) : (
|
|
<span className="badge">user</span>
|
|
)}
|
|
</div>
|
|
<div className="k">Status</div>
|
|
<div>
|
|
{banned ? (
|
|
<span className="badge badge-banned">banned</span>
|
|
) : u.email_confirmed_at ? (
|
|
<span className="badge badge-ok">confirmed</span>
|
|
) : (
|
|
<span className="badge">unconfirmed</span>
|
|
)}
|
|
</div>
|
|
<div className="k">Created</div>
|
|
<div>{formatDate(u.created_at)}</div>
|
|
<div className="k">Last sign-in</div>
|
|
<div>{formatDate(u.last_sign_in_at)}</div>
|
|
</div>
|
|
|
|
<UserActions
|
|
userId={u.id}
|
|
role={role}
|
|
banned={banned}
|
|
isSelf={isSelf}
|
|
/>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h2>Tunnel</h2>
|
|
{tunnel ? (
|
|
<div className="kv">
|
|
<div className="k">Subdomain</div>
|
|
<div>{tunnel.subdomain}.linumiq.net</div>
|
|
<div className="k">Status</div>
|
|
<div>{tunnel.is_active ? 'Active' : 'Inactive'}</div>
|
|
<div className="k">Usage</div>
|
|
<div>
|
|
{formatBytes(tunnel.bytes_used)} /{' '}
|
|
{formatBytes(tunnel.quota_bytes)}
|
|
</div>
|
|
<div className="k">Last seen</div>
|
|
<div>{formatDate(tunnel.last_seen_at)}</div>
|
|
<div className="k">Created</div>
|
|
<div>{formatDate(tunnel.created_at)}</div>
|
|
<div className="k">Manage</div>
|
|
<div>
|
|
<Link href="/admin/tunnels">Go to tunnels →</Link>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="muted">No tunnel claimed.</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h2>Audit history</h2>
|
|
{audit && audit.length > 0 ? (
|
|
<div className="admin-table-wrap">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>When</th>
|
|
<th>Action</th>
|
|
<th>By</th>
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(audit as AuditRow[]).map((a) => (
|
|
<tr key={a.id}>
|
|
<td>{formatDate(a.created_at)}</td>
|
|
<td>{a.action}</td>
|
|
<td>{a.actor_email ?? '—'}</td>
|
|
<td>
|
|
<code className="muted">
|
|
{JSON.stringify(a.details ?? {})}
|
|
</code>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p className="muted">No audit entries.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|