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>
680 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|