Files
arcadia-admin/app/components/users/user-detail-sheet.tsx
jules ab116f8465 refactor: rename @crema/arcadia-client → @crema/arcadia-core-client
Disambiguates the Phoenix/auth client lib from lib-arcadia-agents-client.
Dir lib-arcadia-client → lib-arcadia-core-client; alias updated in
tsconfig paths, vite config, app.css @source, imports, CI and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:31:56 +10:00

680 lines
20 KiB
TypeScript

// 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<void>
}
export function UserDetailSheet({ user, roles, onClose, onChanged }: Props) {
const open = user !== null
return (
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col gap-0 p-0">
{user ? (
<UserDetailBody user={user} roles={roles} onChanged={onChanged} onClose={onClose} />
) : null}
</SheetContent>
</Sheet>
)
}
function UserDetailBody({
user,
roles,
onChanged,
onClose,
}: {
user: User
roles: Role[]
onChanged: () => Promise<void>
onClose: () => void
}) {
return (
<>
<SheetHeader className="border-b px-6 py-4">
<SheetTitle className="flex items-center justify-between gap-3">
<span className="flex flex-col">
<span>{user.full_name || user.email}</span>
<span className="text-xs font-normal text-muted-foreground">{user.email}</span>
</span>
<Badge variant={user.email_verified ? "default" : "secondary"}>
{user.email_verified ? "Verified" : "Unverified"}
</Badge>
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview" data-action={`user-${user.id}-detail-overview`}>
Overview
</TabsTrigger>
<TabsTrigger value="roles" data-action={`user-${user.id}-detail-roles`}>
Roles
</TabsTrigger>
<TabsTrigger value="api-keys" data-action={`user-${user.id}-detail-keys`}>
API keys
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewPanel user={user} />
</TabsContent>
<TabsContent value="roles">
<RolesPanel user={user} roles={roles} onChanged={onChanged} />
</TabsContent>
<TabsContent value="api-keys">
<ApiKeysPanel user={user} onChanged={onChanged} />
</TabsContent>
</Tabs>
</div>
<div className="border-t px-6 py-3 flex justify-end">
<Button variant="outline" onClick={onClose} data-action={`user-${user.id}-detail-close`}>
Close
</Button>
</div>
</>
)
}
// --- Overview tab ------------------------------------------------------
function OverviewPanel({ user }: { user: User }) {
const arcadia = useArcadiaClient()
const [usage, setUsage] = useState<UserUsage | null>(null)
const [quota, setQuota] = useState<UserQuota | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-4 pt-4">
<dl className="grid grid-cols-2 gap-3 text-sm">
<Stat label="Status" value={user.status} />
<Stat
label="Email verified"
value={user.email_verified ? "Yes" : "No"}
/>
<Stat
label="Last sign-in"
value={user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString() : "Never"}
/>
<Stat label="Created" value={new Date(user.inserted_at).toLocaleString()} />
<Stat label="Tenant" value={user.tenant_id} mono />
<Stat label="ID" value={user.id} mono />
</dl>
<h3 className="mt-2 text-sm font-semibold">Storage</h3>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{loading ? (
<p className="text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
) : (
<div className="grid grid-cols-2 gap-3 text-sm">
<Stat
label="Storage used"
value={
usage
? formatBytes(usage.storage_used_bytes) +
(quota?.storage_limit_bytes
? ` / ${formatBytes(quota.storage_limit_bytes)}`
: "")
: "—"
}
/>
<Stat
label="Object count"
value={
usage
? `${usage.object_count}` +
(quota?.object_count_limit ? ` / ${quota.object_count_limit}` : "")
: "—"
}
/>
{quota ? (
<>
<Stat
label="Storage usage"
value={
quota.storage_usage_percentage != null
? `${Math.round(quota.storage_usage_percentage)}%`
: "—"
}
/>
<Stat
label="Quota state"
value={quota.quota_exceeded ? "exceeded" : "ok"}
/>
</>
) : (
<Stat label="Quota" value="No quota set" />
)}
</div>
)}
</div>
)
}
function Stat({
label,
value,
mono,
}: {
label: string
value: string
mono?: boolean
}) {
return (
<div className="flex flex-col gap-0.5 rounded-md border bg-card/50 px-3 py-2">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className={mono ? "truncate font-mono text-xs" : "text-sm"}>{value}</dd>
</div>
)
}
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<void>
}) {
const arcadia = useArcadiaClient()
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-3 pt-4">
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{roles.length === 0 ? (
<p className="text-sm text-muted-foreground">No roles defined yet.</p>
) : (
<ul className="flex flex-col divide-y rounded-md border">
{roles.map((r) => {
const has = assigned.has(r.id)
return (
<li
key={r.id}
className="flex items-start justify-between gap-3 px-3 py-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-2 text-sm font-medium">
{has ? (
<ShieldCheck className="size-4 text-emerald-500" />
) : (
<Shield className="size-4 text-muted-foreground" />
)}
{r.name}{" "}
<code className="rounded bg-muted px-1 font-mono text-xs">{r.slug}</code>
</span>
{r.description ? (
<span className="text-xs text-muted-foreground">{r.description}</span>
) : null}
</div>
<Button
variant={has ? "outline" : "default"}
size="sm"
disabled={busy !== null}
onClick={() => toggle(r)}
data-action={`user-${user.id}-role-${r.slug}-${has ? "remove" : "add"}`}
>
{busy === r.id ? (
<RefreshCw className="size-3.5 animate-spin" />
) : has ? (
<ShieldOff className="size-3.5" />
) : (
<Plus className="size-3.5" />
)}
{has ? "Remove" : "Add"}
</Button>
</li>
)
})}
</ul>
)}
</div>
)
}
// --- API keys tab ------------------------------------------------------
function ApiKeysPanel({
user,
onChanged,
}: {
user: User
onChanged: () => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [keys, setKeys] = useState<ApiKey[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [revealed, setRevealed] = useState<ApiKeyCreated | null>(null)
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(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 (
<div className="flex flex-col gap-3 pt-4">
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Keys are shown in full <strong>only once</strong>, on creation. Treat them like
passwords.
</p>
<Button
size="sm"
onClick={() => setCreateOpen(true)}
data-action={`user-${user.id}-api-key-create`}
>
<Plus className="size-4" />
New key
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
) : keys.length === 0 ? (
<p className="rounded-md border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
No API keys yet.
</p>
) : (
<ul className="flex flex-col divide-y rounded-md border">
{keys.map((k) => (
<li key={k.id} className="flex items-start justify-between gap-3 px-3 py-2">
<div className="flex flex-col">
<span className="flex items-center gap-2 font-mono text-xs">
<KeyRound className="size-3.5 text-muted-foreground" />
{k.key_prefix}
{!k.is_active ? (
<Badge variant="secondary" className="ml-1">
revoked
</Badge>
) : null}
</span>
<span className="text-xs text-muted-foreground">
{k.description ?? "(no description)"}
</span>
<span className="text-[11px] text-muted-foreground">
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()}`
: ""}
</span>
</div>
{k.is_active ? (
<Button
variant="ghost"
size="sm"
onClick={() => setPendingRevoke(k)}
data-action={`user-${user.id}-api-key-${k.id}-revoke`}
>
<Trash2 className="size-3.5" />
Revoke
</Button>
) : null}
</li>
))}
</ul>
)}
<CreateApiKeyDialog
open={createOpen}
userId={user.id}
onClose={() => setCreateOpen(false)}
onCreated={async (created) => {
setCreateOpen(false)
setRevealed(created)
await refresh()
await onChanged()
}}
/>
<RevealKeyDialog created={revealed} onClose={() => setRevealed(null)} />
<ConfirmDialog
open={pendingRevoke !== null}
onOpenChange={(o) => !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)
}
}}
/>
</div>
)
}
function CreateApiKeyDialog({
open,
userId,
onClose,
onCreated,
}: {
open: boolean
userId: string
onClose: () => void
onCreated: (k: ApiKeyCreated) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [description, setDescription] = useState("")
const [expiresAt, setExpiresAt] = useState("")
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New API key</DialogTitle>
<DialogDescription>
The full key value will be shown once after creation. Copy it then; we don't store it
in cleartext.
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="apikey-description">Description</Label>
<Input
id="apikey-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. CI deploy key"
data-action="api-key-form-description"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="apikey-expires">Expires at (optional)</Label>
<Input
id="apikey-expires"
type="datetime-local"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
data-action="api-key-form-expires"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button onClick={submit} disabled={busy} data-action="api-key-form-create">
{busy ? <RefreshCw className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
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 (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="size-5 text-emerald-500" />
API key created
</DialogTitle>
<DialogDescription>
<strong>This is the only time the key will be shown.</strong> Copy and store it now.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
<code className="select-all break-all font-mono text-xs">{created.api_key}</code>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Prefix: <code className="font-mono">{created.key_prefix}</code>
{created.expires_at
? ` · expires ${new Date(created.expires_at).toLocaleString()}`
: " · never expires"}
</span>
<Button size="sm" variant="outline" onClick={copy} data-action="api-key-reveal-copy">
{copied ? <CheckCircle2 className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">{created.warning}</p>
<DialogFooter>
<Button onClick={onClose} data-action="api-key-reveal-close">
<X className="size-4" />
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}