// Per-user drilldown: profile, role assignment, API keys, usage + quota. // Opened from the Users tab via the row's "View" action. import { useCallback, useEffect, useState } from "react" import { CheckCircle2, Copy, KeyRound, Plus, RefreshCw, Shield, ShieldCheck, ShieldOff, Trash2, X, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client" import { AlertBanner, ConfirmDialog } from "@crema/feedback-ui" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { Sheet, SheetContent, SheetHeader, SheetTitle, } from "~/components/ui/sheet" import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs" import { createUserApiKey, listUserApiKeys, revokeUserApiKey, type ApiKey, type ApiKeyCreated, } from "~/lib/arcadia/api-keys" import { getUserQuota, getUserUsage, type UserQuota, type UserUsage, } from "~/lib/arcadia/user-stats" import { assignRole, removeRole, type User, } from "~/lib/arcadia/users" import type { Role } from "~/lib/arcadia/roles" interface Props { user: User | null roles: Role[] onClose: () => void /** Called whenever something changes that the parent may want to re-fetch. */ onChanged: () => Promise } export function UserDetailSheet({ user, roles, onClose, onChanged }: Props) { const open = user !== null return ( !o && onClose()}> {user ? ( ) : null} ) } function UserDetailBody({ user, roles, onChanged, onClose, }: { user: User roles: Role[] onChanged: () => Promise onClose: () => void }) { return ( <> {user.full_name || user.email} {user.email} {user.email_verified ? "Verified" : "Unverified"}
Overview Roles API keys
) } // --- Overview tab ------------------------------------------------------ function OverviewPanel({ user }: { user: User }) { const arcadia = useArcadiaClient() const [usage, setUsage] = useState(null) const [quota, setQuota] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { let mounted = true setLoading(true) setError(null) Promise.all([ getUserUsage(arcadia, user.id).catch((err) => { throw err }), getUserQuota(arcadia, user.id), ]) .then(([u, q]) => { if (!mounted) return setUsage(u) setQuota(q) }) .catch((err) => { if (mounted) setError(err instanceof ArcadiaError ? err.message : "Failed to load stats.") }) .finally(() => mounted && setLoading(false)) return () => { mounted = false } }, [arcadia, user.id]) return (

Storage

{error ? ( setError(null)}> {error} ) : null} {loading ? (

Loading…

) : (
{quota ? ( <> ) : ( )}
)}
) } function Stat({ label, value, mono, }: { label: string value: string mono?: boolean }) { return (
{label}
{value}
) } function formatBytes(n: number | null | undefined): string { if (n == null) return "—" const units = ["B", "KB", "MB", "GB", "TB"] let i = 0 let v = n while (v >= 1024 && i < units.length - 1) { v /= 1024 i++ } return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}` } // --- Roles tab --------------------------------------------------------- function RolesPanel({ user, roles, onChanged, }: { user: User roles: Role[] onChanged: () => Promise }) { const arcadia = useArcadiaClient() const [busy, setBusy] = useState(null) const [error, setError] = useState(null) const assigned = new Set(user.roles.map((r) => r.id)) const toggle = useCallback( async (role: Role) => { setError(null) setBusy(role.id) try { if (assigned.has(role.id)) await removeRole(arcadia, user.id, role.id) else await assignRole(arcadia, user.id, role.id) await onChanged() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Role change failed.") } finally { setBusy(null) } }, [arcadia, assigned, onChanged, user.id], ) return (
{error ? ( setError(null)}> {error} ) : null} {roles.length === 0 ? (

No roles defined yet.

) : (
    {roles.map((r) => { const has = assigned.has(r.id) return (
  • {has ? ( ) : ( )} {r.name}{" "} {r.slug} {r.description ? ( {r.description} ) : null}
  • ) })}
)}
) } // --- API keys tab ------------------------------------------------------ function ApiKeysPanel({ user, onChanged, }: { user: User onChanged: () => Promise }) { const arcadia = useArcadiaClient() const [keys, setKeys] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [revealed, setRevealed] = useState(null) const [pendingRevoke, setPendingRevoke] = useState(null) const refresh = useCallback(async () => { setLoading(true) setError(null) try { setKeys(await listUserApiKeys(arcadia, user.id)) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load keys.") } finally { setLoading(false) } }, [arcadia, user.id]) useEffect(() => { refresh() }, [refresh]) return (
{error ? ( setError(null)}> {error} ) : null}

Keys are shown in full only once, on creation. Treat them like passwords.

{loading ? (

Loading…

) : keys.length === 0 ? (

No API keys yet.

) : (
    {keys.map((k) => (
  • {k.key_prefix}… {!k.is_active ? ( revoked ) : null} {k.description ?? "(no description)"} Created {new Date(k.created_at).toLocaleDateString()} {k.last_used_at ? ` · last used ${new Date(k.last_used_at).toLocaleDateString()}` : " · never used"} {k.expires_at ? ` · expires ${new Date(k.expires_at).toLocaleDateString()}` : ""}
    {k.is_active ? ( ) : null}
  • ))}
)} setCreateOpen(false)} onCreated={async (created) => { setCreateOpen(false) setRevealed(created) await refresh() await onChanged() }} /> setRevealed(null)} /> !o && setPendingRevoke(null)} title="Revoke API key?" description={ pendingRevoke ? `Key ${pendingRevoke.key_prefix}… will stop working immediately. This cannot be undone.` : "" } confirmLabel="Revoke" variant="danger" onConfirm={async () => { if (!pendingRevoke) return try { await revokeUserApiKey(arcadia, user.id, pendingRevoke.id) setPendingRevoke(null) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Revoke failed.") setPendingRevoke(null) } }} />
) } function CreateApiKeyDialog({ open, userId, onClose, onCreated, }: { open: boolean userId: string onClose: () => void onCreated: (k: ApiKeyCreated) => Promise }) { const arcadia = useArcadiaClient() const [description, setDescription] = useState("") const [expiresAt, setExpiresAt] = useState("") const [busy, setBusy] = useState(false) const [error, setError] = useState(null) useEffect(() => { if (!open) { setDescription("") setExpiresAt("") setError(null) } }, [open]) const submit = async () => { setError(null) setBusy(true) try { const created = await createUserApiKey(arcadia, userId, { description: description || undefined, expires_at: expiresAt ? new Date(expiresAt).toISOString() : null, }) await onCreated(created) } catch (err) { setError( err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Create failed.", ) } finally { setBusy(false) } } return ( !o && onClose()}> New API key The full key value will be shown once after creation. Copy it then; we don't store it in cleartext. {error ? ( setError(null)}> {error} ) : null}
setDescription(e.target.value)} placeholder="e.g. CI deploy key" data-action="api-key-form-description" />
setExpiresAt(e.target.value)} data-action="api-key-form-expires" />
) } function RevealKeyDialog({ created, onClose, }: { created: ApiKeyCreated | null onClose: () => void }) { const [copied, setCopied] = useState(false) useEffect(() => { if (!created) setCopied(false) }, [created]) if (!created) return null const copy = async () => { try { await navigator.clipboard.writeText(created.api_key) setCopied(true) } catch { // ignore } } return ( !o && onClose()}> API key created This is the only time the key will be shown. Copy and store it now.
{created.api_key}
Prefix: {created.key_prefix}… {created.expires_at ? ` · expires ${new Date(created.expires_at).toLocaleString()}` : " · never expires"}

{created.warning}

) }