page.tsx396 lines · main
1import { ActivityIcon } from '@/components/ui/activity';
2import { DatabaseIcon } from '@/components/ui/database';
3import { FoldersIcon } from '@/components/ui/folders';
4import { GlobeIcon } from '@/components/ui/globe';
5import { LayoutGridIcon } from '@/components/ui/layout-grid';
6import { ShieldCheckIcon } from '@/components/ui/shield-check';
7import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
8import { UsersIcon } from '@/components/ui/users';
9
10import { apiJson } from '@/lib/api';
11import { toValidDate } from '@/lib/utils';
12
13import { UserActions } from '../user-actions';
14
15import { EmptyState } from '../../_components/empty-state';
16import { Section } from '../../_components/section';
17import { StatCard } from '../../_components/stat-card';
18
19interface UserDetail {
20 user: {
21 id: string;
22 email: string;
23 name: string | null;
24 legalName: string | null;
25 isAdmin: boolean;
26 emailVerified: boolean;
27 suspendedAt: string | null;
28 createdAt: string;
29 timezone: string | null;
30 dateOfBirth: string | null;
31 countryOfBirth: string | null;
32 company: { name: string | null; vatId: string | null; country: string | null } | null;
33 lastSignIn: {
34 at: string;
35 ipAddress: string | null;
36 userAgent: string | null;
37 nearBy: { city: string | null; region: string | null; country: string | null } | null;
38 } | null;
39 };
40 projects: Array<{
41 id: string;
42 name: string;
43 slug: string;
44 tier: string;
45 region: string;
46 createdAt: string;
47 rows: number;
48 tables: number;
49 rowLimit: number;
50 tableLimit: number;
51 }>;
52 totals: { projectCount: number; totalRows: number; totalTables: number };
53 activity: Array<{ at: string; action: string; detail: string | null }>;
54}
55
56export const dynamic = 'force-dynamic';
57export const metadata = { title: 'user · admin' };
58
59/** Honest placeholder — anything null/empty renders "—", never a fake value. */
60function dash(v: string | null | undefined): string {
61 return v && v.length > 0 ? v : '—';
62}
63
64/** A date-ish value as a local date string, or "—" when missing/unparseable. */
65function dashDate(v: string | null | undefined): string {
66 const d = toValidDate(v);
67 return d ? d.toLocaleDateString() : '—';
68}
69
70/** pct + UsageBar copied verbatim from the storage page. */
71function pct(used: number, max: number): number {
72 if (max <= 0) return 0;
73 return Math.min(100, Math.round((used / max) * 100));
74}
75
76/**
77 * Compact usage bar. Fills proportionally to used/max and flips to the
78 * error colour once `over` is true, so an operator scanning the card
79 * spots a project at/over its cap without reading the numbers.
80 */
81function UsageBar({ used, max, over }: { used: number; max: number; over: boolean }) {
82 const filled = pct(used, max);
83 return (
84 <div className="flex items-center gap-2">
85 <div className="h-1.5 w-24 overflow-hidden rounded-full bg-[var(--color-surface-raised)]">
86 <div
87 className="h-full rounded-full"
88 style={{
89 width: `${filled}%`,
90 backgroundColor: over ? 'var(--color-error)' : 'var(--color-primary)',
91 }}
92 />
93 </div>
94 <span
95 className={
96 over
97 ? 'font-mono text-[10px] text-[var(--color-error)]'
98 : 'font-mono text-[10px] text-[var(--color-text-subtle)]'
99 }
100 >
101 {filled}%
102 </span>
103 </div>
104 );
105}
106
107/** A genuine used-count, incl. a real 0 — only "—" when the number is negative. */
108function usedNum(n: number): string {
109 return n >= 0 ? n.toLocaleString() : '—';
110}
111
112/** A limit — "—" when it's 0/unknown (there's no cap to honestly show). */
113function limitNum(n: number): string {
114 return n > 0 ? n.toLocaleString() : '—';
115}
116
117export default async function AdminUserDetailPage({
118 params,
119}: {
120 params: Promise<{ id: string }>;
121}) {
122 const { id } = await params;
123 const data = await apiJson<UserDetail>(`/v1/admin/users/${id}`).catch(() => null);
124
125 if (data === null) {
126 return (
127 <div className="flex flex-col gap-10">
128 <header className="flex flex-col gap-2">
129 <div className="flex items-center gap-2">
130 <span className="text-[var(--color-primary)]">
131 <UsersIcon size={20} />
132 </span>
133 <h1 className="font-mono text-xl tracking-tight">user</h1>
134 </div>
135 <a
136 href="/admin/users"
137 className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)] transition-colors hover:text-[var(--color-text-link)]"
138 >
139 ← all users
140 </a>
141 </header>
142 <EmptyState
143 icon={<TriangleAlertIcon size={24} />}
144 title="user not found"
145 message="either the api didn't answer or no user exists with this id — head back and pick one from the list."
146 />
147 </div>
148 );
149 }
150
151 const { user, projects, totals, activity } = data;
152 const nearBy = user.lastSignIn?.nearBy ?? null;
153
154 return (
155 <div className="flex flex-col gap-10">
156 {/* ── header ───────────────────────────────────────────────────── */}
157 <header className="flex flex-col gap-3">
158 <a
159 href="/admin/users"
160 className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)] transition-colors hover:text-[var(--color-text-link)]"
161 >
162 ← all users
163 </a>
164 <div className="flex flex-wrap items-center gap-3">
165 <span className="text-[var(--color-primary)]">
166 <UsersIcon size={20} />
167 </span>
168 <h1 className="font-mono text-xl tracking-tight">{user.email}</h1>
169 {user.isAdmin ? (
170 <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)]">
171 admin
172 </span>
173 ) : null}
174 {user.suspendedAt ? (
175 <span className="rounded-full bg-red-400/20 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-red-400">
176 suspended
177 </span>
178 ) : null}
179 {user.emailVerified ? (
180 <span className="flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-success)]">
181 <ShieldCheckIcon size={12} />
182 verified
183 </span>
184 ) : (
185 <span className="rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
186 unverified
187 </span>
188 )}
189 </div>
190 <p className="font-mono text-sm text-[var(--color-text-muted)]">
191 account detail — identity, geo-location, owned projects, and activity.
192 </p>
193 <p className="font-mono text-xs text-[var(--color-text-subtle)]">{user.id}</p>
194 <div className="pt-1">
195 <UserActions
196 user={{ id: user.id, isAdmin: user.isAdmin, suspendedAt: user.suspendedAt }}
197 apiOrigin={process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''}
198 />
199 </div>
200 </header>
201
202 {/* ── the numbers ──────────────────────────────────────────────── */}
203 <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
204 <StatCard
205 label="projects owned"
206 value={totals.projectCount}
207 icon={<FoldersIcon size={14} />}
208 hint="projects they own"
209 />
210 <StatCard
211 label="total rows"
212 value={totals.totalRows}
213 icon={<DatabaseIcon size={14} />}
214 tone="primary"
215 hint="across their projects"
216 />
217 <StatCard
218 label="total tables"
219 value={totals.totalTables}
220 icon={<LayoutGridIcon size={14} />}
221 hint="across their projects"
222 />
223 </div>
224
225 {/* ── identity ─────────────────────────────────────────────────── */}
226 <Section title="identity" icon={<LayoutGridIcon size={16} />}>
227 <div className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
228 <dl className="grid grid-cols-1 gap-x-8 gap-y-4 font-mono text-sm sm:grid-cols-2">
229 <Field label="name" value={dash(user.name)} />
230 <Field label="legal name" value={dash(user.legalName)} />
231 <Field label="company name" value={dash(user.company?.name)} />
232 <Field label="company vat" value={dash(user.company?.vatId)} />
233 <Field label="company country" value={dash(user.company?.country)} />
234 <Field label="timezone" value={dash(user.timezone)} />
235 <Field label="date of birth" value={dashDate(user.dateOfBirth)} />
236 <Field label="country of birth" value={dash(user.countryOfBirth)} />
237 <Field label="joined" value={dashDate(user.createdAt)} />
238 </dl>
239 </div>
240 </Section>
241
242 {/* ── geo · last sign-in ───────────────────────────────────────── */}
243 <Section title="geo · last sign-in" icon={<GlobeIcon size={16} />}>
244 {user.lastSignIn === null ? (
245 <EmptyState
246 icon={<GlobeIcon size={24} />}
247 title="no sign-in recorded yet"
248 message="geo-ip and device details appear here after this user next signs in."
249 />
250 ) : (
251 <div className="flex flex-col gap-6 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
252 <div className="flex items-center gap-3">
253 <span className="text-[var(--color-primary)]">
254 <GlobeIcon size={20} />
255 </span>
256 <p className="font-mono text-lg text-[var(--color-text)]">
257 {nearBy === null
258 ? 'location unknown'
259 : [dash(nearBy.city), dash(nearBy.region), dash(nearBy.country)].join(' · ')}
260 </p>
261 </div>
262 <dl className="grid grid-cols-1 gap-x-8 gap-y-4 font-mono text-sm sm:grid-cols-2">
263 <Field label="ip address" value={dash(user.lastSignIn.ipAddress)} />
264 <Field label="city" value={dash(nearBy?.city)} />
265 <Field label="region" value={dash(nearBy?.region)} />
266 <Field label="country" value={dash(nearBy?.country)} />
267 <Field
268 label="signed in at"
269 value={new Date(user.lastSignIn.at).toLocaleString()}
270 />
271 <div className="flex flex-col gap-1 sm:col-span-2">
272 <dt className="text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
273 user agent
274 </dt>
275 <dd className="break-all text-[var(--color-text)]">
276 {dash(user.lastSignIn.userAgent)}
277 </dd>
278 </div>
279 </dl>
280 </div>
281 )}
282 </Section>
283
284 {/* ── projects ─────────────────────────────────────────────────── */}
285 <Section title={`projects · ${projects.length}`} icon={<FoldersIcon size={16} />}>
286 {projects.length === 0 ? (
287 <EmptyState
288 icon={<FoldersIcon size={24} />}
289 title="no projects owned"
290 message="this user doesn't own any projects yet."
291 />
292 ) : (
293 <div className="flex flex-col gap-4">
294 {projects.map((p) => (
295 <div
296 key={p.id}
297 className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
298 >
299 <div className="flex flex-wrap items-start justify-between gap-3">
300 <div className="flex flex-wrap items-center gap-2">
301 <span className="font-mono text-sm text-[var(--color-text)]">{p.name}</span>
302 <span className="rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
303 {p.tier}
304 </span>
305 </div>
306 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
307 {p.region} · {dashDate(p.createdAt)}
308 </span>
309 </div>
310 <p className="mt-1 font-mono text-[10px] text-[var(--color-text-subtle)]">
311 {p.slug} · {p.id}
312 </p>
313 <div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2">
314 <div className="flex flex-col gap-2">
315 <p className="flex items-baseline justify-between font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
316 <span>rows</span>
317 <span className="text-[var(--color-text-muted)]">
318 {usedNum(p.rows)} / {limitNum(p.rowLimit)}
319 </span>
320 </p>
321 <UsageBar
322 used={p.rows}
323 max={p.rowLimit}
324 over={p.rows >= p.rowLimit && p.rowLimit > 0}
325 />
326 </div>
327 <div className="flex flex-col gap-2">
328 <p className="flex items-baseline justify-between font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
329 <span>tables</span>
330 <span className="text-[var(--color-text-muted)]">
331 {usedNum(p.tables)} / {limitNum(p.tableLimit)}
332 </span>
333 </p>
334 <UsageBar
335 used={p.tables}
336 max={p.tableLimit}
337 over={p.tables >= p.tableLimit && p.tableLimit > 0}
338 />
339 </div>
340 </div>
341 </div>
342 ))}
343 </div>
344 )}
345 </Section>
346
347 {/* ── activity ─────────────────────────────────────────────────── */}
348 <Section title="activity" icon={<ActivityIcon size={16} />}>
349 {activity.length === 0 ? (
350 <EmptyState
351 icon={<ActivityIcon size={24} />}
352 title="no activity recorded yet"
353 message="actions on this account appear here over time."
354 />
355 ) : (
356 <div className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 sm:p-8">
357 <ol className="relative ml-1 flex flex-col gap-7 border-l border-[var(--color-border-subtle)] pl-7">
358 {activity.map((a, i) => (
359 <li key={`${a.at}-${i}`} className="relative flex flex-col gap-1">
360 <span
361 aria-hidden
362 className="absolute -left-[33px] top-[3px] size-2.5 rounded-full border-2 border-[var(--color-surface)] bg-[var(--color-primary)]"
363 />
364 <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 font-mono text-xs">
365 <span className="text-[var(--color-text)]">{a.action}</span>
366 {a.detail ? (
367 <span className="text-[var(--color-text-muted)]">{a.detail}</span>
368 ) : null}
369 </div>
370 <time
371 className="font-mono text-[10px] text-[var(--color-text-subtle)]"
372 dateTime={a.at}
373 >
374 {new Date(a.at).toLocaleString()}
375 </time>
376 </li>
377 ))}
378 </ol>
379 </div>
380 )}
381 </Section>
382 </div>
383 );
384}
385
386/** One identity/geo definition-list field: uppercase mono label + value. */
387function Field({ label, value }: { label: string; value: string }) {
388 return (
389 <div className="flex flex-col gap-1">
390 <dt className="text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
391 {label}
392 </dt>
393 <dd className="text-[var(--color-text)]">{value}</dd>
394 </div>
395 );
396}