Add Storage, Users, Secrets, Webhooks, Scheduled tasks, Audit log screens
Full management surfaces for the platform-admin tenant, mirroring the existing Tenants pattern (DataTable + row actions + create/edit dialogs + ConfirmDialog for destructive ops, all data-action tagged for the command bus, useRegisterAdminContext publishing for the assistant). - Storage (/storage): backends + credentials. Write-only secret fields, Validate/Activate/Deactivate/Set-default/Mark-degraded/Maintenance. - Users (/users): tabs for Users, Invitations, Roles. Per-user View drawer with profile, role add/remove, API keys (one-time reveal on create), usage + quota. - Secrets (/secrets): /api/v1/admin/secrets — create/rotate/rollback, versions dialog, enable/disable, generate-value helper. - Webhooks (/webhooks): CRUD, pause/resume, regenerate-secret with one-time reveal, send test event, deliveries dialog. - Scheduled tasks (/scheduled-tasks): cron CRUD, run-now trigger, enable/disable, expandable run history. - Audit log (/activity): replaces the empty stub. Filter by severity, resource type, date range; click for full JSON detail. All endpoints are hand-rolled HTTP because most aren't covered by the generated OpenAPI typed paths yet — switch to arcadia.typed.* when the backend wires them into OpenApiSpex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
679
app/components/users/user-detail-sheet.tsx
Normal file
679
app/components/users/user-detail-sheet.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
// 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-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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user