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:
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"
|
||||
import { Pause, Play, Plus, RefreshCw } from "lucide-react"
|
||||
import { Link, useNavigate } from "react-router"
|
||||
import { Pause, Play, Plus, RefreshCw, Settings2 } 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,
|
||||
@@ -14,10 +16,12 @@ 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 { PageHeader } from "~/components/layout/page-header"
|
||||
import { errorMessage } from "~/lib/errors"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -59,10 +63,14 @@ type PendingAction = {
|
||||
export default function TenantsRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// The raw thrown value — `DataState` normalises it into plain language. We
|
||||
// deliberately don't stringify here; the status code carries the meaning.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [pending, setPending] = useState<PendingAction>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
@@ -74,7 +82,7 @@ export default function TenantsRoute() {
|
||||
const list = await listTenants(arcadia)
|
||||
setTenants(list)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load tenants.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -87,17 +95,19 @@ export default function TenantsRoute() {
|
||||
const runAction = useCallback(
|
||||
async (action: PendingAction) => {
|
||||
if (!action) return
|
||||
const verb = action.kind === "suspend" ? "Suspended" : "Deactivated"
|
||||
try {
|
||||
if (action.kind === "suspend") await suspendTenant(arcadia, action.tenant.id)
|
||||
else await deactivateTenant(arcadia, action.tenant.id)
|
||||
setPending(null)
|
||||
await refresh()
|
||||
toast.success(`${verb} ${action.tenant.name}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Action failed.")
|
||||
setPending(null)
|
||||
toast.error(errorMessage(err, `${action.kind} ${action.tenant.name}`))
|
||||
}
|
||||
},
|
||||
[arcadia, refresh],
|
||||
[arcadia, refresh, toast],
|
||||
)
|
||||
|
||||
const columns = useMemo<Column<Tenant>[]>(
|
||||
@@ -107,7 +117,15 @@ export default function TenantsRoute() {
|
||||
header: "Name",
|
||||
accessor: "name",
|
||||
sortable: true,
|
||||
cell: (t) => <span className="font-medium">{t.name}</span>,
|
||||
cell: (t) => (
|
||||
<Link
|
||||
to={`/tenants/${t.id}`}
|
||||
className="font-medium hover:underline"
|
||||
data-action={`tenant-${t.slug}-open`}
|
||||
>
|
||||
{t.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "slug",
|
||||
@@ -145,13 +163,13 @@ export default function TenantsRoute() {
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<ActionsCell
|
||||
items={rowActions(t, arcadia, refresh, setPending, setError)}
|
||||
items={rowActions(t, arcadia, refresh, setPending, toast, navigate)}
|
||||
triggerDataAction={`tenant-${t.slug}-actions`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[arcadia, refresh],
|
||||
[arcadia, refresh, toast, navigate],
|
||||
)
|
||||
|
||||
const tenantSummary = useMemo(
|
||||
@@ -215,12 +233,6 @@ export default function TenantsRoute() {
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<SearchInput
|
||||
@@ -236,46 +248,56 @@ export default function TenantsRoute() {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay active={loading && tenants.length === 0} label="Loading tenants…" />
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
title={search ? "No tenants match that search." : "No tenants yet."}
|
||||
description={
|
||||
search ? "Try a different name, slug, or status." : "Create your first tenant to get started."
|
||||
}
|
||||
className="py-12"
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={table.total === 0}
|
||||
onRetry={refresh}
|
||||
loadingLabel="Loading tenants…"
|
||||
empty={
|
||||
<EmptyState
|
||||
title={search ? "No tenants match that search." : "No tenants yet."}
|
||||
description={
|
||||
search
|
||||
? "Try a different name, slug, or status."
|
||||
: "Create your first tenant to get started."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(t) => t.id}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && tenants.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(t) => t.id}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && tenants.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>
|
||||
|
||||
<TenantCreateDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={async () => {
|
||||
onCreated={async (tenant, adminEmail) => {
|
||||
setCreateOpen(false)
|
||||
await refresh()
|
||||
// The money moment. Say what happened and what the operator can do
|
||||
// next — previously the dialog just closed with no confirmation at all.
|
||||
toast.success(`Tenant "${tenant.name}" created`, {
|
||||
description: `${adminEmail} can now sign in with tenant ID "${tenant.slug}".`,
|
||||
})
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={pending?.kind === "suspend"}
|
||||
@@ -296,7 +318,7 @@ export default function TenantsRoute() {
|
||||
title="Deactivate tenant?"
|
||||
description={
|
||||
pending
|
||||
? `${pending.tenant.name} will be deactivated. This is more severe than suspending.`
|
||||
? `${pending.tenant.name} will be taken offline: nobody can sign in, and its apps stop serving. Its data is kept, and you can reactivate it from this table. Suspend instead if this is temporary.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Deactivate"
|
||||
@@ -319,9 +341,18 @@ function rowActions(
|
||||
arcadia: ReturnType<typeof useArcadiaClient>,
|
||||
refresh: () => Promise<void>,
|
||||
setPending: (p: PendingAction) => void,
|
||||
setError: (msg: string | null) => void,
|
||||
toast: ReturnType<typeof useToast>,
|
||||
navigate: (to: string) => void,
|
||||
): ActionItem[] {
|
||||
const items: ActionItem[] = []
|
||||
const items: ActionItem[] = [
|
||||
{
|
||||
id: "manage",
|
||||
label: "Manage",
|
||||
icon: <Settings2 className="size-4" />,
|
||||
dataAction: `tenant-${t.slug}-manage`,
|
||||
onSelect: () => navigate(`/tenants/${t.id}`),
|
||||
},
|
||||
]
|
||||
if (t.status === "active") {
|
||||
items.push({
|
||||
id: "suspend",
|
||||
@@ -340,8 +371,9 @@ function rowActions(
|
||||
try {
|
||||
await activateTenant(arcadia, t.id)
|
||||
await refresh()
|
||||
toast.success(`Activated ${t.name}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
|
||||
toast.error(errorMessage(err, `activate ${t.name}`))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -356,29 +388,6 @@ function rowActions(
|
||||
return items
|
||||
}
|
||||
|
||||
function formatArcadiaError(err: unknown, fallback: string): string {
|
||||
if (!(err instanceof ArcadiaError)) return fallback
|
||||
// 422 validation errors carry per-field reasons in `details`. Shape from
|
||||
// Ecto's FallbackController is typically `{ field: ["msg1", "msg2"] }` or
|
||||
// nested `{ tenant: { slug: ["has already been taken"] } }`. Flatten so
|
||||
// the user sees what to fix instead of a generic "validation failed".
|
||||
if (err.isValidation && err.details) {
|
||||
const lines: string[] = []
|
||||
const walk = (obj: unknown, prefix: string) => {
|
||||
if (Array.isArray(obj)) {
|
||||
lines.push(`${prefix}: ${obj.join(", ")}`)
|
||||
} else if (obj && typeof obj === "object") {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
walk(v, prefix ? `${prefix}.${k}` : k)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(err.details, "")
|
||||
if (lines.length) return `${err.message} — ${lines.join("; ")}`
|
||||
}
|
||||
return err.message
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
@@ -391,12 +400,10 @@ function TenantCreateDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void> | void
|
||||
onError: (msg: string) => void
|
||||
onCreated: (tenant: Tenant, adminEmail: string) => Promise<void> | void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [name, setName] = useState("")
|
||||
@@ -407,6 +414,11 @@ function TenantCreateDialog({
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// Errors belong to the dialog, not the page. They used to be hoisted to a
|
||||
// page-level banner that rendered *behind* the modal scrim — dimmed, above
|
||||
// the fold, and unreadable — so a failed provision looked like nothing
|
||||
// happened at all.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -418,6 +430,7 @@ function TenantCreateDialog({
|
||||
setEmail("")
|
||||
setPassword("")
|
||||
setSubmitting(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
@@ -436,8 +449,9 @@ function TenantCreateDialog({
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await provisionTenant(arcadia, {
|
||||
const tenant = await provisionTenant(arcadia, {
|
||||
tenant: { name: name.trim(), slug },
|
||||
admin_user: {
|
||||
email: email.trim(),
|
||||
@@ -446,9 +460,11 @@ function TenantCreateDialog({
|
||||
last_name: lastName.trim(),
|
||||
},
|
||||
})
|
||||
await onCreated()
|
||||
await onCreated(tenant, email.trim())
|
||||
} catch (err) {
|
||||
onError(formatArcadiaError(err, "Failed to create tenant."))
|
||||
// Keep the dialog open with the form intact so the operator can fix and
|
||||
// resubmit without retyping.
|
||||
setError(err)
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
@@ -460,7 +476,8 @@ function TenantCreateDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>New tenant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Provisions the tenant with default roles, quotas, and an initial admin user.
|
||||
Creates the tenant with its system roles and an initial admin user who can
|
||||
sign in straight away.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -547,6 +564,8 @@ function TenantCreateDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <DialogError error={error} context="create the tenant" /> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -570,3 +589,5 @@ function TenantCreateDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||
|
||||
Reference in New Issue
Block a user