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

@@ -3,7 +3,7 @@
// routes after a successful arcadia API exchange. The shape here matches what
// AppShell + useUser expect.
import { useEffect, useSyncExternalStore } from "react"
import { useSyncExternalStore } from "react"
import { profileInitials } from "~/lib/profile"
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
@@ -157,12 +157,34 @@ export function hasSession(): boolean {
return !!readFromStorage()
}
// `useSyncExternalStore` demands a *referentially stable* snapshot: it must
// return the identical object until the underlying value genuinely changes.
// Keying the cache on the raw stored string gives us that for free — reparse
// only when the bytes differ.
//
// This used to keep a `cacheValid` flag that `useSession` cleared on mount,
// which meant the very next render reparsed storage and produced a brand-new
// Session object. Every `useEffect([session, …])` in the app then saw a
// "changed" session and refetched: three identical GETs per list screen, and
// enough request volume during navigation to trip arcadia's own rate limiter
// and greet the operator with 429 banners. The session had not changed at all.
let cached: Session | null = null
let cacheValid = false
let cachedRaw: string | null = null
let primed = false
function readRaw(): string | null {
if (typeof window === "undefined") return null
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
function subscribe(cb: () => void): () => void {
const onChange = () => {
cacheValid = false
// Force the next getSnapshot to reparse, then let React re-render.
primed = false
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
@@ -171,23 +193,25 @@ function subscribe(cb: () => void): () => void {
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Session | null {
if (!cacheValid) {
const raw = readRaw()
if (!primed || raw !== cachedRaw) {
cachedRaw = raw
// readFromStorage re-validates expiry and may clear the token; when it
// does, `raw` differs on the next read and we reparse again.
cached = readFromStorage()
cacheValid = true
primed = true
}
return cached
}
function getServerSnapshot(): Session | null {
return null
}
export function useSession(): Session | null {
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cacheValid = false
}, [])
return s
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
export function sessionInitials(session: Session | null): string {