Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page
From the 2026-07-14 UI/UX audit (17/40). Four phases: P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never imported). Added imports + a shared route-level error boundary (components/route-error.tsx) re-exported from all shell routes, so one crashing panel degrades to an explained card with the nav intact instead of replacing the whole app with a stack trace. Corrected the stale CLAUDE.md claim that `npm run typecheck` crashes — it works, and would have caught the missing import. P2 — Error/empty/feedback foundations. Fixed useSession identity churn that fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially stable snapshot). New lib/errors.ts (describeError → plain-language + the fix) and components/data-state.tsx (DataState renders exactly one of error/loading/empty/content, so a failed load never shows as "empty"; DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15 list routes; every mutation now toasts. Surfaced+fixed two silent-failure bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules on save). P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent sections; collapsed the Apps/Plan/Entitlements stub triplication into one honest /billing page; deleted fabricated seeded notifications and the dead Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't exist); removed orphan /assistant + /library routes; gated dev-seed login hints behind DEV; renamed /activity → /audit-log with a redirect. P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*), closing the provision→configure gap. 8 tabs (Overview, Plan & quotas, Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound webhooks), each verified saving to the real backend. Row name links to detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,8 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
import {
|
||||
ActionsCell,
|
||||
BadgeCell,
|
||||
@@ -25,9 +26,11 @@ import {
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { DataState, DialogError } from "~/components/data-state"
|
||||
import { errorMessage } from "~/lib/errors"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -91,11 +94,13 @@ type EditorState =
|
||||
export default function SecretsRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
// The raw thrown value — `DataState` normalises it into plain language. A
|
||||
// secrets list that failed to load must never read as "no secrets yet".
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [categoryFilter, setCategoryFilter] = useState<"all" | SecretCategory>("all")
|
||||
const [editor, setEditor] = useState<EditorState>(null)
|
||||
@@ -107,7 +112,7 @@ export default function SecretsRoute() {
|
||||
try {
|
||||
setSecrets(await listSecrets(arcadia))
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load secrets.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -204,15 +209,14 @@ export default function SecretsRoute() {
|
||||
refresh,
|
||||
setEditor,
|
||||
setPendingDelete,
|
||||
setError,
|
||||
setInfo,
|
||||
toast,
|
||||
})}
|
||||
triggerDataAction={`secret-${s.name}-actions`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[arcadia, refresh],
|
||||
[arcadia, refresh, toast],
|
||||
)
|
||||
|
||||
const summary = useMemo(
|
||||
@@ -279,17 +283,6 @@ export default function SecretsRoute() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center gap-3">
|
||||
<SearchInput
|
||||
@@ -321,41 +314,45 @@ export default function SecretsRoute() {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay active={loading && secrets.length === 0} label="Loading secrets…" />
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
title={
|
||||
search || categoryFilter !== "all"
|
||||
? "No secrets match those filters."
|
||||
: "No secrets yet."
|
||||
}
|
||||
description={
|
||||
search || categoryFilter !== "all"
|
||||
? "Try a different search or category."
|
||||
: "Create your first secret. The value is encrypted at rest and never returned by the API."
|
||||
}
|
||||
className="py-12"
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={table.total === 0}
|
||||
onRetry={refresh}
|
||||
loadingLabel="Loading secrets…"
|
||||
empty={
|
||||
<EmptyState
|
||||
title={
|
||||
search || categoryFilter !== "all"
|
||||
? "No secrets match those filters."
|
||||
: "No secrets yet."
|
||||
}
|
||||
description={
|
||||
search || categoryFilter !== "all"
|
||||
? "Try a different search or category."
|
||||
: "Create your first secret. The value is encrypted at rest and never returned by the API."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(s) => s.id}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && secrets.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(s) => s.id}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && secrets.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -373,14 +370,15 @@ export default function SecretsRoute() {
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
const target = pendingDelete
|
||||
try {
|
||||
await deleteSecret(arcadia, pendingDelete.id)
|
||||
await deleteSecret(arcadia, target.id)
|
||||
setPendingDelete(null)
|
||||
setInfo("Secret deleted.")
|
||||
await refresh()
|
||||
toast.success(`Deleted ${target.name}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
toast.error(errorMessage(err, `delete ${target.name}`))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -388,12 +386,13 @@ export default function SecretsRoute() {
|
||||
<SecretEditorDialog
|
||||
state={editor}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
onSaved={async (msg, opts) => {
|
||||
// The versions dialog stays open across a rollback — only the
|
||||
// create/edit/rotate flows close on success.
|
||||
if (!opts?.keepOpen) setEditor(null)
|
||||
await refresh()
|
||||
toast.success(msg)
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
@@ -420,11 +419,10 @@ function rowActions(
|
||||
refresh: () => Promise<void>
|
||||
setEditor: (e: EditorState) => void
|
||||
setPendingDelete: (s: Secret | null) => void
|
||||
setError: (m: string | null) => void
|
||||
setInfo: (m: string | null) => void
|
||||
toast: ReturnType<typeof useToast>
|
||||
},
|
||||
): ActionItem[] {
|
||||
const { arcadia, refresh, setEditor, setPendingDelete, setError, setInfo } = ctx
|
||||
const { arcadia, refresh, setEditor, setPendingDelete, toast } = ctx
|
||||
const items: ActionItem[] = []
|
||||
|
||||
items.push({
|
||||
@@ -457,10 +455,10 @@ function rowActions(
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await disableSecret(arcadia, s.id)
|
||||
setInfo(`${s.name} disabled.`)
|
||||
await refresh()
|
||||
toast.success(`Disabled ${s.name}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Disable failed.")
|
||||
toast.error(errorMessage(err, `disable ${s.name}`))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -473,10 +471,10 @@ function rowActions(
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await enableSecret(arcadia, s.id)
|
||||
setInfo(`${s.name} enabled.`)
|
||||
await refresh()
|
||||
toast.success(`Enabled ${s.name}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Enable failed.")
|
||||
toast.error(errorMessage(err, `enable ${s.name}`))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -494,41 +492,43 @@ function rowActions(
|
||||
return items
|
||||
}
|
||||
|
||||
/** What the parent does when a dialog reports success. */
|
||||
type OnSaved = (message: string, opts?: { keepOpen?: boolean }) => Promise<void>
|
||||
|
||||
function SecretEditorDialog({
|
||||
state,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: EditorState
|
||||
onClose: () => void
|
||||
onSaved: (info?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onSaved: OnSaved
|
||||
}) {
|
||||
if (state?.mode === "versions") {
|
||||
return <VersionsDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
|
||||
return <VersionsDialog state={state} onClose={onClose} onSaved={onSaved} />
|
||||
}
|
||||
if (state?.mode === "rotate") {
|
||||
return <RotateDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
|
||||
return <RotateDialog state={state} onClose={onClose} onSaved={onSaved} />
|
||||
}
|
||||
return <UpsertDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
|
||||
return <UpsertDialog state={state} onClose={onClose} onSaved={onSaved} />
|
||||
}
|
||||
|
||||
function UpsertDialog({
|
||||
state,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: EditorState
|
||||
onClose: () => void
|
||||
onSaved: (info?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onSaved: OnSaved
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state?.mode === "create" || state?.mode === "edit"
|
||||
const isEdit = state?.mode === "edit"
|
||||
const initial = isEdit ? state.secret : null
|
||||
// A failed save renders here, not on the page behind the scrim — and the
|
||||
// form keeps its state, including the value the operator just pasted.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [value, setValue] = useState("")
|
||||
@@ -545,7 +545,11 @@ function UpsertDialog({
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open) {
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
if (initial) {
|
||||
setName(initial.name)
|
||||
setValue("")
|
||||
@@ -577,18 +581,19 @@ function UpsertDialog({
|
||||
|
||||
const generate = async () => {
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const v = await generateSecretValue(arcadia, { length: 48 })
|
||||
setValue(v)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Generate failed.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const tags = csv(tagsText)
|
||||
@@ -613,7 +618,7 @@ function UpsertDialog({
|
||||
expires_at,
|
||||
rotation_interval_days,
|
||||
})
|
||||
await onSaved("Secret metadata updated.")
|
||||
await onSaved(`Updated ${initial.name}`)
|
||||
} else {
|
||||
if (!value) throw new Error("A value is required for new secrets.")
|
||||
const input: SecretCreateInput = {
|
||||
@@ -630,10 +635,10 @@ function UpsertDialog({
|
||||
rotation_interval_days,
|
||||
}
|
||||
await createSecret(arcadia, input)
|
||||
await onSaved("Secret created.")
|
||||
await onSaved(`Created ${name}`)
|
||||
}
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -812,6 +817,13 @@ function UpsertDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<DialogError
|
||||
error={error}
|
||||
context={isEdit ? "save the secret" : "create the secret"}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving} data-action="secret-form-cancel">
|
||||
Cancel
|
||||
@@ -834,43 +846,46 @@ function RotateDialog({
|
||||
state,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: { mode: "rotate"; secret: Secret }
|
||||
onClose: () => void
|
||||
onSaved: (info?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onSaved: OnSaved
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [value, setValue] = useState("")
|
||||
const [note, setNote] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
// Rotation is destructive-adjacent: if it fails, the operator must see why
|
||||
// *here*, with the new value still in the field.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setValue("")
|
||||
setNote("")
|
||||
setError(null)
|
||||
}, [state])
|
||||
|
||||
const generate = async () => {
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
try {
|
||||
setValue(await generateSecretValue(arcadia, { length: 48 }))
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Generate failed.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
await rotateSecret(arcadia, state.secret.id, { value, note: note || undefined })
|
||||
await onSaved(`${state.secret.name} rotated.`)
|
||||
await onSaved(`Rotated ${state.secret.name}`)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Rotate failed.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -924,6 +939,10 @@ function RotateDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<DialogError error={error} context={`rotate ${state.secret.name}`} />
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving} data-action="secret-rotate-cancel">
|
||||
Cancel
|
||||
@@ -942,28 +961,31 @@ function VersionsDialog({
|
||||
state,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: { mode: "versions"; secret: Secret }
|
||||
onClose: () => void
|
||||
onSaved: (info?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onSaved: OnSaved
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
const [versions, setVersions] = useState<SecretVersion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// A versions load that failed is not a secret with no history. Own error
|
||||
// state, rendered in place of the list.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [pendingRollback, setPendingRollback] = useState<SecretVersion | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
listSecretVersions(arcadia, state.secret.id)
|
||||
.then((v) => {
|
||||
if (mounted) setVersions(v.sort((a, b) => b.version - a.version))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted)
|
||||
onError(err instanceof ArcadiaError ? err.message : "Failed to load versions.")
|
||||
if (mounted) setError(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false)
|
||||
@@ -971,7 +993,7 @@ function VersionsDialog({
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [arcadia, state.secret.id, onError])
|
||||
}, [arcadia, state.secret.id, reloadKey])
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -983,15 +1005,18 @@ function VersionsDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
||||
<RefreshCw className="mr-2 size-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No previous versions yet. Rotate the value to create one.
|
||||
</p>
|
||||
) : (
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={versions.length === 0}
|
||||
onRetry={() => setReloadKey((n) => n + 1)}
|
||||
loadingLabel="Loading versions…"
|
||||
empty={
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No previous versions yet. Rotate the value to create one.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ul className="flex flex-col divide-y rounded-md border">
|
||||
{versions.map((v) => (
|
||||
<li key={v.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
@@ -1015,7 +1040,7 @@ function VersionsDialog({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DataState>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} data-action="secret-versions-close">
|
||||
@@ -1036,13 +1061,25 @@ function VersionsDialog({
|
||||
variant="default"
|
||||
onConfirm={async () => {
|
||||
if (!pendingRollback) return
|
||||
const target = pendingRollback
|
||||
try {
|
||||
await rollbackSecret(arcadia, state.secret.id, pendingRollback.version)
|
||||
await rollbackSecret(arcadia, state.secret.id, target.version)
|
||||
setPendingRollback(null)
|
||||
await onSaved(`Rolled back to version ${pendingRollback.version}.`)
|
||||
setReloadKey((n) => n + 1)
|
||||
// Keep the versions dialog open — the rollback minted a new
|
||||
// version, and the operator is looking right at the list.
|
||||
await onSaved(
|
||||
`Rolled ${state.secret.name} back to version ${target.version}`,
|
||||
{ keepOpen: true },
|
||||
)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Rollback failed.")
|
||||
setPendingRollback(null)
|
||||
toast.error(
|
||||
errorMessage(
|
||||
err,
|
||||
`roll ${state.secret.name} back to version ${target.version}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1065,3 +1102,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||
|
||||
Reference in New Issue
Block a user