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:
jules
2026-07-14 13:43:57 +10:00
parent 938143f3f5
commit 7415b40240
51 changed files with 5923 additions and 4575 deletions

View File

@@ -17,7 +17,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,
DataTable,
@@ -28,11 +29,13 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { EmptyState } from "@crema/feedback-ui"
import { FileGrid, FileList, formatBytes, type FileItem } from "@crema/file-ui"
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -102,6 +105,7 @@ type Editor =
export default function BucketsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [configs, setConfigs] = useState<StorageConfig[]>([])
const [configId, setConfigId] = useState<string>(() =>
@@ -111,8 +115,13 @@ export default function BucketsRoute() {
)
const [buckets, setBuckets] = useState<Bucket[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Two independently-loaded lists, two error slots. A dead /storage_configs
// must not read as "no buckets", and a dead /buckets must not blank the
// config picker.
const [error, setError] = useState<unknown>(null)
const [configsError, setConfigsError] = useState<unknown>(null)
const [configsLoading, setConfigsLoading] = useState(true)
const [configsReloadKey, setConfigsReloadKey] = useState(0)
const [view, setView] = useState<View>({ kind: "list" })
const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<Bucket | null>(null)
@@ -126,6 +135,8 @@ export default function BucketsRoute() {
useEffect(() => {
if (!session) return
let mounted = true
setConfigsLoading(true)
setConfigsError(null)
listStorageConfigs(arcadia)
.then((rows) => {
if (!mounted) return
@@ -142,16 +153,17 @@ export default function BucketsRoute() {
setConfigId(def?.id ?? "")
}
})
.catch((err) =>
setError(
err instanceof ArcadiaError ? err.message : "Failed to load storage configs.",
),
)
.catch((err) => {
if (mounted) setConfigsError(err)
})
.finally(() => {
if (mounted) setConfigsLoading(false)
})
return () => {
mounted = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session, arcadia])
}, [session, arcadia, configsReloadKey])
useEffect(() => {
if (configId) localStorage.setItem(SELECTED_CONFIG_KEY, configId)
@@ -167,7 +179,7 @@ export default function BucketsRoute() {
try {
setBuckets(await listBuckets(arcadia, configId))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load buckets.")
setError(err)
} finally {
setLoading(false)
}
@@ -177,6 +189,14 @@ export default function BucketsRoute() {
refresh()
}, [refresh])
// The buckets table can't do its job without a config, so a failed config
// load surfaces there too — otherwise it would read as "pick a config" with
// an empty picker and no explanation.
const retryAll = useCallback(() => {
setConfigsReloadKey((n) => n + 1)
refresh()
}, [refresh])
const summary = useMemo(
() => ({
storage_config: activeConfig
@@ -249,17 +269,6 @@ export default function BucketsRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
{view.kind === "list" ? (
<>
<Card>
@@ -317,7 +326,9 @@ export default function BucketsRoute() {
<BucketsTable
buckets={buckets}
loading={loading}
loading={loading || configsLoading}
error={error ?? configsError}
onRetry={retryAll}
hasConfig={!!configId}
onOpen={(b) => setView({ kind: "objects", bucket: b })}
onConfigure={(b) => setEditor({ kind: "configure", bucket: b })}
@@ -325,11 +336,7 @@ export default function BucketsRoute() {
/>
</>
) : (
<ObjectBrowser
storageConfigId={configId}
bucket={view.bucket}
onError={setError}
/>
<ObjectBrowser storageConfigId={configId} bucket={view.bucket} />
)}
</div>
@@ -338,12 +345,11 @@ export default function BucketsRoute() {
open={editor?.kind === "create"}
configId={configId}
onClose={() => setEditor(null)}
onCreated={async (msg) => {
onCreated={async (name) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(`Created bucket ${name}`)
}}
onError={setError}
/>
{/* Configure (versioning / CORS / policy) */}
@@ -352,10 +358,9 @@ export default function BucketsRoute() {
configId={configId}
onClose={() => setEditor(null)}
onChanged={async (msg) => {
if (msg) setInfo(msg)
await refresh()
toast.success(msg)
}}
onError={setError}
/>
{/* Delete */}
@@ -363,12 +368,11 @@ export default function BucketsRoute() {
bucket={pendingDelete}
configId={configId}
onClose={() => setPendingDelete(null)}
onDeleted={async (msg) => {
onDeleted={async (name) => {
setPendingDelete(null)
if (msg) setInfo(msg)
await refresh()
toast.success(`Deleted bucket ${name}`)
}}
onError={setError}
/>
</AppShell>
)
@@ -379,6 +383,8 @@ export default function BucketsRoute() {
function BucketsTable({
buckets,
loading,
error,
onRetry,
hasConfig,
onOpen,
onConfigure,
@@ -386,6 +392,9 @@ function BucketsTable({
}: {
buckets: Bucket[]
loading: boolean
/** Raw thrown value from either the buckets load or the configs load. */
error: unknown
onRetry: () => void
hasConfig: boolean
onOpen: (b: Bucket) => void
onConfigure: (b: Bucket) => void
@@ -525,41 +534,47 @@ function BucketsTable({
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && buckets.length === 0} label="Loading buckets…" />
{!hasConfig ? (
<EmptyState
icon={<Boxes className="size-6" />}
title="Pick a storage configuration"
description="Buckets are scoped to a credential. Add one under Storage if you don't have any yet."
className="py-12"
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRetry}
loadingLabel="Loading buckets…"
empty={
!hasConfig ? (
<EmptyState
icon={<Boxes className="size-6" />}
title="Pick a storage configuration"
description="Buckets are scoped to a credential. Add one under Storage if you don't have any yet."
className="py-12"
/>
) : (
<EmptyState
icon={<Boxes className="size-6" />}
title={search ? "No buckets match." : "No buckets in this account."}
description={search ? "Try a different search." : "Create your first bucket."}
className="py-12"
/>
)
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(b) => b.name}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && buckets.length > 0}
stickyHeader
/>
) : table.total === 0 && !loading ? (
<EmptyState
icon={<Boxes className="size-6" />}
title={search ? "No buckets match." : "No buckets in this account."}
description={search ? "Try a different search." : "Create your first bucket."}
className="py-12"
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(b) => b.name}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && buckets.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
)
@@ -570,23 +585,25 @@ function BucketsTable({
function ObjectBrowser({
storageConfigId,
bucket,
onError,
}: {
storageConfigId: string
bucket: Bucket
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [objects, setObjects] = useState<BucketObject[]>([])
const [prefix, setPrefix] = useState("")
const [loading, setLoading] = useState(true)
// The object listing owns its failure: a 403 on this bucket must not read as
// "Empty bucket."
const [error, setError] = useState<unknown>(null)
const [layout, setLayout] = useState<"grid" | "list">("list")
const [previewUrl, setPreviewUrl] = useState<{ url: string; key: string } | null>(null)
const [search, setSearch] = useState("")
const refresh = useCallback(async () => {
setLoading(true)
onError(null)
setError(null)
try {
const res = await listObjects(arcadia, {
storage_config_id: storageConfigId,
@@ -596,11 +613,11 @@ function ObjectBrowser({
})
setObjects(res.objects ?? [])
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Failed to load objects.")
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, storageConfigId, bucket.name, prefix, onError])
}, [arcadia, storageConfigId, bucket.name, prefix])
useEffect(() => {
refresh()
@@ -635,10 +652,10 @@ function ObjectBrowser({
})
setPreviewUrl({ url: res.url, key })
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Presign failed.")
toast.error(errorMessage(err, `sign a link for ${key}`))
}
},
[arcadia, storageConfigId, bucket.name, onError],
[arcadia, storageConfigId, bucket.name, toast],
)
return (
@@ -697,44 +714,52 @@ function ObjectBrowser({
</CardHeader>
<CardContent className="relative p-4">
<LoadingOverlay active={loading && objects.length === 0} label="Loading objects…" />
{fileItems.length === 0 && !loading ? (
<EmptyState
icon={<FolderOpen className="size-6" />}
title={search || prefix ? "No matches." : "Empty bucket."}
description={
search || prefix
? "Adjust the filter or prefix."
: "Upload an object via your application; this view is read-only for now."
}
className="py-12"
/>
) : layout === "list" ? (
<FileList
files={fileItems}
onItemClick={(f) => openPresigned(f.id)}
renderAction={(f) => (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
openPresigned(f.id)
}}
data-action={`object-${f.id}-presign`}
>
<ExternalLink className="size-3.5" />
Link
</Button>
)}
/>
) : (
<FileGrid
files={fileItems}
onItemClick={(f) => openPresigned(f.id)}
minItemWidth={180}
/>
)}
<DataState
loading={loading}
error={error}
isEmpty={fileItems.length === 0}
onRetry={refresh}
loadingLabel="Loading objects…"
empty={
<EmptyState
icon={<FolderOpen className="size-6" />}
title={search || prefix ? "No matches." : "Empty bucket."}
description={
search || prefix
? "Adjust the filter or prefix."
: "Upload an object via your application; this view is read-only for now."
}
className="py-12"
/>
}
>
{layout === "list" ? (
<FileList
files={fileItems}
onItemClick={(f) => openPresigned(f.id)}
renderAction={(f) => (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
openPresigned(f.id)
}}
data-action={`object-${f.id}-presign`}
>
<ExternalLink className="size-3.5" />
Link
</Button>
)}
/>
) : (
<FileGrid
files={fileItems}
onItemClick={(f) => openPresigned(f.id)}
minItemWidth={180}
/>
)}
</DataState>
</CardContent>
<PresignDialog reveal={previewUrl} onClose={() => setPreviewUrl(null)} />
@@ -812,13 +837,11 @@ function CreateBucketDialog({
configId,
onClose,
onCreated,
onError,
}: {
open: boolean
configId: string
onClose: () => void
onCreated: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onCreated: (name: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [name, setName] = useState("")
@@ -827,6 +850,10 @@ function CreateBucketDialog({
const [versioning, setVersioning] = useState(false)
const [regions, setRegions] = useState<string[]>([])
const [saving, setSaving] = useState(false)
// "Bucket names must be globally unique" is the single most likely failure
// here, and the provider's message is the only thing that explains it — so
// it renders in the dialog, next to the name the operator chose.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) {
@@ -834,8 +861,10 @@ function CreateBucketDialog({
setRegion("")
setAcl("private")
setVersioning(false)
setError(null)
return
}
setError(null)
if (configId) {
listRegions(arcadia, configId)
.then(setRegions)
@@ -844,7 +873,7 @@ function CreateBucketDialog({
}, [open, arcadia, configId])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
await createBucket(arcadia, {
@@ -854,15 +883,9 @@ function CreateBucketDialog({
acl,
versioning,
})
await onCreated(`Bucket ${name} created.`)
await onCreated(name)
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Create failed.",
)
setError(err)
} finally {
setSaving(false)
}
@@ -942,6 +965,8 @@ function CreateBucketDialog({
</div>
</div>
{error ? <DialogError error={error} context="create the bucket" /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="bucket-form-cancel">
Cancel
@@ -961,13 +986,11 @@ function ConfigureBucketDialog({
configId,
onClose,
onChanged,
onError,
}: {
state: { kind: "configure"; bucket: Bucket } | null
configId: string
onClose: () => void
onChanged: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onChanged: (msg: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [versioningOn, setVersioningOn] = useState(false)
@@ -976,38 +999,58 @@ function ConfigureBucketDialog({
const [corsRules, setCorsRules] = useState<CorsRule[]>([])
const [corsSaving, setCorsSaving] = useState(false)
const [corsLoading, setCorsLoading] = useState(false)
// The CORS load used to fail *silently* into an empty rule list — so a 500
// looked exactly like "this bucket has no CORS rules", and saving from that
// state would have wiped the real ones.
const [corsError, setCorsError] = useState<unknown>(null)
const [corsReloadKey, setCorsReloadKey] = useState(0)
const [policyText, setPolicyText] = useState("")
const [policySaving, setPolicySaving] = useState(false)
// Each section saves independently, so each failure speaks in its own tab.
const [error, setError] = useState<unknown>(null)
const [errorContext, setErrorContext] = useState("save")
const open = state !== null
useEffect(() => {
if (!open || !state) return
setCorsLoading(true)
setCorsError(null)
getCors(arcadia, configId, state.bucket.name)
.then((res) => {
setCorsRules(res?.rules ?? [])
})
.catch(() => setCorsRules([]))
.catch((err) => {
setCorsRules([])
setCorsError(err)
})
.finally(() => setCorsLoading(false))
}, [open, state, arcadia, configId])
}, [open, state, arcadia, configId, corsReloadKey])
if (!state) return null
const { bucket } = state
const fail = (err: unknown, context: string) => {
setError(err)
setErrorContext(context)
}
const saveVersioning = async () => {
setVersioningSaving(true)
onError(null)
setError(null)
try {
await configureVersioning(arcadia, {
storage_config_id: configId,
bucket_name: bucket.name,
enabled: versioningOn,
})
await onChanged(`Versioning ${versioningOn ? "enabled" : "suspended"}.`)
await onChanged(
`${versioningOn ? "Enabled" : "Suspended"} versioning on ${bucket.name}`,
)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
fail(err, `save versioning on ${bucket.name}`)
} finally {
setVersioningSaving(false)
}
@@ -1015,16 +1058,16 @@ function ConfigureBucketDialog({
const saveCors = async () => {
setCorsSaving(true)
onError(null)
setError(null)
try {
await configureCors(arcadia, {
storage_config_id: configId,
bucket_name: bucket.name,
rules: corsRules,
})
await onChanged("CORS rules saved.")
await onChanged(`Saved CORS rules on ${bucket.name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
fail(err, `save the CORS rules on ${bucket.name}`)
} finally {
setCorsSaving(false)
}
@@ -1032,23 +1075,19 @@ function ConfigureBucketDialog({
const savePolicy = async () => {
setPolicySaving(true)
onError(null)
setError(null)
try {
// Parsed here so malformed JSON never reaches the API — and so the
// SyntaxError describeError() surfaces names the real problem.
const policy = policyText.trim() === "" ? {} : JSON.parse(policyText)
await configurePolicy(arcadia, {
storage_config_id: configId,
bucket_name: bucket.name,
policy,
})
await onChanged("Bucket policy saved.")
await onChanged(`Saved the bucket policy on ${bucket.name}`)
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? `Invalid JSON or save failed: ${err.message}`
: "Save failed.",
)
fail(err, `save the bucket policy on ${bucket.name}`)
} finally {
setPolicySaving(false)
}
@@ -1112,16 +1151,22 @@ function ConfigureBucketDialog({
</TabsContent>
<TabsContent value="cors" className="pt-4">
{corsLoading ? (
<p className="py-4 text-center text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading rules…
</p>
) : (
<DataState
loading={corsLoading}
error={corsError}
isEmpty={corsRules.length === 0}
onRetry={() => setCorsReloadKey((n) => n + 1)}
loadingLabel="Loading CORS rules…"
// A bucket with no rules is a real, reachable state — CorsEditor
// already says so. It just must never be shown for a failed read.
empty={<CorsEditor rules={[]} onChange={setCorsRules} />}
>
<CorsEditor rules={corsRules} onChange={setCorsRules} />
)}
</DataState>
<div className="mt-3 flex justify-end gap-2">
<Button
variant="outline"
disabled={corsLoading || !!corsError}
onClick={() =>
setCorsRules([
...corsRules,
@@ -1140,7 +1185,9 @@ function ConfigureBucketDialog({
</Button>
<Button
onClick={saveCors}
disabled={corsSaving}
// Saving rules we never managed to read would silently wipe
// whatever is actually on the bucket.
disabled={corsSaving || corsLoading || !!corsError}
data-action="configure-cors-save"
>
{corsSaving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
@@ -1182,6 +1229,8 @@ function ConfigureBucketDialog({
</TabsContent>
</Tabs>
{error ? <DialogError error={error} context={errorContext} /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="configure-close">
Close
@@ -1303,38 +1352,38 @@ function DeleteBucketFlow({
configId,
onClose,
onDeleted,
onError,
}: {
bucket: Bucket | null
configId: string
onClose: () => void
onDeleted: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onDeleted: (name: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [code, setCode] = useState("")
const [forceEmpty, setForceEmpty] = useState(false)
const [issuingCode, setIssuingCode] = useState(false)
const [deleting, setDeleting] = useState(false)
// A refused delete (wrong code, bucket not empty) has to be readable right
// where the operator typed the code — this dialog is the whole confirmation.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!bucket) {
setCode("")
setForceEmpty(false)
}
setError(null)
}, [bucket])
const requestCode = async () => {
if (!bucket) return
setIssuingCode(true)
onError(null)
setError(null)
try {
const res = await generateConfirmationCode(arcadia, configId, bucket.name)
setCode(res.code ?? "")
} catch (err) {
onError(
err instanceof ArcadiaError ? err.message : "Failed to generate confirmation code.",
)
setError(err)
} finally {
setIssuingCode(false)
}
@@ -1343,7 +1392,7 @@ function DeleteBucketFlow({
const doDelete = async () => {
if (!bucket) return
setDeleting(true)
onError(null)
setError(null)
try {
await deleteBucket(arcadia, {
storage_config_id: configId,
@@ -1352,9 +1401,9 @@ function DeleteBucketFlow({
force_empty: forceEmpty,
dry_run: false,
})
await onDeleted(`${bucket.name} deleted.`)
await onDeleted(bucket.name)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setError(err)
} finally {
setDeleting(false)
}
@@ -1417,8 +1466,20 @@ function DeleteBucketFlow({
</div>
</div>
{error ? (
<DialogError
error={error}
context={bucket ? `delete ${bucket.name}` : "delete the bucket"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={deleting}>
<Button
variant="outline"
onClick={onClose}
disabled={deleting}
data-action="bucket-delete-cancel"
>
Cancel
</Button>
<Button
@@ -1468,3 +1529,5 @@ function guessMime(key: string): string {
}
return m[ext] ?? "application/octet-stream"
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"