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

@@ -6,7 +6,7 @@
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
// and drop these manual types.
import type { ArcadiaClient } from "@crema/arcadia-core-client"
import { ArcadiaError, type ArcadiaClient } from "@crema/arcadia-core-client"
export type TenantStatus = "active" | "suspended" | "deactivated" | string
@@ -111,3 +111,427 @@ export async function provisionTenant(
})
return res.data
}
// ---------------------------------------------------------------------------
// Tenant detail — one screen per tab, all against /admin/tenants/:id/*.
// Enum values mirror arcadia-core's Tenant schema (tenant.ex) so the pickers
// only offer values the server will accept.
// ---------------------------------------------------------------------------
export const TENANT_PLANS = ["free", "starter", "professional", "enterprise", "custom"]
export const TENANT_LOCALES = ["en", "es", "fr", "de", "pt", "ja", "zh"]
export const TENANT_CURRENCIES = ["USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CNY"]
export const EMAIL_PROVIDERS = ["smtp", "sendgrid", "mailgun", "ses"] as const
export const SMS_PROVIDERS = ["twilio", "vonage", "messagebird"] as const
// A curated subset — the server accepts any IANA name, but a full 400-entry
// list is worse UX than the ones operators actually pick.
export const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Singapore",
"Australia/Sydney",
"Pacific/Auckland",
]
/** Rename the tenant. */
export async function updateTenant(
arcadia: ArcadiaClient,
id: string,
tenant: { name?: string },
): Promise<Tenant> {
const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}`, {
body: { tenant },
})
return res.data
}
export async function updateBranding(
arcadia: ArcadiaClient,
id: string,
branding: Partial<Omit<TenantBranding, "settings">>,
): Promise<Tenant> {
const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/branding`, {
body: { branding },
})
return res.data
}
export async function updateLocalization(
arcadia: ArcadiaClient,
id: string,
localization: Partial<Omit<TenantLocalization, "settings">>,
): Promise<Tenant> {
const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/localization`, {
body: { localization },
})
return res.data
}
/** The plan endpoint nests its params under `plan` and validates the slug
* against the server's plan list — pass one of `TENANT_PLANS`. */
export async function updatePlan(
arcadia: ArcadiaClient,
id: string,
plan: { plan: string; plan_limits?: Record<string, unknown> },
): Promise<Tenant> {
const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/plan`, {
body: { plan },
})
return res.data
}
// --- Quotas & usage ---
export interface QuotaConfig {
category: string
calls_per_minute: number | null
calls_per_day: number | null
calls_per_month: number | null
enabled: boolean
}
export interface UsageRow {
category: string
usage: { minute: number; day: number; month: number }
quota: {
enabled: boolean
calls_per_minute: number | null
calls_per_day: number | null
calls_per_month: number | null
}
}
export async function listUsage(arcadia: ArcadiaClient, id: string): Promise<UsageRow[]> {
const res = await arcadia.GET<{ data: UsageRow[] }>(
`/api/v1/admin/tenants/${id}/api-metering/usage`,
)
return res.data
}
export async function upsertQuota(
arcadia: ArcadiaClient,
id: string,
category: string,
limits: Partial<Omit<QuotaConfig, "category">>,
): Promise<QuotaConfig> {
const res = await arcadia.PUT<{ data: QuotaConfig }>(
`/api/v1/admin/tenants/${id}/api-metering/quotas/${encodeURIComponent(category)}`,
{ body: limits },
)
return res.data
}
export async function deleteQuota(
arcadia: ArcadiaClient,
id: string,
category: string,
): Promise<void> {
await arcadia.DELETE(
`/api/v1/admin/tenants/${id}/api-metering/quotas/${encodeURIComponent(category)}`,
)
}
// --- Feature flags ---
//
// The list is one row per *platform-defined* flag, overlaid with this tenant's
// override: `source` is "override" when the tenant pins a value, "default" when
// it inherits `enabled_by_default`. You can only override a flag that exists at
// the platform level — an override for an unknown key is stored but never shown
// (define platform flags under the platform Feature-flags screen). So an empty
// list means "no platform flags defined", not "no overrides".
export interface TenantFeatureFlag {
key: string
description: string | null
enabled: boolean
source: "override" | "default" | string
}
export async function listFeatureFlags(
arcadia: ArcadiaClient,
id: string,
): Promise<TenantFeatureFlag[]> {
const res = await arcadia.GET<{ data: TenantFeatureFlag[] }>(
`/api/v1/admin/tenants/${id}/feature-flags`,
)
return res.data
}
/** Pin `key` on/off for this tenant, overriding the platform default. */
export async function setFeatureFlag(
arcadia: ArcadiaClient,
id: string,
key: string,
enabled: boolean,
): Promise<void> {
await arcadia.PUT(
`/api/v1/admin/tenants/${id}/feature-flags/${encodeURIComponent(key)}`,
{ body: { enabled } },
)
}
/** Drop the tenant's override for `key`, reverting it to the platform default. */
export async function clearFeatureFlag(
arcadia: ArcadiaClient,
id: string,
key: string,
): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/feature-flags/${encodeURIComponent(key)}`)
}
// --- IP rules ---
export interface IpRule {
id: string
cidr: string
rule_type: "allow" | "deny" | string
description: string | null
enabled: boolean
}
export type IpRuleInput = {
cidr: string
rule_type: "allow" | "deny"
description?: string
enabled?: boolean
}
export async function listIpRules(arcadia: ArcadiaClient, id: string): Promise<IpRule[]> {
const res = await arcadia.GET<{ data: IpRule[] }>(`/api/v1/admin/tenants/${id}/ip-rules`)
return res.data
}
export async function createIpRule(
arcadia: ArcadiaClient,
id: string,
input: IpRuleInput,
): Promise<IpRule> {
const res = await arcadia.POST<{ data: IpRule }>(`/api/v1/admin/tenants/${id}/ip-rules`, {
body: input,
})
return res.data
}
export async function updateIpRule(
arcadia: ArcadiaClient,
id: string,
ruleId: string,
input: Partial<IpRuleInput>,
): Promise<IpRule> {
const res = await arcadia.PUT<{ data: IpRule }>(
`/api/v1/admin/tenants/${id}/ip-rules/${ruleId}`,
{ body: input },
)
return res.data
}
export async function deleteIpRule(
arcadia: ArcadiaClient,
id: string,
ruleId: string,
): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/ip-rules/${ruleId}`)
}
// --- Inbound webhooks ---
export interface InboundWebhookSource {
id: string
name: string
provider: string | null
signature_header: string | null
signature_algorithm: string
enabled: boolean
event_mappings?: Record<string, unknown>
metadata?: Record<string, unknown>
inserted_at?: string
}
export type InboundWebhookInput = {
name: string
provider?: string
signing_secret?: string
signature_header?: string
signature_algorithm?: string
enabled?: boolean
}
export interface InboundWebhookDelivery {
id: string
status?: string
received_at?: string
inserted_at?: string
[key: string]: unknown
}
export async function listInboundWebhooks(
arcadia: ArcadiaClient,
id: string,
): Promise<InboundWebhookSource[]> {
const res = await arcadia.GET<{ data: InboundWebhookSource[] }>(
`/api/v1/admin/tenants/${id}/inbound-webhooks`,
)
return res.data
}
export async function createInboundWebhook(
arcadia: ArcadiaClient,
id: string,
input: InboundWebhookInput,
): Promise<InboundWebhookSource> {
const res = await arcadia.POST<{ data: InboundWebhookSource }>(
`/api/v1/admin/tenants/${id}/inbound-webhooks`,
{ body: input },
)
return res.data
}
export async function updateInboundWebhook(
arcadia: ArcadiaClient,
id: string,
sourceId: string,
input: Partial<InboundWebhookInput>,
): Promise<InboundWebhookSource> {
const res = await arcadia.PUT<{ data: InboundWebhookSource }>(
`/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}`,
{ body: input },
)
return res.data
}
export async function deleteInboundWebhook(
arcadia: ArcadiaClient,
id: string,
sourceId: string,
): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}`)
}
export async function listInboundWebhookDeliveries(
arcadia: ArcadiaClient,
id: string,
sourceId: string,
): Promise<InboundWebhookDelivery[]> {
const res = await arcadia.GET<{ data: InboundWebhookDelivery[] }>(
`/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}/deliveries`,
)
return res.data
}
// --- Email & SMS delivery config ---
// GET 404s when unconfigured; credentials are write-only (never returned).
export interface EmailConfig {
id: string
provider: (typeof EMAIL_PROVIDERS)[number] | string
from_email: string | null
from_name: string | null
reply_to: string | null
enabled: boolean
}
export type EmailConfigInput = {
provider: string
from_email?: string
from_name?: string
reply_to?: string
credentials?: Record<string, unknown>
enabled?: boolean
}
export interface SmsConfig {
id: string
provider: (typeof SMS_PROVIDERS)[number] | string
from_number: string | null
enabled: boolean
}
export type SmsConfigInput = {
provider: string
from_number?: string
credentials?: Record<string, unknown>
enabled?: boolean
}
export interface TestResult {
ok: boolean
message: string
}
/** Resolves to null when no config exists (the endpoint 404s), so callers can
* distinguish "unconfigured" from a real load failure. */
export async function getEmailConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<EmailConfig | null> {
return getOrNull<EmailConfig>(arcadia, `/api/v1/admin/tenants/${id}/email-config`)
}
export async function upsertEmailConfig(
arcadia: ArcadiaClient,
id: string,
input: EmailConfigInput,
): Promise<EmailConfig> {
const res = await arcadia.PUT<{ data: EmailConfig }>(
`/api/v1/admin/tenants/${id}/email-config`,
{ body: { email_config: input } },
)
return res.data
}
export async function deleteEmailConfig(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/email-config`)
}
export async function testEmailConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<TestResult> {
return arcadia.POST<TestResult>(`/api/v1/admin/tenants/${id}/email-config/test`)
}
export async function getSmsConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<SmsConfig | null> {
return getOrNull<SmsConfig>(arcadia, `/api/v1/admin/tenants/${id}/sms-config`)
}
export async function upsertSmsConfig(
arcadia: ArcadiaClient,
id: string,
input: SmsConfigInput,
): Promise<SmsConfig> {
const res = await arcadia.PUT<{ data: SmsConfig }>(`/api/v1/admin/tenants/${id}/sms-config`, {
body: { sms_config: input },
})
return res.data
}
export async function deleteSmsConfig(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/sms-config`)
}
export async function testSmsConfig(arcadia: ArcadiaClient, id: string): Promise<TestResult> {
return arcadia.POST<TestResult>(`/api/v1/admin/tenants/${id}/sms-config/test`)
}
/** GET that treats a 404 as "not configured yet" (null) rather than an error,
* and rethrows anything else so real failures still surface. */
async function getOrNull<T>(arcadia: ArcadiaClient, path: string): Promise<T | null> {
try {
const res = await arcadia.GET<{ data: T }>(path)
return res.data
} catch (err) {
if (err instanceof ArcadiaError && err.status === 404) return null
throw err
}
}

View File

@@ -114,11 +114,12 @@ export const ROUTE_CAPABILITY: Record<string, Capability> = {
"/memberships": "tenant.memberships",
"/storage": "tenant.storage",
"/buckets": "tenant.buckets",
"/activity": "tenant.activity",
"/audit-log": "tenant.activity",
"/activity": "tenant.activity", // legacy path → redirects to /audit-log
"/settings": "tenant.settings",
"/apps": "tenant.apps",
"/plan": "tenant.plan",
"/entitlements": "tenant.entitlements",
// Plan, Entitlements, and Apps collapsed into one Billing surface (Phase 3).
// They split back out under this same capability set once wired (Phase 5).
"/billing": "tenant.plan",
"/tenants": "platform.tenants",
"/organizations": "platform.organizations",

134
app/lib/errors.ts Normal file
View File

@@ -0,0 +1,134 @@
// One place that turns whatever the API threw into something an operator can
// act on. Before this existed, every list screen surfaced the raw status text
// ("Internal Server Error", "Too Many Requests", "Bad Request") — which names
// the failure but never the fix — and, worse, rendered its empty state *next
// to* the error, so "the load failed" and "there is nothing here" looked
// identical. An operator can't tell an empty audit log from a broken one.
import { ArcadiaError } from "@crema/arcadia-core-client"
export type LoadError = {
/** Plain-language headline. Never a raw HTTP status. */
title: string
/** What to do about it. Empty when there's genuinely nothing to suggest. */
detail: string
status?: number
/** Set for 429s — seconds until it's worth retrying. Drives auto-retry. */
retryAfterSec?: number
/** True when retrying might plausibly work (5xx, 429, network). */
retryable: boolean
/** Field-level validation messages, flattened from Ecto's error tree. */
fields?: string[]
}
/** Flatten Ecto's nested `{tenant: {slug: ["has already been taken"]}}`. */
function flattenFieldErrors(details: unknown): string[] {
const lines: string[] = []
const walk = (obj: unknown, prefix: string) => {
if (Array.isArray(obj)) {
lines.push(prefix ? `${prefix}: ${obj.join(", ")}` : obj.join(", "))
} else if (obj && typeof obj === "object") {
for (const [k, v] of Object.entries(obj)) {
walk(v, prefix ? `${prefix}.${k}` : k)
}
}
}
walk(details, "")
return lines
}
export function describeError(err: unknown, context = "load"): LoadError {
// Network / CORS / server down — fetch rejects before any status exists.
if (err instanceof TypeError || (err instanceof Error && /fetch/i.test(err.message))) {
return {
title: "Can't reach arcadia",
detail:
"The API didn't respond. Check the service is running and that this host is allowed to call it, then retry.",
retryable: true,
}
}
if (!(err instanceof ArcadiaError)) {
return {
title: `Couldn't ${context}`,
detail: err instanceof Error && err.message ? err.message : "An unexpected error occurred.",
retryable: true,
}
}
const fields = err.details ? flattenFieldErrors(err.details) : undefined
switch (true) {
case err.status === 401:
return {
title: "Your session has expired",
detail: "Sign in again to continue.",
status: 401,
retryable: false,
}
case err.status === 403:
return {
title: "You don't have access to this",
detail:
"Your account lacks the role this screen needs. A platform administrator can grant it.",
status: 403,
retryable: false,
}
case err.status === 404:
return {
title: "Not found",
detail: "It may have been deleted, or the endpoint isn't available on this deployment.",
status: 404,
retryable: false,
}
case err.status === 422:
return {
title: "That didn't validate",
detail: fields?.length ? "" : err.message,
status: 422,
retryable: false,
fields,
}
case err.status === 429:
return {
title: "Too many requests",
detail: "arcadia is rate-limiting this console. It'll retry automatically.",
status: 429,
retryAfterSec: 30,
retryable: true,
}
case err.status >= 500:
return {
title: "arcadia hit a server error",
detail: `The request failed on the server${
err.requestId ? ` (request ${err.requestId})` : ""
}. Retry, and if it persists check the service logs.`,
status: err.status,
retryable: true,
}
default:
return {
title: `Couldn't ${context}`,
// Prefer the server's own message over the bare status line.
detail: fields?.length ? "" : err.message,
status: err.status,
retryable: err.status >= 500,
fields,
}
}
}
/** One-line form, for toasts and inside dialogs. */
export function errorMessage(err: unknown, context = "save"): string {
const d = describeError(err, context)
const parts = [d.title]
if (d.fields?.length) parts.push(d.fields.join("; "))
else if (d.detail) parts.push(d.detail)
return parts.join(" — ")
}

