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:
364
app/components/tenant-detail/delivery-tab.tsx
Normal file
364
app/components/tenant-detail/delivery-tab.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
|
||||
import { TenantSection, Field } from "~/components/tenant-detail/section"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import { NativeSelect, NativeSelectOption } from "~/components/ui/native-select"
|
||||
import {
|
||||
EMAIL_PROVIDERS,
|
||||
SMS_PROVIDERS,
|
||||
deleteEmailConfig,
|
||||
deleteSmsConfig,
|
||||
getEmailConfig,
|
||||
getSmsConfig,
|
||||
testEmailConfig,
|
||||
testSmsConfig,
|
||||
upsertEmailConfig,
|
||||
upsertSmsConfig,
|
||||
type EmailConfigInput,
|
||||
type SmsConfigInput,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
import { errorMessage } from "~/lib/errors"
|
||||
import type { TenantTabProps } from "~/routes/tenants.$id"
|
||||
|
||||
/**
|
||||
* Email & SMS delivery. These are two independent configs — each loads, saves,
|
||||
* tests, and deletes on its own — so the tab renders the same <DeliveryConfig>
|
||||
* frame twice, parameterised by `kind`.
|
||||
*/
|
||||
export function DeliveryTab({ tenant, reload }: TenantTabProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<DeliveryConfig kind="email" tenant={tenant} reload={reload} />
|
||||
<DeliveryConfig kind="sms" tenant={tenant} reload={reload} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Kind = "email" | "sms"
|
||||
|
||||
/** A credential input the operator can fill. Values are collected into the
|
||||
* write-only `credentials` object and only sent when actually typed. */
|
||||
type CredField = { key: string; label: string; type?: string }
|
||||
|
||||
function credentialFields(kind: Kind, provider: string): CredField[] {
|
||||
if (kind === "email") {
|
||||
if (provider === "smtp") {
|
||||
return [
|
||||
{ key: "host", label: "SMTP host" },
|
||||
{ key: "port", label: "Port" },
|
||||
{ key: "username", label: "Username" },
|
||||
{ key: "password", label: "Password", type: "password" },
|
||||
]
|
||||
}
|
||||
return [{ key: "api_key", label: "API key", type: "password" }]
|
||||
}
|
||||
// sms
|
||||
if (provider === "twilio") {
|
||||
return [
|
||||
{ key: "account_sid", label: "Account SID" },
|
||||
{ key: "auth_token", label: "Auth token", type: "password" },
|
||||
]
|
||||
}
|
||||
if (provider === "vonage") {
|
||||
return [
|
||||
{ key: "api_key", label: "API key" },
|
||||
{ key: "api_secret", label: "API secret", type: "password" },
|
||||
]
|
||||
}
|
||||
return [{ key: "api_key", label: "API key", type: "password" }]
|
||||
}
|
||||
|
||||
function DeliveryConfig({ tenant, kind }: TenantTabProps & { kind: Kind }) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
|
||||
const providers = kind === "email" ? EMAIL_PROVIDERS : SMS_PROVIDERS
|
||||
const prefix = `tenant-detail-${kind}`
|
||||
const label = kind === "email" ? "Email" : "SMS"
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
// Whether a config exists server-side (loaded non-null, or just saved). Gates
|
||||
// "Send test" and "Remove", which are meaningless with nothing configured.
|
||||
const [exists, setExists] = useState(false)
|
||||
|
||||
const [provider, setProvider] = useState<string>(providers[0])
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
// email-only
|
||||
const [fromEmail, setFromEmail] = useState("")
|
||||
const [fromName, setFromName] = useState("")
|
||||
const [replyTo, setReplyTo] = useState("")
|
||||
// sms-only
|
||||
const [fromNumber, setFromNumber] = useState("")
|
||||
// write-only credentials, keyed by field
|
||||
const [creds, setCreds] = useState<Record<string, string>>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
if (kind === "email") {
|
||||
const cfg = await getEmailConfig(arcadia, tenant.id)
|
||||
if (cfg) {
|
||||
setExists(true)
|
||||
setProvider(cfg.provider)
|
||||
setEnabled(cfg.enabled)
|
||||
setFromEmail(cfg.from_email ?? "")
|
||||
setFromName(cfg.from_name ?? "")
|
||||
setReplyTo(cfg.reply_to ?? "")
|
||||
} else {
|
||||
setExists(false)
|
||||
}
|
||||
} else {
|
||||
const cfg = await getSmsConfig(arcadia, tenant.id)
|
||||
if (cfg) {
|
||||
setExists(true)
|
||||
setProvider(cfg.provider)
|
||||
setEnabled(cfg.enabled)
|
||||
setFromNumber(cfg.from_number ?? "")
|
||||
} else {
|
||||
setExists(false)
|
||||
}
|
||||
}
|
||||
// Credentials are never returned — always start the write-only fields empty.
|
||||
setCreds({})
|
||||
} catch (err) {
|
||||
// getEmailConfig/getSmsConfig already map 404 → null, so anything thrown
|
||||
// here is a genuine load failure.
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia, tenant.id, kind])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Only send credentials the operator actually typed, scoped to the
|
||||
// current provider — otherwise we'd wipe stored creds with blanks, or
|
||||
// leak a previous provider's fields.
|
||||
const typedCreds: Record<string, string> = {}
|
||||
for (const f of credentialFields(kind, provider)) {
|
||||
const v = (creds[f.key] ?? "").trim()
|
||||
if (v) typedCreds[f.key] = v
|
||||
}
|
||||
const hasCreds = Object.keys(typedCreds).length > 0
|
||||
|
||||
if (kind === "email") {
|
||||
const input: EmailConfigInput = {
|
||||
provider,
|
||||
from_email: fromEmail,
|
||||
from_name: fromName,
|
||||
reply_to: replyTo,
|
||||
enabled,
|
||||
...(hasCreds ? { credentials: typedCreds } : {}),
|
||||
}
|
||||
await upsertEmailConfig(arcadia, tenant.id, input)
|
||||
toast.success("Email settings saved")
|
||||
} else {
|
||||
const input: SmsConfigInput = {
|
||||
provider,
|
||||
from_number: fromNumber,
|
||||
enabled,
|
||||
...(hasCreds ? { credentials: typedCreds } : {}),
|
||||
}
|
||||
await upsertSmsConfig(arcadia, tenant.id, input)
|
||||
toast.success("SMS settings saved")
|
||||
}
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const res =
|
||||
kind === "email"
|
||||
? await testEmailConfig(arcadia, tenant.id)
|
||||
: await testSmsConfig(arcadia, tenant.id)
|
||||
if (res.ok) toast.success(res.message || `${label} test succeeded`)
|
||||
else toast.error(res.message || `${label} test failed`)
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, `test ${label.toLowerCase()} delivery`))
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
setRemoving(true)
|
||||
try {
|
||||
if (kind === "email") await deleteEmailConfig(arcadia, tenant.id)
|
||||
else await deleteSmsConfig(arcadia, tenant.id)
|
||||
toast.success(`${label} configuration removed`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, `remove ${label.toLowerCase()} configuration`))
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fields = credentialFields(kind, provider)
|
||||
|
||||
return (
|
||||
<TenantSection
|
||||
title={`${label} delivery`}
|
||||
description={
|
||||
kind === "email"
|
||||
? "Outbound email for this tenant. Credentials are write-only and never shown again."
|
||||
: "Outbound SMS for this tenant. Credentials are write-only and never shown again."
|
||||
}
|
||||
onSubmit={save}
|
||||
saving={saving || loading}
|
||||
error={error}
|
||||
errorContext={`save ${label.toLowerCase()} settings`}
|
||||
saveLabel={kind === "email" ? "Save email settings" : "Save SMS settings"}
|
||||
dataAction={`${prefix}-save`}
|
||||
footerExtra={
|
||||
exists ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={runTest}
|
||||
disabled={testing || saving}
|
||||
data-action={`${prefix}-test`}
|
||||
>
|
||||
{testing ? "Testing…" : "Send test"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={remove}
|
||||
disabled={removing || saving}
|
||||
data-action={`${prefix}-remove`}
|
||||
>
|
||||
{removing ? "Removing…" : "Remove configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Field label="Provider" htmlFor={`${prefix}-provider`}>
|
||||
<NativeSelect
|
||||
id={`${prefix}-provider`}
|
||||
className="w-full"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
data-action={`${prefix}-provider`}
|
||||
>
|
||||
{providers.map((p) => (
|
||||
<NativeSelectOption key={p} value={p}>
|
||||
{p}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
{kind === "email" ? (
|
||||
<>
|
||||
<Field label="From email" htmlFor={`${prefix}-from-email`}>
|
||||
<Input
|
||||
id={`${prefix}-from-email`}
|
||||
type="email"
|
||||
value={fromEmail}
|
||||
onChange={(e) => setFromEmail(e.target.value)}
|
||||
placeholder="no-reply@example.com"
|
||||
data-action={`${prefix}-from-email`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="From name" htmlFor={`${prefix}-from-name`}>
|
||||
<Input
|
||||
id={`${prefix}-from-name`}
|
||||
value={fromName}
|
||||
onChange={(e) => setFromName(e.target.value)}
|
||||
placeholder="Example App"
|
||||
data-action={`${prefix}-from-name`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Reply-to" htmlFor={`${prefix}-reply-to`}>
|
||||
<Input
|
||||
id={`${prefix}-reply-to`}
|
||||
type="email"
|
||||
value={replyTo}
|
||||
onChange={(e) => setReplyTo(e.target.value)}
|
||||
placeholder="support@example.com"
|
||||
data-action={`${prefix}-reply-to`}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field
|
||||
label="From number"
|
||||
htmlFor={`${prefix}-from-number`}
|
||||
hint="The sender number or short code, in E.164 (e.g. +15551234567)."
|
||||
>
|
||||
<Input
|
||||
id={`${prefix}-from-number`}
|
||||
value={fromNumber}
|
||||
onChange={(e) => setFromNumber(e.target.value)}
|
||||
placeholder="+15551234567"
|
||||
data-action={`${prefix}-from-number`}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Enabled</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When off, {label.toLowerCase()} is configured but not sent.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v)}
|
||||
data-action={`${prefix}-enabled`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-input bg-muted/30 p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Credentials</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{exists
|
||||
? "Write-only and never shown again. Leave blank to keep the stored credentials; fill in to replace them."
|
||||
: "Write-only and never shown again."}
|
||||
</p>
|
||||
</div>
|
||||
{fields.map((f) => (
|
||||
<Field key={f.key} label={f.label} htmlFor={`${prefix}-cred-${f.key}`}>
|
||||
<Input
|
||||
id={`${prefix}-cred-${f.key}`}
|
||||
type={f.type ?? "text"}
|
||||
autoComplete="off"
|
||||
value={creds[f.key] ?? ""}
|
||||
onChange={(e) => setCreds((c) => ({ ...c, [f.key]: e.target.value }))}
|
||||
placeholder={exists ? "•••••• (unchanged)" : ""}
|
||||
data-action={`${prefix}-cred-${f.key}`}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</TenantSection>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user