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:
201
app/components/tenant-detail/branding-tab.tsx
Normal file
201
app/components/tenant-detail/branding-tab.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { 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 { Input } from "~/components/ui/input"
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import { updateBranding } from "~/lib/arcadia/tenants"
|
||||
import type { TenantTabProps } from "~/routes/tenants.$id"
|
||||
|
||||
/**
|
||||
* Tenant branding: logo/favicon URLs, the three brand colours, and a custom-CSS
|
||||
* override. All fields are optional; clearing one and saving sends `null` so it
|
||||
* clears server-side (the server accepts null and validates any colour it does
|
||||
* get against a `#rrggbb` hex).
|
||||
*/
|
||||
export function BrandingTab({ tenant, reload }: TenantTabProps) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
const b = tenant.branding
|
||||
|
||||
const [logoUrl, setLogoUrl] = useState(b.logo_url ?? "")
|
||||
const [faviconUrl, setFaviconUrl] = useState(b.favicon_url ?? "")
|
||||
const [primary, setPrimary] = useState(b.primary_color ?? "")
|
||||
const [secondary, setSecondary] = useState(b.secondary_color ?? "")
|
||||
const [accent, setAccent] = useState(b.accent_color ?? "")
|
||||
const [customCss, setCustomCss] = useState(b.custom_css ?? "")
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
const dirty =
|
||||
logoUrl !== (b.logo_url ?? "") ||
|
||||
faviconUrl !== (b.favicon_url ?? "") ||
|
||||
primary !== (b.primary_color ?? "") ||
|
||||
secondary !== (b.secondary_color ?? "") ||
|
||||
accent !== (b.accent_color ?? "") ||
|
||||
customCss !== (b.custom_css ?? "")
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateBranding(arcadia, tenant.id, {
|
||||
logo_url: emptyToNull(logoUrl),
|
||||
favicon_url: emptyToNull(faviconUrl),
|
||||
primary_color: emptyToNull(primary),
|
||||
secondary_color: emptyToNull(secondary),
|
||||
accent_color: emptyToNull(accent),
|
||||
custom_css: customCss.trim() === "" ? null : customCss,
|
||||
})
|
||||
await reload()
|
||||
toast.success("Branding updated")
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TenantSection
|
||||
title="Branding"
|
||||
description="How this tenant's apps present themselves. Leave a field blank to fall back to the platform default; clearing a saved value removes it."
|
||||
onSubmit={save}
|
||||
saving={saving}
|
||||
error={error}
|
||||
errorContext="update branding"
|
||||
dirty={dirty}
|
||||
dataAction="tenant-detail-branding-save"
|
||||
>
|
||||
<Field
|
||||
label="Logo URL"
|
||||
htmlFor="branding-logo-url"
|
||||
hint="Publicly reachable image URL shown in the tenant's app header."
|
||||
>
|
||||
<Input
|
||||
id="branding-logo-url"
|
||||
type="url"
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
data-action="tenant-detail-branding-logo-url"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Favicon URL"
|
||||
htmlFor="branding-favicon-url"
|
||||
hint="Publicly reachable .ico/.png used as the browser tab icon."
|
||||
>
|
||||
<Input
|
||||
id="branding-favicon-url"
|
||||
type="url"
|
||||
value={faviconUrl}
|
||||
onChange={(e) => setFaviconUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
data-action="tenant-detail-branding-favicon-url"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<ColorField
|
||||
label="Primary color"
|
||||
id="branding-primary-color"
|
||||
value={primary}
|
||||
onChange={setPrimary}
|
||||
dataAction="tenant-detail-branding-primary-color"
|
||||
/>
|
||||
<ColorField
|
||||
label="Secondary color"
|
||||
id="branding-secondary-color"
|
||||
value={secondary}
|
||||
onChange={setSecondary}
|
||||
dataAction="tenant-detail-branding-secondary-color"
|
||||
/>
|
||||
<ColorField
|
||||
label="Accent color"
|
||||
id="branding-accent-color"
|
||||
value={accent}
|
||||
onChange={setAccent}
|
||||
dataAction="tenant-detail-branding-accent-color"
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Custom CSS"
|
||||
htmlFor="branding-custom-css"
|
||||
hint="Injected into the tenant's apps. Applies verbatim — test before saving."
|
||||
>
|
||||
<Textarea
|
||||
id="branding-custom-css"
|
||||
value={customCss}
|
||||
onChange={(e) => setCustomCss(e.target.value)}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
placeholder=":root { --brand: #5b21b6; }"
|
||||
data-action="tenant-detail-branding-custom-css"
|
||||
/>
|
||||
</Field>
|
||||
</TenantSection>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A hex-colour control: a native colour swatch and a text input kept in sync,
|
||||
* plus a live preview chip. The text field holds the source of truth (may be
|
||||
* empty or a partial/invalid hex mid-edit); the swatch falls back to black so
|
||||
* the picker always has something to show.
|
||||
*/
|
||||
function ColorField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
dataAction,
|
||||
}: {
|
||||
label: string
|
||||
id: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
dataAction: string
|
||||
}) {
|
||||
const trimmed = value.trim()
|
||||
const valid = /^#[0-9a-fA-F]{6}$/.test(trimmed)
|
||||
|
||||
return (
|
||||
<Field label={label} htmlFor={id} hint="Hex like #5b21b6. Clear to remove.">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
aria-label={`${label} swatch`}
|
||||
value={valid ? trimmed : "#000000"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-8 w-12 shrink-0 cursor-pointer p-1"
|
||||
data-action={`${dataAction}-swatch`}
|
||||
/>
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="font-mono"
|
||||
data-action={dataAction}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
title={valid ? trimmed : "No colour"}
|
||||
className="size-8 shrink-0 rounded-lg border border-input"
|
||||
style={{ background: valid ? trimmed : "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
/** Trim to detect emptiness; a blank field clears server-side via null. */
|
||||
function emptyToNull(s: string): string | null {
|
||||
return s.trim() === "" ? null : s.trim()
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
174
app/components/tenant-detail/feature-flags-tab.tsx
Normal file
174
app/components/tenant-detail/feature-flags-tab.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { RefreshCw, RotateCcw } from "lucide-react"
|
||||
|
||||
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
import { BadgeCell } from "@crema/table-ui"
|
||||
import { EmptyState } from "@crema/feedback-ui"
|
||||
|
||||
import type { TenantTabProps } from "~/routes/tenants.$id"
|
||||
import { DataState } from "~/components/data-state"
|
||||
import { errorMessage } from "~/lib/errors"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import {
|
||||
clearFeatureFlag,
|
||||
listFeatureFlags,
|
||||
setFeatureFlag,
|
||||
type TenantFeatureFlag,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
|
||||
/**
|
||||
* Per-tenant feature-flag overrides. The list is every platform-defined flag
|
||||
* with this tenant's effective value; a flag is either inherited from the
|
||||
* platform default ("default") or pinned for this tenant ("override"). Toggling
|
||||
* a row pins it; "Revert" drops the override so it follows the default again.
|
||||
* You can't add arbitrary keys here — a flag has to exist at the platform level
|
||||
* before a tenant can override it.
|
||||
*/
|
||||
export function FeatureFlagsTab({ tenant }: TenantTabProps) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [flags, setFlags] = useState<TenantFeatureFlag[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
setFlags(await listFeatureFlags(arcadia, tenant.id))
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia, tenant.id])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const toggle = async (flag: TenantFeatureFlag, next: boolean) => {
|
||||
setBusy(flag.key)
|
||||
// Optimistic: reflect the pin immediately, roll back on failure.
|
||||
setFlags((prev) =>
|
||||
prev.map((f) => (f.key === flag.key ? { ...f, enabled: next, source: "override" } : f)),
|
||||
)
|
||||
try {
|
||||
await setFeatureFlag(arcadia, tenant.id, flag.key, next)
|
||||
toast.success(`${next ? "Enabled" : "Disabled"} ${flag.key} for ${tenant.name}`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setFlags((prev) => prev.map((f) => (f.key === flag.key ? flag : f)))
|
||||
toast.error(errorMessage(err, `override ${flag.key}`))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const revert = async (flag: TenantFeatureFlag) => {
|
||||
setBusy(flag.key)
|
||||
try {
|
||||
await clearFeatureFlag(arcadia, tenant.id, flag.key)
|
||||
toast.success(`${flag.key} follows the platform default again`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, `revert ${flag.key}`))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Feature flags</CardTitle>
|
||||
<CardDescription>
|
||||
Override a platform flag for this tenant. Un-overridden flags follow
|
||||
the platform default.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
data-action="tenant-detail-flags-refresh"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={flags.length === 0}
|
||||
onRetry={load}
|
||||
loadingLabel="Loading feature flags…"
|
||||
empty={
|
||||
<EmptyState
|
||||
title="No platform feature flags defined"
|
||||
description="Flags are defined at the platform level; once they exist, you can pin any of them on or off for this tenant here."
|
||||
className="py-12"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ul className="divide-y">
|
||||
{flags.map((flag) => (
|
||||
<li key={flag.key} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono text-sm">{flag.key}</code>
|
||||
<BadgeCell
|
||||
label={flag.source === "override" ? "override" : "default"}
|
||||
tone={flag.source === "override" ? "info" : "default"}
|
||||
/>
|
||||
</div>
|
||||
{flag.description ? (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{flag.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{flag.source === "override" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => revert(flag)}
|
||||
disabled={busy === flag.key}
|
||||
data-action={`tenant-detail-flags-revert-${flag.key}`}
|
||||
title="Revert to the platform default"
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Revert
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Switch
|
||||
checked={flag.enabled}
|
||||
onCheckedChange={(v) => toggle(flag, v)}
|
||||
disabled={busy === flag.key}
|
||||
data-action={`tenant-detail-flags-toggle-${flag.key}`}
|
||||
aria-label={`Override ${flag.key}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
534
app/components/tenant-detail/inbound-webhooks-tab.tsx
Normal file
534
app/components/tenant-detail/inbound-webhooks-tab.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Clock, ListChecks, Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
|
||||
import { BadgeCell, type BadgeTone } from "@crema/table-ui"
|
||||
|
||||
import type { TenantTabProps } from "~/routes/tenants.$id"
|
||||
import { DataState, DialogError } from "~/components/data-state"
|
||||
import { errorMessage } from "~/lib/errors"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import {
|
||||
createInboundWebhook,
|
||||
deleteInboundWebhook,
|
||||
listInboundWebhookDeliveries,
|
||||
listInboundWebhooks,
|
||||
updateInboundWebhook,
|
||||
type InboundWebhookDelivery,
|
||||
type InboundWebhookSource,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
|
||||
/**
|
||||
* Inbound webhook sources: external providers whose signed callbacks this
|
||||
* tenant accepts and verifies. Each source can be toggled, deleted, and has a
|
||||
* recent-deliveries log.
|
||||
*/
|
||||
export function InboundWebhooksTab({ tenant }: TenantTabProps) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [sources, setSources] = useState<InboundWebhookSource[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Raw thrown value; DataState normalises it. A failed load must never render
|
||||
// as "no sources yet".
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set())
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [deliveriesFor, setDeliveriesFor] = useState<InboundWebhookSource | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<InboundWebhookSource | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
setSources(await listInboundWebhooks(arcadia, tenant.id))
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia, tenant.id])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const markBusy = (id: string, on: boolean) =>
|
||||
setBusy((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (on) next.add(id)
|
||||
else next.delete(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggle = useCallback(
|
||||
async (src: InboundWebhookSource, next: boolean) => {
|
||||
markBusy(src.id, true)
|
||||
setSources((prev) =>
|
||||
prev.map((s) => (s.id === src.id ? { ...s, enabled: next } : s)),
|
||||
)
|
||||
try {
|
||||
const updated = await updateInboundWebhook(arcadia, tenant.id, src.id, {
|
||||
enabled: next,
|
||||
})
|
||||
setSources((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
|
||||
toast.success(`${next ? "Enabled" : "Disabled"} ${src.name}`)
|
||||
} catch (err) {
|
||||
setSources((prev) =>
|
||||
prev.map((s) => (s.id === src.id ? { ...s, enabled: !next } : s)),
|
||||
)
|
||||
toast.error(errorMessage(err, `update ${src.name}`))
|
||||
} finally {
|
||||
markBusy(src.id, false)
|
||||
}
|
||||
},
|
||||
[arcadia, tenant.id, toast],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Inbound webhooks</CardTitle>
|
||||
<CardDescription>
|
||||
External providers whose signed callbacks {tenant.name} accepts and
|
||||
verifies.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
data-action="tenant-detail-webhooks-refresh"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
data-action="tenant-detail-webhooks-add"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add source
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={sources.length === 0}
|
||||
onRetry={load}
|
||||
loadingLabel="Loading webhook sources…"
|
||||
empty={
|
||||
<EmptyState
|
||||
title="No inbound webhook sources."
|
||||
description="Add one to accept and verify signed callbacks from an external provider."
|
||||
className="py-12"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-6 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Provider</th>
|
||||
<th className="px-4 py-2 font-medium">Enabled</th>
|
||||
<th className="px-6 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{sources.map((src) => {
|
||||
const isBusy = busy.has(src.id)
|
||||
return (
|
||||
<tr key={src.id}>
|
||||
<td className="px-6 py-3 font-medium">{src.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{src.provider || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Switch
|
||||
checked={src.enabled}
|
||||
disabled={isBusy}
|
||||
onCheckedChange={(next) => toggle(src, next)}
|
||||
data-action={`tenant-detail-webhooks-toggle-${src.id}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeliveriesFor(src)}
|
||||
data-action={`tenant-detail-webhooks-deliveries-${src.id}`}
|
||||
>
|
||||
<ListChecks className="size-4" />
|
||||
Deliveries
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
disabled={isBusy}
|
||||
onClick={() => setPendingDelete(src)}
|
||||
aria-label={`Delete ${src.name}`}
|
||||
data-action={`tenant-detail-webhooks-delete-${src.id}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
|
||||
<AddWebhookDialog
|
||||
open={addOpen}
|
||||
tenantId={tenant.id}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSaved={async (msg) => {
|
||||
setAddOpen(false)
|
||||
await load()
|
||||
toast.success(msg)
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeliveriesDialog
|
||||
source={deliveriesFor}
|
||||
tenantId={tenant.id}
|
||||
onClose={() => setDeliveriesFor(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||
title="Delete webhook source?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `${pendingDelete.name} will be removed. Incoming callbacks from this provider will be rejected, and its delivery history is discarded.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
const target = pendingDelete
|
||||
try {
|
||||
await deleteInboundWebhook(arcadia, tenant.id, target.id)
|
||||
setPendingDelete(null)
|
||||
await load()
|
||||
toast.success(`Deleted ${target.name}`)
|
||||
} catch (err) {
|
||||
setPendingDelete(null)
|
||||
toast.error(errorMessage(err, `delete ${target.name}`))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AddWebhookDialog({
|
||||
open,
|
||||
tenantId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
tenantId: string
|
||||
onClose: () => void
|
||||
onSaved: (message: string) => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [name, setName] = useState("")
|
||||
const [provider, setProvider] = useState("")
|
||||
const [signingSecret, setSigningSecret] = useState("")
|
||||
const [signatureHeader, setSignatureHeader] = useState("")
|
||||
const [signatureAlgorithm, setSignatureAlgorithm] = useState("hmac_sha256")
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Failed submit renders here, above the buttons, with the form intact.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName("")
|
||||
setProvider("")
|
||||
setSigningSecret("")
|
||||
setSignatureHeader("")
|
||||
setSignatureAlgorithm("hmac_sha256")
|
||||
setEnabled(true)
|
||||
setError(null)
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
const trimmedName = name.trim()
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
if (!trimmedName) throw new Error("A name is required.")
|
||||
await createInboundWebhook(arcadia, tenantId, {
|
||||
name: trimmedName,
|
||||
provider: provider.trim() || undefined,
|
||||
signing_secret: signingSecret || undefined,
|
||||
signature_header: signatureHeader.trim() || undefined,
|
||||
signature_algorithm: signatureAlgorithm.trim() || undefined,
|
||||
enabled,
|
||||
})
|
||||
await onSaved(`Added webhook source ${trimmedName}`)
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add webhook source</DialogTitle>
|
||||
<DialogDescription>
|
||||
Register an external provider whose signed callbacks this tenant will
|
||||
accept and verify.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="webhook-name">Name</Label>
|
||||
<Input
|
||||
id="webhook-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Stripe events"
|
||||
autoFocus
|
||||
data-action="tenant-detail-webhooks-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="webhook-provider">Provider</Label>
|
||||
<Input
|
||||
id="webhook-provider"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
placeholder="stripe"
|
||||
data-action="tenant-detail-webhooks-form-provider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="webhook-secret">Signing secret</Label>
|
||||
<Input
|
||||
id="webhook-secret"
|
||||
type="password"
|
||||
value={signingSecret}
|
||||
onChange={(e) => setSigningSecret(e.target.value)}
|
||||
placeholder="whsec_…"
|
||||
data-action="tenant-detail-webhooks-form-secret"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stored encrypted; used to verify incoming signatures. Write-only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="webhook-header">Signature header</Label>
|
||||
<Input
|
||||
id="webhook-header"
|
||||
value={signatureHeader}
|
||||
onChange={(e) => setSignatureHeader(e.target.value)}
|
||||
placeholder="X-Signature"
|
||||
className="font-mono"
|
||||
data-action="tenant-detail-webhooks-form-header"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="webhook-algorithm">Signature algorithm</Label>
|
||||
<Input
|
||||
id="webhook-algorithm"
|
||||
value={signatureAlgorithm}
|
||||
onChange={(e) => setSignatureAlgorithm(e.target.value)}
|
||||
placeholder="hmac_sha256"
|
||||
className="font-mono"
|
||||
data-action="tenant-detail-webhooks-form-algorithm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Enabled</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Disabled sources reject incoming callbacks.
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
data-action="tenant-detail-webhooks-form-enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <DialogError error={error} context="add the source" /> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
data-action="tenant-detail-webhooks-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !name.trim()}
|
||||
data-action="tenant-detail-webhooks-form-save"
|
||||
>
|
||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : null}
|
||||
Add source
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DeliveriesDialog({
|
||||
source,
|
||||
tenantId,
|
||||
onClose,
|
||||
}: {
|
||||
source: InboundWebhookSource | null
|
||||
tenantId: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [deliveries, setDeliveries] = useState<InboundWebhookDelivery[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// A deliveries load that failed is not a source with no history. Own error
|
||||
// state, rendered in place of the list.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return
|
||||
let mounted = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
listInboundWebhookDeliveries(arcadia, tenantId, source.id)
|
||||
.then((rows) => {
|
||||
if (mounted) setDeliveries(rows)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) setError(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [arcadia, tenantId, source, reloadKey])
|
||||
|
||||
return (
|
||||
<Dialog open={source !== null} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deliveries{source ? ` — ${source.name}` : ""}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Recent signed callbacks received from this provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={deliveries.length === 0}
|
||||
onRetry={() => setReloadKey((n) => n + 1)}
|
||||
loadingLabel="Loading deliveries…"
|
||||
empty={
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No deliveries yet. Callbacks this source receives and verifies will
|
||||
appear here.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ul className="flex max-h-[50vh] flex-col divide-y overflow-y-auto rounded-md border">
|
||||
{deliveries.map((d) => {
|
||||
const when = d.received_at ?? d.inserted_at
|
||||
return (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<BadgeCell
|
||||
label={d.status ?? "unknown"}
|
||||
tone={deliveryTone(d.status)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Clock className="mr-1 inline size-3" />
|
||||
{when ? new Date(when).toLocaleString() : "—"}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</DataState>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
data-action="tenant-detail-webhooks-deliveries-close"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function deliveryTone(status?: string): BadgeTone {
|
||||
const s = (status ?? "").toLowerCase()
|
||||
if (["ok", "success", "delivered", "verified", "processed"].includes(s))
|
||||
return "success"
|
||||
if (["failed", "error", "rejected", "invalid"].includes(s)) return "danger"
|
||||
if (["pending", "retrying", "queued"].includes(s)) return "warning"
|
||||
return "default"
|
||||
}
|
||||
389
app/components/tenant-detail/ip-rules-tab.tsx
Normal file
389
app/components/tenant-detail/ip-rules-tab.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
|
||||
import { BadgeCell } from "@crema/table-ui"
|
||||
|
||||
import type { TenantTabProps } from "~/routes/tenants.$id"
|
||||
import { DataState, DialogError } from "~/components/data-state"
|
||||
import { errorMessage } from "~/lib/errors"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import { NativeSelect, NativeSelectOption } from "~/components/ui/native-select"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import {
|
||||
createIpRule,
|
||||
deleteIpRule,
|
||||
listIpRules,
|
||||
updateIpRule,
|
||||
type IpRule,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
|
||||
/**
|
||||
* Per-tenant IP allow/deny rules. With no allow rules every IP is permitted;
|
||||
* an allow rule locks access to known ranges, a deny rule blocks specific ones.
|
||||
*/
|
||||
export function IpRulesTab({ tenant }: TenantTabProps) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [rules, setRules] = useState<IpRule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Raw thrown value; DataState normalises it. A failed load must never render
|
||||
// as "no rules — every IP allowed".
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set())
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [pendingDelete, setPendingDelete] = useState<IpRule | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
setRules(await listIpRules(arcadia, tenant.id))
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia, tenant.id])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const markBusy = (id: string, on: boolean) =>
|
||||
setBusy((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (on) next.add(id)
|
||||
else next.delete(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggle = useCallback(
|
||||
async (rule: IpRule, next: boolean) => {
|
||||
markBusy(rule.id, true)
|
||||
setRules((prev) =>
|
||||
prev.map((r) => (r.id === rule.id ? { ...r, enabled: next } : r)),
|
||||
)
|
||||
try {
|
||||
const updated = await updateIpRule(arcadia, tenant.id, rule.id, {
|
||||
enabled: next,
|
||||
})
|
||||
setRules((prev) => prev.map((r) => (r.id === updated.id ? updated : r)))
|
||||
toast.success(
|
||||
`${next ? "Enabled" : "Disabled"} ${rule.rule_type} rule ${rule.cidr}`,
|
||||
)
|
||||
} catch (err) {
|
||||
setRules((prev) =>
|
||||
prev.map((r) => (r.id === rule.id ? { ...r, enabled: !next } : r)),
|
||||
)
|
||||
toast.error(errorMessage(err, `update rule ${rule.cidr}`))
|
||||
} finally {
|
||||
markBusy(rule.id, false)
|
||||
}
|
||||
},
|
||||
[arcadia, tenant.id, toast],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>IP rules</CardTitle>
|
||||
<CardDescription>
|
||||
Control which client IPs may reach {tenant.name}. With no allow
|
||||
rules, every IP is allowed.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
data-action="tenant-detail-ip-refresh"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
data-action="tenant-detail-ip-add"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add rule
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={rules.length === 0}
|
||||
onRetry={load}
|
||||
loadingLabel="Loading IP rules…"
|
||||
empty={
|
||||
<EmptyState
|
||||
title="No IP rules."
|
||||
description="With no allow rules, every IP is allowed; add a deny rule to block specific ranges, or an allow rule to lock access down to known ranges."
|
||||
className="py-12"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-6 py-2 font-medium">CIDR</th>
|
||||
<th className="px-4 py-2 font-medium">Type</th>
|
||||
<th className="px-4 py-2 font-medium">Description</th>
|
||||
<th className="px-4 py-2 font-medium">Enabled</th>
|
||||
<th className="px-6 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rules.map((rule) => {
|
||||
const isBusy = busy.has(rule.id)
|
||||
return (
|
||||
<tr key={rule.id}>
|
||||
<td className="px-6 py-3">
|
||||
<code className="font-mono text-xs">{rule.cidr}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<BadgeCell
|
||||
label={rule.rule_type}
|
||||
tone={rule.rule_type === "deny" ? "danger" : "success"}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{rule.description || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
disabled={isBusy}
|
||||
onCheckedChange={(next) => toggle(rule, next)}
|
||||
data-action={`tenant-detail-ip-toggle-${rule.id}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
disabled={isBusy}
|
||||
onClick={() => setPendingDelete(rule)}
|
||||
aria-label={`Delete rule ${rule.cidr}`}
|
||||
data-action={`tenant-detail-ip-delete-${rule.id}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
|
||||
<AddIpRuleDialog
|
||||
open={addOpen}
|
||||
tenantId={tenant.id}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSaved={async (msg) => {
|
||||
setAddOpen(false)
|
||||
await load()
|
||||
toast.success(msg)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||
title="Delete IP rule?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `The ${pendingDelete.rule_type} rule for ${pendingDelete.cidr} will be removed. If this was the last allow rule, every IP becomes allowed again.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
const target = pendingDelete
|
||||
try {
|
||||
await deleteIpRule(arcadia, tenant.id, target.id)
|
||||
setPendingDelete(null)
|
||||
await load()
|
||||
toast.success(`Deleted ${target.rule_type} rule ${target.cidr}`)
|
||||
} catch (err) {
|
||||
setPendingDelete(null)
|
||||
toast.error(errorMessage(err, `delete rule ${target.cidr}`))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AddIpRuleDialog({
|
||||
open,
|
||||
tenantId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
tenantId: string
|
||||
onClose: () => void
|
||||
onSaved: (message: string) => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [cidr, setCidr] = useState("")
|
||||
const [ruleType, setRuleType] = useState<"allow" | "deny">("allow")
|
||||
const [description, setDescription] = useState("")
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Failed submit renders here, above the buttons, form kept intact.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCidr("")
|
||||
setRuleType("allow")
|
||||
setDescription("")
|
||||
setEnabled(true)
|
||||
setError(null)
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = cidr.trim()
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
if (!trimmed) throw new Error("A CIDR range is required.")
|
||||
await createIpRule(arcadia, tenantId, {
|
||||
cidr: trimmed,
|
||||
rule_type: ruleType,
|
||||
description: description.trim() || undefined,
|
||||
enabled,
|
||||
})
|
||||
await onSaved(`Added ${ruleType} rule ${trimmed}`)
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add IP rule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Allow rules lock access to known ranges; deny rules block specific
|
||||
ones.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ip-cidr">CIDR range</Label>
|
||||
<Input
|
||||
id="ip-cidr"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
placeholder="203.0.113.0/24"
|
||||
className="font-mono"
|
||||
autoFocus
|
||||
data-action="tenant-detail-ip-form-cidr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ip-type">Rule type</Label>
|
||||
<NativeSelect
|
||||
id="ip-type"
|
||||
className="w-full"
|
||||
value={ruleType}
|
||||
onChange={(e) => setRuleType(e.target.value as "allow" | "deny")}
|
||||
data-action="tenant-detail-ip-form-type"
|
||||
>
|
||||
<NativeSelectOption value="allow">Allow</NativeSelectOption>
|
||||
<NativeSelectOption value="deny">Deny</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ip-description">Description</Label>
|
||||
<Input
|
||||
id="ip-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Office VPN egress"
|
||||
data-action="tenant-detail-ip-form-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Enabled</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Disabled rules are kept but not enforced.
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
data-action="tenant-detail-ip-form-enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <DialogError error={error} context="add the rule" /> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
data-action="tenant-detail-ip-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !cidr.trim()}
|
||||
data-action="tenant-detail-ip-form-save"
|
||||
>
|
||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : null}
|
||||
Add rule
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
114
app/components/tenant-detail/localization-tab.tsx
Normal file
114
app/components/tenant-detail/localization-tab.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { 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 { NativeSelect, NativeSelectOption } from "~/components/ui/native-select"
|
||||
import {
|
||||
updateLocalization,
|
||||
TENANT_LOCALES,
|
||||
TENANT_CURRENCIES,
|
||||
COMMON_TIMEZONES,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
import type { TenantTabProps } from "~/routes/tenants.$id"
|
||||
|
||||
export function LocalizationTab({ tenant, reload }: TenantTabProps) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
|
||||
const current = tenant.localization
|
||||
const [locale, setLocale] = useState(current?.locale ?? "")
|
||||
const [timezone, setTimezone] = useState(current?.timezone ?? "")
|
||||
const [currency, setCurrency] = useState(current?.currency ?? "")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
// The tenant's real timezone might be an IANA name outside our curated list —
|
||||
// prepend it so the select shows the current value instead of silently
|
||||
// snapping to the first option.
|
||||
const timezoneOptions =
|
||||
current?.timezone && !COMMON_TIMEZONES.includes(current.timezone)
|
||||
? [current.timezone, ...COMMON_TIMEZONES]
|
||||
: COMMON_TIMEZONES
|
||||
|
||||
const dirty =
|
||||
locale !== (current?.locale ?? "") ||
|
||||
timezone !== (current?.timezone ?? "") ||
|
||||
currency !== (current?.currency ?? "")
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateLocalization(arcadia, tenant.id, { locale, timezone, currency })
|
||||
await reload()
|
||||
toast.success("Localization updated")
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TenantSection
|
||||
title="Localization"
|
||||
description="Default locale, timezone, and currency for this tenant."
|
||||
onSubmit={save}
|
||||
saving={saving}
|
||||
error={error}
|
||||
errorContext="update localization"
|
||||
dirty={dirty}
|
||||
dataAction="tenant-detail-localization-save"
|
||||
>
|
||||
<Field label="Locale" htmlFor="tenant-detail-localization-locale">
|
||||
<NativeSelect
|
||||
id="tenant-detail-localization-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value)}
|
||||
data-action="tenant-detail-localization-locale"
|
||||
>
|
||||
{TENANT_LOCALES.map((l) => (
|
||||
<NativeSelectOption key={l} value={l}>
|
||||
{l}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Currency" htmlFor="tenant-detail-localization-currency">
|
||||
<NativeSelect
|
||||
id="tenant-detail-localization-currency"
|
||||
className="w-full"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
data-action="tenant-detail-localization-currency"
|
||||
>
|
||||
{TENANT_CURRENCIES.map((c) => (
|
||||
<NativeSelectOption key={c} value={c}>
|
||||
{c}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Timezone" htmlFor="tenant-detail-localization-timezone">
|
||||
<NativeSelect
|
||||
id="tenant-detail-localization-timezone"
|
||||
className="w-full"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
data-action="tenant-detail-localization-timezone"
|
||||
>
|
||||
{timezoneOptions.map((tz) => (
|
||||
<NativeSelectOption key={tz} value={tz}>
|
||||
{tz}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</TenantSection>
|
||||
)
|
||||
}
|
||||
279
app/components/tenant-detail/plan-tab.tsx
Normal file
279
app/components/tenant-detail/plan-tab.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, X } from "lucide-react"
|
||||
|
||||
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
|
||||
import { TenantSection, Field } from "~/components/tenant-detail/section"
|
||||
import { DataState } from "~/components/data-state"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { NativeSelect, NativeSelectOption } from "~/components/ui/native-select"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
|
||||
import {
|
||||
listUsage,
|
||||
updatePlan,
|
||||
TENANT_PLANS,
|
||||
type UsageRow,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
import type { TenantTabProps } from "~/routes/tenants.$id"
|
||||
|
||||
// One editable limit row. `locked` marks a key that already existed on the
|
||||
// tenant (its name is fixed — you edit the value or remove the row); new rows
|
||||
// added with "Add limit" have an editable key.
|
||||
type LimitRow = { id: number; key: string; value: string; locked: boolean }
|
||||
|
||||
let rowSeq = 0
|
||||
const nextRowId = () => ++rowSeq
|
||||
|
||||
function rowsFromLimits(limits: Record<string, unknown> | undefined): LimitRow[] {
|
||||
return Object.entries(limits ?? {}).map(([key, value]) => ({
|
||||
id: nextRowId(),
|
||||
key,
|
||||
value: String(value ?? ""),
|
||||
locked: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// Collapse the editable rows into the Record<string, number> the server wants.
|
||||
// Blank keys are dropped; non-numeric values coerce to 0 (Number("") === 0 too).
|
||||
function buildLimits(rows: LimitRow[]): Record<string, number> {
|
||||
const out: Record<string, number> = {}
|
||||
for (const row of rows) {
|
||||
const key = row.key.trim()
|
||||
if (!key) continue
|
||||
out[key] = Number(row.value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normaliseLimits(limits: Record<string, unknown> | undefined): Record<string, number> {
|
||||
const out: Record<string, number> = {}
|
||||
for (const [key, value] of Object.entries(limits ?? {})) out[key] = Number(value)
|
||||
return out
|
||||
}
|
||||
|
||||
export function PlanTab({ tenant, reload }: TenantTabProps) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
|
||||
const currentPlan = tenant.plan?.name ?? ""
|
||||
const [plan, setPlan] = useState(currentPlan || TENANT_PLANS[0])
|
||||
const [rows, setRows] = useState<LimitRow[]>(() => rowsFromLimits(tenant.plan?.limits))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
const originalLimits = normaliseLimits(tenant.plan?.limits)
|
||||
const dirty =
|
||||
plan !== currentPlan ||
|
||||
JSON.stringify(buildLimits(rows)) !== JSON.stringify(originalLimits)
|
||||
|
||||
const setRowKey = (id: number, key: string) =>
|
||||
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, key } : r)))
|
||||
const setRowValue = (id: number, value: string) =>
|
||||
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, value } : r)))
|
||||
const removeRow = (id: number) => setRows((rs) => rs.filter((r) => r.id !== id))
|
||||
const addRow = () =>
|
||||
setRows((rs) => [...rs, { id: nextRowId(), key: "", value: "", locked: false }])
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updatePlan(arcadia, tenant.id, {
|
||||
plan,
|
||||
plan_limits: buildLimits(rows),
|
||||
})
|
||||
await reload()
|
||||
toast.success("Plan updated")
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<TenantSection
|
||||
title="Plan"
|
||||
description="The tenant's plan and its quota limits are saved together. The plan slug must be one the server recognises."
|
||||
onSubmit={save}
|
||||
saving={saving}
|
||||
error={error}
|
||||
errorContext="update the plan"
|
||||
dirty={dirty}
|
||||
dataAction="tenant-detail-plan-save"
|
||||
>
|
||||
<Field label="Plan" htmlFor="tenant-detail-plan-select">
|
||||
<NativeSelect
|
||||
id="tenant-detail-plan-select"
|
||||
className="w-full"
|
||||
value={plan}
|
||||
onChange={(e) => setPlan(e.target.value)}
|
||||
data-action="tenant-detail-plan-select"
|
||||
>
|
||||
{TENANT_PLANS.map((p) => (
|
||||
<NativeSelectOption key={p} value={p}>
|
||||
{p}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Plan limits"
|
||||
hint="Numeric quotas attached to this plan (e.g. users, storage_gb). Saved with the plan above."
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No limits set.</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="key"
|
||||
value={row.key}
|
||||
readOnly={row.locked}
|
||||
disabled={row.locked}
|
||||
onChange={(e) => setRowKey(row.id, e.target.value)}
|
||||
aria-label="Limit key"
|
||||
data-action="tenant-detail-plan-limit-key"
|
||||
/>
|
||||
<Input
|
||||
className="w-32"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="value"
|
||||
value={row.value}
|
||||
onChange={(e) => setRowValue(row.id, e.target.value)}
|
||||
aria-label="Limit value"
|
||||
data-action="tenant-detail-plan-limit-value"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => removeRow(row.id)}
|
||||
aria-label="Remove limit"
|
||||
data-action="tenant-detail-plan-limit-remove"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addRow}
|
||||
data-action="tenant-detail-plan-limit-add"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add limit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</TenantSection>
|
||||
|
||||
<PlanUsage tenantId={tenant.id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Read-only usage-vs-quota panel. Loads independently so a metering failure
|
||||
// never blanks the plan form above it.
|
||||
function PlanUsage({ tenantId }: { tenantId: string }) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [rows, setRows] = useState<UsageRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
const load = async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
setRows(await listUsage(arcadia, tenantId))
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantId])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={rows.length === 0}
|
||||
onRetry={load}
|
||||
loadingLabel="Loading usage…"
|
||||
empty={
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No metered usage yet.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{rows.map((row) => (
|
||||
<li key={row.category} className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0">
|
||||
<div className="font-medium capitalize">{row.category}</div>
|
||||
<div className="grid gap-1 text-sm text-muted-foreground sm:grid-cols-3">
|
||||
<UsageMeter
|
||||
label="This minute"
|
||||
used={row.usage.minute}
|
||||
limit={row.quota.enabled ? row.quota.calls_per_minute : null}
|
||||
/>
|
||||
<UsageMeter
|
||||
label="Today"
|
||||
used={row.usage.day}
|
||||
limit={row.quota.enabled ? row.quota.calls_per_day : null}
|
||||
/>
|
||||
<UsageMeter
|
||||
label="This month"
|
||||
used={row.usage.month}
|
||||
limit={row.quota.enabled ? row.quota.calls_per_month : null}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageMeter({
|
||||
label,
|
||||
used,
|
||||
limit,
|
||||
}: {
|
||||
label: string
|
||||
used: number
|
||||
limit: number | null
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-wider">{label}</span>
|
||||
<div className="text-foreground">
|
||||
{used} {limit != null ? `/ ${limit}` : "/ no limit"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
app/components/tenant-detail/section.tsx
Normal file
97
app/components/tenant-detail/section.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { DialogError } from "~/components/data-state"
|
||||
|
||||
/**
|
||||
* The shared frame every tenant-detail settings tab renders through: a titled
|
||||
* card whose footer holds a Save button and, on failure, an inline error in the
|
||||
* same place (never a page-level banner). Keeps the eight tabs visually and
|
||||
* behaviourally identical so the operator learns the surface once.
|
||||
*/
|
||||
export function TenantSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
onSubmit,
|
||||
saving,
|
||||
error,
|
||||
errorContext,
|
||||
saveLabel = "Save changes",
|
||||
dirty = true,
|
||||
dataAction,
|
||||
footerExtra,
|
||||
}: {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
onSubmit: () => void
|
||||
saving: boolean
|
||||
error: unknown
|
||||
errorContext: string
|
||||
saveLabel?: string
|
||||
/** Disable Save until something changed. Defaults to always-enabled. */
|
||||
dirty?: boolean
|
||||
dataAction: string
|
||||
/** Rendered to the left of Save (e.g. a "Send test" or "Remove" button). */
|
||||
footerExtra?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
{children}
|
||||
|
||||
{error ? <DialogError error={error} context={errorContext} /> : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<div>{footerExtra}</div>
|
||||
<Button type="submit" disabled={saving || !dirty} data-action={dataAction}>
|
||||
{saving ? "Saving…" : saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** A labelled form row — label, control, and an optional hint underneath. */
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
htmlFor?: string
|
||||
hint?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={htmlFor} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user