Files
linumiq_net-web_app/app/admin/users/[id]/page.tsx
T

181 lines
5.3 KiB
TypeScript

import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { withAdminRetry } from '@/lib/admin/retry';
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';
export const revalidate = 0;
type TunnelRow = {
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 withAdminRetry(() =>
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(
'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>
);
}