View File

@@ -2,7 +2,7 @@
// Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
// this store is for the appbar bell's persistent inbox.
import { useEffect, useSyncExternalStore } from "react"
import { useSyncExternalStore } from "react"
export type NotificationKind = "info" | "success" | "warning" | "error"
@@ -95,11 +95,27 @@ export function dismissAll() {
writeToStorage([])
}
let cached: AppNotification[] | null = null
// Cache keyed on the raw stored string so the snapshot stays referentially
// stable — `useSyncExternalStore` requires that getSnapshot return the same
// reference until the value genuinely changes. (This used to clear a flag on
// every mount without notifying subscribers, the same identity-churn bug that
// was fixed in session.ts.)
let cached: AppNotification[] = []
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 = () => {
cached = null
primed = false
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
@@ -109,7 +125,12 @@ function subscribe(cb: () => void): () => void {
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): AppNotification[] {
if (!cached) cached = readFromStorage()
const raw = readRaw()
if (!primed || raw !== cachedRaw) {
cachedRaw = raw
cached = readFromStorage()
primed = true
}
return cached
}
function getServerSnapshot(): AppNotification[] {
@@ -117,39 +138,9 @@ function getServerSnapshot(): AppNotification[] {
}
export function useNotifications(): AppNotification[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
export function unreadCount(items: AppNotification[]): number {
return items.filter((n) => !n.readAt).length
}
/** Seed a few demo notifications on first load so the bell isn't empty. */
export function seedIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: AppNotification[] = [
{
id: newId(),
kind: "info",
title: "Welcome",
body: "Tag elements with data-action and the assistant can drive them.",
href: "/assistant",
createdAt: now - 60_000,
},
{
id: newId(),
kind: "success",
title: "Profile saved",
body: "Your display name and avatar are live across the app.",
href: "/profile",
createdAt: now - 5 * 60_000,
},
]
writeToStorage(seed)
}

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 {