page.tsx396 lines · main
| 1 | import { ActivityIcon } from '@/components/ui/activity'; |
| 2 | import { DatabaseIcon } from '@/components/ui/database'; |
| 3 | import { FoldersIcon } from '@/components/ui/folders'; |
| 4 | import { GlobeIcon } from '@/components/ui/globe'; |
| 5 | import { LayoutGridIcon } from '@/components/ui/layout-grid'; |
| 6 | import { ShieldCheckIcon } from '@/components/ui/shield-check'; |
| 7 | import { TriangleAlertIcon } from '@/components/ui/triangle-alert'; |
| 8 | import { UsersIcon } from '@/components/ui/users'; |
| 9 | |
| 10 | import { apiJson } from '@/lib/api'; |
| 11 | import { toValidDate } from '@/lib/utils'; |
| 12 | |
| 13 | import { UserActions } from '../user-actions'; |
| 14 | |
| 15 | import { EmptyState } from '../../_components/empty-state'; |
| 16 | import { Section } from '../../_components/section'; |
| 17 | import { StatCard } from '../../_components/stat-card'; |
| 18 | |
| 19 | interface 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 | |
| 56 | export const dynamic = 'force-dynamic'; |
| 57 | export const metadata = { title: 'user · admin' }; |
| 58 | |
| 59 | /** Honest placeholder — anything null/empty renders "—", never a fake value. */ |
| 60 | function 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. */ |
| 65 | function 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. */ |
| 71 | function 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 | */ |
| 81 | function 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. */ |
| 108 | function 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). */ |
| 113 | function limitNum(n: number): string { |
| 114 | return n > 0 ? n.toLocaleString() : '—'; |
| 115 | } |
| 116 | |
| 117 | export 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. */ |
| 387 | function 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 | } |