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>
365 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|