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>
538 lines
15 KiB
TypeScript
538 lines
15 KiB
TypeScript
// Arcadia tenants API helpers.
|
|
//
|
|
// Hand-rolled because /api/v1/admin/tenants isn't covered by arcadia's
|
|
// OpenAPI spec (controller hasn't been wired into OpenApiSpex yet — same
|
|
// "ok"-placeholder issue as some other admin endpoints). When the spec
|
|
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
|
|
// and drop these manual types.
|
|
|
|
import { ArcadiaError, type ArcadiaClient } from "@crema/arcadia-core-client"
|
|
|
|
export type TenantStatus = "active" | "suspended" | "deactivated" | string
|
|
|
|
export interface TenantPlan {
|
|
name: string
|
|
limits: Record<string, unknown>
|
|
}
|
|
|
|
export interface TenantBranding {
|
|
logo_url: string | null
|
|
favicon_url: string | null
|
|
primary_color: string | null
|
|
secondary_color: string | null
|
|
accent_color: string | null
|
|
custom_css: string | null
|
|
settings: Record<string, unknown>
|
|
}
|
|
|
|
export interface TenantSettings {
|
|
timezone?: string
|
|
currency?: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
export interface TenantLocalization {
|
|
locale: string
|
|
timezone: string
|
|
currency: string
|
|
settings: Record<string, unknown>
|
|
}
|
|
|
|
export interface Tenant {
|
|
id: string
|
|
slug: string
|
|
name: string
|
|
status: TenantStatus
|
|
plan: TenantPlan
|
|
branding: TenantBranding
|
|
settings: TenantSettings
|
|
localization: TenantLocalization
|
|
email_settings: Record<string, unknown>
|
|
notification_settings: Record<string, unknown>
|
|
metadata: Record<string, unknown>
|
|
inserted_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface TenantListParams {
|
|
q?: string
|
|
status?: TenantStatus
|
|
page?: number
|
|
per_page?: number
|
|
}
|
|
|
|
export async function listTenants(
|
|
arcadia: ArcadiaClient,
|
|
params?: TenantListParams,
|
|
): Promise<Tenant[]> {
|
|
const queryParams: Record<string, string | number | boolean | null | undefined> | undefined = params
|
|
? { q: params.q, status: params.status, page: params.page, per_page: params.per_page }
|
|
: undefined
|
|
const res = await arcadia.GET<{ data: Tenant[] }>("/api/v1/admin/tenants", { params: queryParams })
|
|
return res.data
|
|
}
|
|
|
|
export async function getTenant(arcadia: ArcadiaClient, id: string): Promise<Tenant> {
|
|
const res = await arcadia.GET<{ data: Tenant }>(`/api/v1/admin/tenants/${id}`)
|
|
return res.data
|
|
}
|
|
|
|
export async function suspendTenant(arcadia: ArcadiaClient, id: string): Promise<Tenant> {
|
|
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/suspend`)
|
|
return res.data
|
|
}
|
|
|
|
export async function activateTenant(arcadia: ArcadiaClient, id: string): Promise<Tenant> {
|
|
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/activate`)
|
|
return res.data
|
|
}
|
|
|
|
export async function deactivateTenant(arcadia: ArcadiaClient, id: string): Promise<Tenant> {
|
|
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`)
|
|
return res.data
|
|
}
|
|
|
|
export interface ProvisionTenantInput {
|
|
tenant: { name: string; slug: string }
|
|
admin_user: {
|
|
email: string
|
|
password: string
|
|
first_name: string
|
|
last_name: string
|
|
}
|
|
}
|
|
|
|
export async function provisionTenant(
|
|
arcadia: ArcadiaClient,
|
|
input: ProvisionTenantInput,
|
|
): Promise<Tenant> {
|
|
const res = await arcadia.POST<{ data: Tenant }>("/api/v1/admin/tenants/provision", {
|
|
body: input,
|
|
})
|
|
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
|
|
}
|
|
}
|