Files
arcadia-admin/app/components/tenant-detail/delivery-tab.tsx
jules 7415b40240 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>
2026-07-14 13:43:57 +10:00

365 lines
12 KiB
TypeScript

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>
)
}