page.tsx136 lines · main
1import { ShieldCheckIcon } from '@/components/ui/shield-check';
2import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
3import { UsersIcon } from '@/components/ui/users';
4
5import { apiJson } from '@/lib/api';
6import { toValidDate } from '@/lib/utils';
7
8import { EmptyState } from '../_components/empty-state';
9import { Section } from '../_components/section';
10import { StatCard } from '../_components/stat-card';
11import { UserActions } from './user-actions';
12
13interface AdminUser {
14 id: string;
15 email: string;
16 name: string | null;
17 emailVerified: boolean;
18 isAdmin: boolean;
19 suspendedAt: string | null;
20 createdAt: string;
21 projectCount: number;
22}
23
24export const metadata = { title: 'users · admin' };
25export const dynamic = 'force-dynamic';
26
27function publicApiOrigin(): string {
28 return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? '';
29}
30
31export default async function AdminUsersPage() {
32 const { users } = await apiJson<{ users: AdminUser[] }>('/v1/admin/users').catch(() => ({
33 users: [] as AdminUser[],
34 }));
35 const apiOrigin = publicApiOrigin();
36
37 // Real counts derived from the fetched list — nothing invented.
38 const adminCount = users.filter((u) => u.isAdmin).length;
39 const suspendedCount = users.filter((u) => u.suspendedAt !== null).length;
40
41 return (
42 <div className="flex flex-col gap-10">
43 <header className="flex flex-col gap-2">
44 <div className="flex items-center gap-2">
45 <span className="text-[var(--color-primary)]">
46 <UsersIcon size={20} />
47 </span>
48 <h1 className="font-mono text-xl tracking-tight">users</h1>
49 </div>
50 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
51 every account on the platform — suspend, force sign-out, and admin grants live here.
52 mutations require fresh step-up auth.
53 </p>
54 </header>
55
56 {/* ── the numbers ──────────────────────────────────────────────── */}
57 <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
58 <StatCard
59 label="total users"
60 value={users.length}
61 icon={<UsersIcon size={14} />}
62 hint="non-deleted accounts"
63 />
64 <StatCard
65 label="admins"
66 value={adminCount}
67 icon={<ShieldCheckIcon size={14} />}
68 tone="primary"
69 hint="accounts with admin access"
70 />
71 <StatCard
72 label="suspended"
73 value={suspendedCount}
74 icon={<TriangleAlertIcon size={14} />}
75 tone={suspendedCount > 0 ? 'warning' : 'default'}
76 hint="currently suspended accounts"
77 />
78 </div>
79
80 {/* ── the list ─────────────────────────────────────────────────── */}
81 <Section
82 title={`all users · ${users.length.toLocaleString()}`}
83 icon={<UsersIcon size={16} />}
84 right={
85 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
86 mutations require fresh step-up auth
87 </span>
88 }
89 >
90 {users.length === 0 ? (
91 <EmptyState
92 icon={<UsersIcon size={24} />}
93 title="no users to show"
94 message="either nobody has signed up yet, or the api didn't answer — refresh to retry."
95 />
96 ) : (
97 <ul className="flex flex-col divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
98 {users.map((u) => (
99 <li
100 key={u.id}
101 className="flex flex-wrap items-start justify-between gap-4 px-6 py-4 transition-colors hover:bg-[var(--color-surface-raised)]"
102 >
103 <div className="flex flex-col gap-1">
104 <p className="flex flex-wrap items-center gap-2 font-mono text-sm">
105 <a
106 href={`/admin/users/${u.id}`}
107 className="hover:text-[var(--color-text-link)] hover:underline"
108 >
109 {u.email}
110 </a>
111 {u.isAdmin ? (
112 <span className="rounded-full bg-[var(--color-primary-subtle)] px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-primary)]">
113 admin
114 </span>
115 ) : null}
116 {u.suspendedAt ? (
117 <span className="rounded-full bg-red-400/20 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-red-400">
118 suspended
119 </span>
120 ) : null}
121 </p>
122 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
123 {u.id} · {u.projectCount} project{u.projectCount === 1 ? '' : 's'} ·{' '}
124 {u.emailVerified ? 'verified' : 'unverified'} · joined{' '}
125 {toValidDate(u.createdAt)?.toISOString().slice(0, 10) ?? '—'}
126 </p>
127 </div>
128 <UserActions user={u} apiOrigin={apiOrigin} />
129 </li>
130 ))}
131 </ul>
132 )}
133 </Section>
134 </div>
135 );
136}