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:
jules
2026-07-14 13:43:57 +10:00
parent 938143f3f5
commit 7415b40240
51 changed files with 5923 additions and 4575 deletions

3
.gitignore vendored
View File

@@ -15,3 +15,6 @@
# Generated by `npm run build:docs` — regenerated on every full build
# (prebuild) and on demand during dev. Don't commit the artifact.
/public/docs-index.json
# impeccable tooling scratch (critique snapshots, live-server state)
.impeccable/

View File

@@ -26,7 +26,7 @@ This file is a quick map, not a duplication of upstream docs.
- `npm run dev` — Vite dev server (React Router 7).
- `npm run build` — production build (`react-router build`).
- `npm run start` — serve the built app (`react-router-serve ./build/server/index.js`).
- `npm run typecheck``react-router typegen && tsc`. See gotcha below; may crash.
- `npm run typecheck``react-router typegen && tsc`. **Works** (2026-07-14). Run it before every commit.
- `start.sh` / `stop.sh` — repo's preferred way to run/stop the dev server in the background.
- `npm run test` — Vitest run (vibespace-inherited setup; jsdom + @testing-library/react).
@@ -141,6 +141,7 @@ This repo was scaffolded from `create-crema-app`, which patches marker comments.
## Known gotchas
- `npm run typecheck` may crash with a TypeScript internal error — pre-existing in the Crema toolchain. There's no test runner here, so rely on careful reads + dev server.
- `npm run typecheck` **does not crash** — verified 2026-07-14. The old "it crashes, rely on careful reads" note was stale, and it cost us: a route shipped using `Input`/`Textarea` without importing them, which `tsc` reports instantly as TS2304 but nobody was running it. It currently reports ~39 pre-existing errors in `app/` (mostly `TS2322` prop mismatches) and more in sibling libs; treat *new* errors as blocking even while that backlog stands.
- Every route that renders `<AppShell>` re-exports a shared route-level error boundary (`app/components/route-error.tsx`). Keep that export when adding routes — without it, one crashing panel replaces the entire console with an unstyled stack trace and strands the operator with no nav.
- Vite "Outdated Optimize Dep" 504s after editing `vite.config.ts` or `tsconfig.json`: stop dev, `rm -rf node_modules/.vite`, restart, hard-reload.
- After editing a sibling lib's exports, the dev server sometimes needs a manual restart to pick up the new types.

40
PRODUCT.md Normal file
View File

@@ -0,0 +1,40 @@
# Product
## Register
product
## Users
Two audiences share one build, split by JWT capability gating:
- **Platform operators** (`platform_admin`) — Sky AI staff running the whole arcadia-core fleet: provisioning tenants, watching monitoring, rotating secrets, managing SSO, announcements, status page, integrations. Expert, technical, use it daily, usually on desktop.
- **Tenant admins** (`tenant_admin`) — a customer's administrator managing their own tenant: users, memberships, plan, entitlements, storage, activity. Semi-technical; may visit rarely (only when something needs changing), so rediscoverability matters more than muscle memory.
The job to be done is administrative control of a multi-tenant agentic-cloud platform: "get in, change the thing safely, verify it took effect, get out."
## Product Purpose
Arcadia Admin is the operator/admin UI for arcadia-core (multi-tenant Phoenix backend). It surfaces tenant lifecycle, user/role administration, billing (apps/plan/entitlements), storage, secrets, webhooks, scheduled tasks, SSO, announcements, status page, monitoring, and audit on top of arcadia's `/api/v1` and `/admin/*` endpoints. Success = an operator can onboard a new tenant end-to-end and change any runtime setting without reaching for iex/mix tasks or SSH.
## Brand Personality
Calm, capable, trustworthy. "Premium AI-first glass" (Skyrise theme) but the register is product: the tool should disappear into the task. Confidence through clarity and safe defaults, not decoration.
## Anti-references
- Enterprise admin sprawl (endless nested settings à la old AWS console).
- Toy dashboards — fake stat tiles, decorative charts with no drill-down.
- Anything that makes destructive platform actions (delete tenant, rotate secret) feel casual.
## Design Principles
1. **Safe by construction** — destructive/irreversible actions are explicit, confirmed, and auditable; errors name the fix.
2. **One contract, everywhere** — capability map drives nav and guards; `data-action` ids on every interactive element (AI/script drivable).
3. **Verify what you changed** — every mutation reflects visible state (toast + updated row + audit trail), never silent success.
4. **Recognition over recall** — a tenant admin who shows up quarterly should find everything from the sidebar without training.
5. **Density where experts live** — tables and panels can be dense, but each screen has one clear primary action.
## Accessibility & Inclusion
WCAG AA floor (4.5:1 body text), full keyboard operability, visible focus, labeled icons, `prefers-reduced-motion` honored. Desktop-first but mobile shell must remain usable (operators respond to incidents from phones).

View File

@@ -0,0 +1,183 @@
import { useEffect, useState, type ReactNode } from "react"
import { AlertTriangle, RefreshCw, ShieldOff, WifiOff } from "lucide-react"
import { LoadingOverlay } from "@crema/feedback-ui"
import { Button } from "~/components/ui/button"
import { describeError, type LoadError } from "~/lib/errors"
/**
* The load-state discriminator every list screen renders through.
*
* The rule it enforces: **a failed load is never an empty one.** Screens used
* to render their "No events match those filters — loosen the filter set…"
* empty state underneath a red "Too Many Requests" banner, so an operator
* couldn't tell a quiet audit log from a broken one. Exactly one of
* error / loading / empty / content renders here, ever.
*/
export function DataState({
loading,
error,
isEmpty,
empty,
onRetry,
loadingLabel = "Loading…",
children,
}: {
loading: boolean
/** The raw thrown value; normalised for display here. */
error: unknown
isEmpty: boolean
/** What to show when the load succeeded and there is genuinely nothing. */
empty: ReactNode
onRetry: () => void
loadingLabel?: string
children: ReactNode
}) {
if (error) return <ErrorState error={error} onRetry={onRetry} />
// First load: nothing to show yet. Subsequent refreshes keep the table on
// screen and let the table's own `loading` prop dim it, so the page doesn't
// flash empty every time an operator hits Refresh.
if (loading && isEmpty)
return (
<div className="relative min-h-40">
<LoadingOverlay active label={loadingLabel} />
</div>
)
if (isEmpty) return <>{empty}</>
return <>{children}</>
}
export function ErrorState({
error,
onRetry,
}: {
error: unknown
onRetry: () => void
}) {
const d: LoadError = describeError(error)
const Icon =
d.status === 403 || d.status === 401
? ShieldOff
: d.title === "Can't reach arcadia"
? WifiOff
: AlertTriangle
return (
<div className="flex flex-col items-center gap-3 px-6 py-12 text-center">
<div className="flex size-10 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<Icon className="size-5" />
</div>
<div className="max-w-md space-y-1">
<p className="font-medium">{d.title}</p>
{d.detail ? (
<p className="text-sm text-muted-foreground">{d.detail}</p>
) : null}
{d.fields?.length ? (
<ul className="mt-1 space-y-0.5 text-sm text-muted-foreground">
{d.fields.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
) : null}
</div>
{d.retryable ? (
d.retryAfterSec ? (
<AutoRetry seconds={d.retryAfterSec} onRetry={onRetry} />
) : (
<Button
data-action="data-state-retry"
variant="outline"
size="sm"
onClick={onRetry}
>
<RefreshCw className="size-4" />
Retry
</Button>
)
) : null}
</div>
)
}
/**
* An error raised while a dialog is open, rendered *inside* that dialog.
*
* Page-level banners are invisible here: the modal scrim dims them and the
* dialog covers them. A failed submit has to speak where the operator is
* looking — right above the buttons they just pressed.
*/
export function DialogError({
error,
context = "save",
}: {
error: unknown
context?: string
}) {
const d = describeError(error, context)
return (
<div
role="alert"
className="flex items-start gap-2.5 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 space-y-0.5 text-sm">
<p className="font-medium text-destructive">{d.title}</p>
{d.detail ? <p className="text-muted-foreground">{d.detail}</p> : null}
{d.fields?.length ? (
<ul className="space-y-0.5 text-muted-foreground">
{d.fields.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
) : null}
</div>
</div>
)
}
/** 429s resolve on their own — count down, retry, and say so. Nagging the
* operator to click Retry into a rate limiter would just extend it. */
function AutoRetry({
seconds,
onRetry,
}: {
seconds: number
onRetry: () => void
}) {
const [left, setLeft] = useState(seconds)
useEffect(() => {
if (left <= 0) {
onRetry()
return
}
const t = setTimeout(() => setLeft((n) => n - 1), 1000)
return () => clearTimeout(t)
// `onRetry` is intentionally excluded: routes hand us a fresh closure each
// render, and depending on it would reset the countdown forever.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [left])
return (
<div className="flex items-center gap-2">
<Button
data-action="data-state-retry"
variant="outline"
size="sm"
onClick={onRetry}
>
<RefreshCw className="size-4" />
Retry now
</Button>
<span className="text-xs text-muted-foreground" aria-live="polite">
retrying in {left}s
</span>
</div>
)
}

View File

@@ -18,7 +18,6 @@ import {
PanelLeftOpen,
User as UserIcon,
LogOut,
HelpCircle,
Menu,
Play,
HardDrive,
@@ -38,8 +37,6 @@ import {
Database,
Plug,
MessageSquare,
Eye,
LayoutGrid,
CreditCard,
// CREMA:NAV-ICONS
} from "lucide-react"
@@ -76,7 +73,6 @@ import {
dismissAll,
markAllRead,
markRead,
seedIfEmpty,
unreadCount,
useNotifications,
} from "~/lib/notifications"
@@ -121,7 +117,10 @@ const pinnedTop: NavItem[] = [
]
// Pinned items render flat at the bottom of the rail, below all groups.
// Audit log is cross-cutting rather than owned by any one group, so it sits
// here next to Settings.
const pinnedBottom: NavItem[] = [
{ to: "/audit-log", icon: Activity, label: "Audit log" },
{ to: "/settings", icon: Settings, label: "Settings" },
]
@@ -132,9 +131,9 @@ const navGroups: NavGroup[] = [
icon: Building2,
items: [
{ to: "/tenants", icon: Building2, label: "Tenants" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/organizations", icon: Building, label: "Organizations" },
{ to: "/users", icon: UsersIcon, label: "Users" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
],
},
@@ -142,11 +141,9 @@ const navGroups: NavGroup[] = [
key: "billing",
label: "Billing",
icon: CreditCard,
items: [
{ to: "/apps", icon: LayoutGrid, label: "Apps" },
{ to: "/plan", icon: CreditCard, label: "Plan" },
{ to: "/entitlements", icon: Gauge, label: "Entitlements" },
],
// One item today (Plan/Entitlements/Apps collapsed here — none has a live
// endpoint yet). They split back into siblings under this group once wired.
items: [{ to: "/billing", icon: CreditCard, label: "Plan & usage" }],
},
{
key: "data",
@@ -156,17 +153,25 @@ const navGroups: NavGroup[] = [
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
{ to: "/integrations", icon: Plug, label: "Integrations" },
],
},
{
key: "integrations",
label: "Integrations",
key: "automation",
label: "Automation",
icon: Plug,
items: [
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/integrations", icon: Plug, label: "Integrations" },
],
},
{
key: "infrastructure",
label: "Infrastructure",
icon: Network,
items: [
{ to: "/networking", icon: Network, label: "Networking" },
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
],
},
{
@@ -178,15 +183,6 @@ const navGroups: NavGroup[] = [
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
],
},
{
key: "observability",
label: "Observability",
icon: Eye,
items: [
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
{ to: "/activity", icon: Activity, label: "Audit log" },
],
},
{
key: "ai",
label: "AI & Search",
@@ -260,8 +256,12 @@ export function AppShell({
// short-circuit so a sign-out doesn't reduce the hook count and trip
// React's "rendered fewer hooks than expected" check.
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1"
if (typeof window === "undefined") return true
// Default to expanded on first run — an icon-only rail of ~18 pictograms is
// unreadable to anyone who hasn't memorised it. Collapse stays available and
// is remembered once chosen.
const stored = localStorage.getItem(SIDEBAR_KEY)
return stored === null ? true : stored === "1"
})
useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
@@ -627,9 +627,6 @@ export function AppShell({
>
<Settings /> Settings
</DropdownMenuItem>
<DropdownMenuItem data-action="avatar-help">
<HelpCircle /> Help
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-signout"
@@ -795,10 +792,6 @@ function NotificationsBell() {
const unread = unreadCount(items)
const navigate = useNavigate()
useEffect(() => {
seedIfEmpty()
}, [])
return (
<Popover>
<PopoverTrigger

View File

@@ -0,0 +1,104 @@
import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router"
import { AlertTriangle, RefreshCw, Home } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
/**
* Route-level error boundary.
*
* Re-export this as `ErrorBoundary` from any route and a crash in that route
* degrades to a single explained card *inside the shell* — the nav, the theme
* and every other screen stay reachable. The root boundary in `root.tsx` still
* exists as the last resort, but it replaces the whole app with an unstyled
* stack trace, which strands an operator mid-incident with no way out.
*
* export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
*/
export function RouteErrorBoundary() {
const error = useRouteError()
const navigate = useNavigate()
let title = "This screen hit an error"
let description =
"Something on this page failed to render. The rest of the console still works — you can retry, or head back to the overview."
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
title = "That page doesn't exist"
description = "The link may be stale, or the screen may have been renamed."
} else {
title = `Request failed (${error.status})`
description =
error.statusText ||
"The server rejected this request. Retry, and if it keeps failing check the service logs."
}
}
// The message is useful to an operator even in prod — it's their own platform.
// The stack is noise unless you're the one fixing it, so it stays in dev.
const message = error instanceof Error ? error.message : null
const stack =
import.meta.env.DEV && error instanceof Error ? error.stack : undefined
return (
<AppShell>
<Card>
<CardHeader>
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div className="min-w-0">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{message ? (
<p className="rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs text-muted-foreground">
{message}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="route-error-retry"
onClick={() => window.location.reload()}
>
<RefreshCw className="size-4" />
Retry
</Button>
<Button
data-action="route-error-home"
variant="outline"
onClick={() => navigate("/")}
>
<Home className="size-4" />
Back to overview
</Button>
</div>
{stack ? (
<details className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
<summary className="cursor-pointer text-muted-foreground">
Stack trace (dev only)
</summary>
<pre className="mt-2 overflow-x-auto text-xs">
<code>{stack}</code>
</pre>
</details>
) : null}
</CardContent>
</Card>
</AppShell>
)
}

View File

@@ -39,9 +39,10 @@ function Forbidden({ capability }: { capability: Capability }) {
<ShieldAlert className="size-10 text-muted-foreground" />
<h2 className="text-lg font-semibold">You can't access this page</h2>
<p className="text-sm text-muted-foreground">
This view requires the <code className="font-mono text-xs">{capability}</code>{" "}
capability on your active tenant. If you think you should have it,
switch tenants from the avatar menu or ask an admin.
This view needs the{" "}
<code className="font-mono text-xs">{capability}</code> capability,
which your account doesn't hold on the current tenant. Ask a platform
administrator to grant it.
</p>
</CardContent>
</Card>

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

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

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

View 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"
}

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

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

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

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

View File

@@ -6,7 +6,7 @@
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
// and drop these manual types.
import type { ArcadiaClient } from "@crema/arcadia-core-client"
import { ArcadiaError, type ArcadiaClient } from "@crema/arcadia-core-client"
export type TenantStatus = "active" | "suspended" | "deactivated" | string
@@ -111,3 +111,427 @@ export async function provisionTenant(
})
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
}
}

View File

@@ -114,11 +114,12 @@ export const ROUTE_CAPABILITY: Record<string, Capability> = {
"/memberships": "tenant.memberships",
"/storage": "tenant.storage",
"/buckets": "tenant.buckets",
"/activity": "tenant.activity",
"/audit-log": "tenant.activity",
"/activity": "tenant.activity", // legacy path → redirects to /audit-log
"/settings": "tenant.settings",
"/apps": "tenant.apps",
"/plan": "tenant.plan",
"/entitlements": "tenant.entitlements",
// Plan, Entitlements, and Apps collapsed into one Billing surface (Phase 3).
// They split back out under this same capability set once wired (Phase 5).
"/billing": "tenant.plan",
"/tenants": "platform.tenants",
"/organizations": "platform.organizations",

134
app/lib/errors.ts Normal file
View File

@@ -0,0 +1,134 @@
// One place that turns whatever the API threw into something an operator can
// act on. Before this existed, every list screen surfaced the raw status text
// ("Internal Server Error", "Too Many Requests", "Bad Request") — which names
// the failure but never the fix — and, worse, rendered its empty state *next
// to* the error, so "the load failed" and "there is nothing here" looked
// identical. An operator can't tell an empty audit log from a broken one.
import { ArcadiaError } from "@crema/arcadia-core-client"
export type LoadError = {
/** Plain-language headline. Never a raw HTTP status. */
title: string
/** What to do about it. Empty when there's genuinely nothing to suggest. */
detail: string
status?: number
/** Set for 429s — seconds until it's worth retrying. Drives auto-retry. */
retryAfterSec?: number
/** True when retrying might plausibly work (5xx, 429, network). */
retryable: boolean
/** Field-level validation messages, flattened from Ecto's error tree. */
fields?: string[]
}
/** Flatten Ecto's nested `{tenant: {slug: ["has already been taken"]}}`. */
function flattenFieldErrors(details: unknown): string[] {
const lines: string[] = []
const walk = (obj: unknown, prefix: string) => {
if (Array.isArray(obj)) {
lines.push(prefix ? `${prefix}: ${obj.join(", ")}` : obj.join(", "))
} else if (obj && typeof obj === "object") {
for (const [k, v] of Object.entries(obj)) {
walk(v, prefix ? `${prefix}.${k}` : k)
}
}
}
walk(details, "")
return lines
}
export function describeError(err: unknown, context = "load"): LoadError {
// Network / CORS / server down — fetch rejects before any status exists.
if (err instanceof TypeError || (err instanceof Error && /fetch/i.test(err.message))) {
return {
title: "Can't reach arcadia",
detail:
"The API didn't respond. Check the service is running and that this host is allowed to call it, then retry.",
retryable: true,
}
}
if (!(err instanceof ArcadiaError)) {
return {
title: `Couldn't ${context}`,
detail: err instanceof Error && err.message ? err.message : "An unexpected error occurred.",
retryable: true,
}
}
const fields = err.details ? flattenFieldErrors(err.details) : undefined
switch (true) {
case err.status === 401:
return {
title: "Your session has expired",
detail: "Sign in again to continue.",
status: 401,
retryable: false,
}
case err.status === 403:
return {
title: "You don't have access to this",
detail:
"Your account lacks the role this screen needs. A platform administrator can grant it.",
status: 403,
retryable: false,
}
case err.status === 404:
return {
title: "Not found",
detail: "It may have been deleted, or the endpoint isn't available on this deployment.",
status: 404,
retryable: false,
}
case err.status === 422:
return {
title: "That didn't validate",
detail: fields?.length ? "" : err.message,
status: 422,
retryable: false,
fields,
}
case err.status === 429:
return {
title: "Too many requests",
detail: "arcadia is rate-limiting this console. It'll retry automatically.",
status: 429,
retryAfterSec: 30,
retryable: true,
}
case err.status >= 500:
return {
title: "arcadia hit a server error",
detail: `The request failed on the server${
err.requestId ? ` (request ${err.requestId})` : ""
}. Retry, and if it persists check the service logs.`,
status: err.status,
retryable: true,
}
default:
return {
title: `Couldn't ${context}`,
// Prefer the server's own message over the bare status line.
detail: fields?.length ? "" : err.message,
status: err.status,
retryable: err.status >= 500,
fields,
}
}
}
/** One-line form, for toasts and inside dialogs. */
export function errorMessage(err: unknown, context = "save"): string {
const d = describeError(err, context)
const parts = [d.title]
if (d.fields?.length) parts.push(d.fields.join("; "))
else if (d.detail) parts.push(d.detail)
return parts.join(" — ")
}

View File

@@ -2,7 +2,7 @@
// Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
// this store is for the appbar bell's persistent inbox.
import { useEffect, useSyncExternalStore } from "react"
import { useSyncExternalStore } from "react"
export type NotificationKind = "info" | "success" | "warning" | "error"
@@ -95,11 +95,27 @@ export function dismissAll() {
writeToStorage([])
}
let cached: AppNotification[] | null = null
// Cache keyed on the raw stored string so the snapshot stays referentially
// stable — `useSyncExternalStore` requires that getSnapshot return the same
// reference until the value genuinely changes. (This used to clear a flag on
// every mount without notifying subscribers, the same identity-churn bug that
// was fixed in session.ts.)
let cached: AppNotification[] = []
let cachedRaw: string | null = null
let primed = false
function readRaw(): string | null {
if (typeof window === "undefined") return null
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
primed = false
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
@@ -109,7 +125,12 @@ function subscribe(cb: () => void): () => void {
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): AppNotification[] {
if (!cached) cached = readFromStorage()
const raw = readRaw()
if (!primed || raw !== cachedRaw) {
cachedRaw = raw
cached = readFromStorage()
primed = true
}
return cached
}
function getServerSnapshot(): AppNotification[] {
@@ -117,39 +138,9 @@ function getServerSnapshot(): AppNotification[] {
}
export function useNotifications(): AppNotification[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
export function unreadCount(items: AppNotification[]): number {
return items.filter((n) => !n.readAt).length
}
/** Seed a few demo notifications on first load so the bell isn't empty. */
export function seedIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: AppNotification[] = [
{
id: newId(),
kind: "info",
title: "Welcome",
body: "Tag elements with data-action and the assistant can drive them.",
href: "/assistant",
createdAt: now - 60_000,
},
{
id: newId(),
kind: "success",
title: "Profile saved",
body: "Your display name and avatar are live across the app.",
href: "/profile",
createdAt: now - 5 * 60_000,
},
]
writeToStorage(seed)
}

View File

@@ -3,7 +3,7 @@
// routes after a successful arcadia API exchange. The shape here matches what
// AppShell + useUser expect.
import { useEffect, useSyncExternalStore } from "react"
import { useSyncExternalStore } from "react"
import { profileInitials } from "~/lib/profile"
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
@@ -157,12 +157,34 @@ export function hasSession(): boolean {
return !!readFromStorage()
}
// `useSyncExternalStore` demands a *referentially stable* snapshot: it must
// return the identical object until the underlying value genuinely changes.
// Keying the cache on the raw stored string gives us that for free — reparse
// only when the bytes differ.
//
// This used to keep a `cacheValid` flag that `useSession` cleared on mount,
// which meant the very next render reparsed storage and produced a brand-new
// Session object. Every `useEffect([session, …])` in the app then saw a
// "changed" session and refetched: three identical GETs per list screen, and
// enough request volume during navigation to trip arcadia's own rate limiter
// and greet the operator with 429 banners. The session had not changed at all.
let cached: Session | null = null
let cacheValid = false
let cachedRaw: string | null = null
let primed = false
function readRaw(): string | null {
if (typeof window === "undefined") return null
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
function subscribe(cb: () => void): () => void {
const onChange = () => {
cacheValid = false
// Force the next getSnapshot to reparse, then let React re-render.
primed = false
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
@@ -171,23 +193,25 @@ function subscribe(cb: () => void): () => void {
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Session | null {
if (!cacheValid) {
const raw = readRaw()
if (!primed || raw !== cachedRaw) {
cachedRaw = raw
// readFromStorage re-validates expiry and may clear the token; when it
// does, `raw` differs on the next read and we reparse again.
cached = readFromStorage()
cacheValid = true
primed = true
}
return cached
}
function getServerSnapshot(): Session | null {
return null
}
export function useSession(): Session | null {
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cacheValid = false
}, [])
return s
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
export function sessionInitials(session: Session | null): string {

View File

@@ -2,10 +2,9 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"
export default [
index("routes/home.tsx"),
route("activity", "routes/activity.tsx"),
route("assistant", "routes/assistant.tsx"),
route("audit-log", "routes/activity.tsx"),
route("activity", "routes/activity-redirect.tsx"),
route("ai", "routes/ai.tsx"),
route("library", "routes/library.tsx"),
route("settings", "routes/settings.tsx"),
route("profile", "routes/profile.tsx"),
route("login", "routes/login.tsx"),
@@ -14,6 +13,7 @@ export default [
route("login/2fa", "routes/login.2fa.tsx"),
route("signup", "routes/signup.tsx"),
route("tenants", "routes/tenants.tsx"),
route("tenants/:id", "routes/tenants.$id.tsx"),
route("storage", "routes/storage.tsx"),
route("users", "routes/users.tsx"),
route("secrets", "routes/secrets.tsx"),
@@ -28,9 +28,7 @@ export default [
route("announcements", "routes/announcements.tsx"),
route("status-page", "routes/status-page.tsx"),
route("search", "routes/search.tsx"),
route("apps", "routes/apps.tsx"),
route("plan", "routes/plan.tsx"),
route("entitlements", "routes/entitlements.tsx"),
route("billing", "routes/billing.tsx"),
route("integrations", "routes/integrations.tsx"),
// CREMA:ROUTES
] satisfies RouteConfig

View File

@@ -0,0 +1,13 @@
// The audit log now lives at /audit-log — the nav label and the URL finally
// agree. This keeps old /activity links (bookmarks, deep links) working.
// SPA build (ssr:false), so the redirect must run in the browser: clientLoader,
// not loader (a plain loader never executes when there's no server).
import { redirect } from "react-router"
export function clientLoader() {
return redirect("/audit-log")
}
export default function ActivityRedirect() {
return null
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Activity, Eye, RefreshCw } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import {
ActionsCell,
BadgeCell,
@@ -13,9 +13,10 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState } from "~/components/data-state"
import { Button } from "~/components/ui/button"
import {
Card,
@@ -57,7 +58,9 @@ export default function ActivityRoute() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// The raw thrown value. `DataState` turns it into plain language — and a 429
// must never be mistaken for "no events match those filters".
const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("")
const [severityFilter, setSeverityFilter] = useState<"all" | AuditSeverity>("all")
const [resourceFilter, setResourceFilter] = useState("")
@@ -78,7 +81,7 @@ export default function ActivityRoute() {
})
setLogs(list)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load audit logs.")
setError(err)
} finally {
setLoading(false)
}
@@ -223,12 +226,6 @@ export default function ActivityRoute() {
</Button>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end">
<SearchInput
@@ -300,16 +297,21 @@ export default function ActivityRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && logs.length === 0} label="Loading audit log…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading audit log…"
empty={
<EmptyState
icon={<Activity className="size-6" />}
title="No events match those filters."
description="Loosen the filter set or wait for new platform activity."
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -326,8 +328,7 @@ export default function ActivityRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</div>
@@ -406,3 +407,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return acc
}, {})
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -2371,3 +2371,5 @@ function VoiceInputButton({
</button>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -7,7 +7,8 @@ import {
Trash2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -20,9 +21,14 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
// AlertBanner is imported for the *preview* below — it is the very component
// the published announcement renders as in every Sky AI app. It is no longer
// used to report errors or successes; those are DataState / DialogError / toasts.
import { AlertBanner, ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -110,12 +116,13 @@ type Editor =
export default function AnnouncementsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [items, setItems] = useState<Announcement[]>([])
const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Raw thrown value — `DataState` normalises it. Successes are toasts now.
const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<Announcement | null>(null)
@@ -128,13 +135,16 @@ export default function AnnouncementsRoute() {
try {
const [a, t] = await Promise.all([
listAnnouncements(arcadia),
// Tenants only label the audience column and fill the scope picker.
// Losing them degrades those two spots; it doesn't make the
// announcements list wrong, so it must not fail the whole screen.
listTenants(arcadia).catch(() => [] as Tenant[]),
])
setItems(a)
setTenants(t)
setRefreshedAt(Date.now())
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
setError(err)
} finally {
setLoading(false)
}
@@ -237,10 +247,17 @@ export default function AnnouncementsRoute() {
onSelect: async () => {
try {
await updateAnnouncement(arcadia, a.id, { active: !a.active })
setInfo(a.active ? "Announcement deactivated." : "Announcement activated.")
await refresh()
toast.success(
a.active ? `Deactivated "${a.title}"` : `Activated "${a.title}"`,
)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Toggle failed.")
toast.error(
errorMessage(
err,
`${a.active ? "deactivate" : "activate"} "${a.title}"`,
),
)
}
},
},
@@ -257,7 +274,7 @@ export default function AnnouncementsRoute() {
},
},
],
[arcadia, refresh, tenants],
[arcadia, refresh, tenants, toast],
)
const summary = useMemo(
@@ -329,17 +346,6 @@ export default function AnnouncementsRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
@@ -359,11 +365,13 @@ export default function AnnouncementsRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && items.length === 0}
label="Loading announcements…"
/>
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading announcements…"
empty={
<EmptyState
icon={
<div
@@ -407,8 +415,8 @@ export default function AnnouncementsRoute() {
)
}
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -425,8 +433,7 @@ export default function AnnouncementsRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</div>
@@ -440,14 +447,15 @@ export default function AnnouncementsRoute() {
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const title = pendingDelete.title
try {
await deleteAnnouncement(arcadia, pendingDelete.id)
setPendingDelete(null)
setInfo("Announcement deleted.")
await refresh()
toast.success(`Deleted "${title}"`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete "${title}"`))
}
}}
/>
@@ -458,10 +466,9 @@ export default function AnnouncementsRoute() {
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(msg)
}}
onError={setError}
/>
</AppShell>
)
@@ -487,13 +494,11 @@ function AnnouncementEditorDialog({
tenants,
onClose,
onSaved,
onError,
}: {
state: Editor
tenants: Tenant[]
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (msg: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -512,7 +517,9 @@ function AnnouncementEditorDialog({
const [dismissible, setDismissible] = useState(true)
const [active, setActive] = useState(true)
const [saving, setSaving] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
// A failed publish speaks inside the dialog, right above the button that was
// pressed. Hoisting it to a page banner would put it behind the modal scrim.
const [localError, setLocalError] = useState<unknown>(null)
useEffect(() => {
if (!open) setLocalError(null)
@@ -548,7 +555,6 @@ function AnnouncementEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setLocalError(null)
setSaving(true)
try {
@@ -567,19 +573,17 @@ function AnnouncementEditorDialog({
}
if (isEdit && initial) {
await updateAnnouncement(arcadia, initial.id, input)
await onSaved("Announcement updated.")
await onSaved(`Updated "${title}"`)
} else {
await createAnnouncement(arcadia, input)
await onSaved("Announcement posted.")
await onSaved(
active ? `Published "${title}"` : `Saved draft "${title}"`,
)
}
} catch (err) {
const msg =
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed."
setLocalError(msg)
// Keep the dialog open with the form intact so the operator can fix and
// resubmit without retyping the whole banner.
setLocalError(err)
} finally {
setSaving(false)
}
@@ -630,16 +634,6 @@ function AnnouncementEditorDialog({
</div>
</div>
{localError ? (
<AlertBanner
variant="error"
dismissible
onDismiss={() => setLocalError(null)}
>
{localError}
</AlertBanner>
) : null}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="ann-title">Title</Label>
@@ -784,6 +778,13 @@ function AnnouncementEditorDialog({
</div>
</div>
{localError ? (
<DialogError
error={localError}
context={isEdit ? "save the announcement" : "publish the announcement"}
/>
) : null}
<DialogFooter className="flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Active = publish state, paired with the publish button. */}
<label
@@ -821,3 +822,5 @@ function AnnouncementEditorDialog({
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -1,44 +0,0 @@
// Tenant-scoped "Apps" — placeholder. Real surface is the apps this
// tenant publishes (and their per-app users/grants on the personal
// cloud side). Wired into the nav so tenant admins see the route they
// expect; data layer follows.
import { LayoutGrid } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
export default function AppsRoute() {
return (
<AppShell>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<LayoutGrid className="size-5" />
</div>
<div>
<h1 className="text-2xl font-semibold">Apps</h1>
<p className="text-sm text-muted-foreground">
Apps this tenant publishes and the users that have granted them
access to their personal clouds.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Coming soon</CardTitle>
<CardDescription>
App authoring lives in arcadia-agents-manager today. This view will
surface published apps + per-app grants once the catalog endpoint
is wired.
</CardDescription>
</CardHeader>
<CardContent />
</Card>
</AppShell>
)
}

File diff suppressed because it is too large Load Diff

81
app/routes/billing.tsx Normal file
View File

@@ -0,0 +1,81 @@
// Billing — one honest home for the three surfaces that were previously three
// separate "Coming soon" nav items (Plan, Entitlements, Apps). None of them
// has a live endpoint yet, so none earns its own nav weight; they collapse to
// this single page until Phase 5 wires them. When Plan and Entitlements go
// live they become their own items under the Billing group and this becomes
// the group overview.
import { CreditCard, Gauge, LayoutGrid } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { pageTitle } from "~/lib/page-meta"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
export const meta = () => pageTitle("Billing")
const upcoming = [
{
icon: CreditCard,
title: "Plan",
description:
"The tenant's subscription, renewal date, payment method, and invoice history.",
},
{
icon: Gauge,
title: "Entitlements",
description:
"Metered allowances — included units and usage to date per meter (AI tokens, storage, and so on).",
},
{
icon: LayoutGrid,
title: "Apps",
description:
"Apps this tenant publishes, and the users who've granted them access to their personal clouds.",
},
]
export default function BillingRoute() {
return (
<AppShell>
<PageHeader
title="Billing"
description="Subscription, metered usage, and published apps for this tenant."
/>
<Card>
<CardHeader>
<CardTitle>Not yet available on this deployment</CardTitle>
<CardDescription>
Billing isn't wired to a payment provider here. These are the
surfaces that will land under Billing each becomes its own screen
once its endpoint exists.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="flex flex-col divide-y">
{upcoming.map(({ icon: Icon, title, description }) => (
<li key={title} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="min-w-0">
<p className="font-medium">{title}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</AppShell>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -17,7 +17,8 @@ import {
Trash2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
DataTable,
@@ -28,11 +29,13 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { EmptyState } from "@crema/feedback-ui"
import { FileGrid, FileList, formatBytes, type FileItem } from "@crema/file-ui"
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -102,6 +105,7 @@ type Editor =
export default function BucketsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [configs, setConfigs] = useState<StorageConfig[]>([])
const [configId, setConfigId] = useState<string>(() =>
@@ -111,8 +115,13 @@ export default function BucketsRoute() {
)
const [buckets, setBuckets] = useState<Bucket[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Two independently-loaded lists, two error slots. A dead /storage_configs
// must not read as "no buckets", and a dead /buckets must not blank the
// config picker.
const [error, setError] = useState<unknown>(null)
const [configsError, setConfigsError] = useState<unknown>(null)
const [configsLoading, setConfigsLoading] = useState(true)
const [configsReloadKey, setConfigsReloadKey] = useState(0)
const [view, setView] = useState<View>({ kind: "list" })
const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<Bucket | null>(null)
@@ -126,6 +135,8 @@ export default function BucketsRoute() {
useEffect(() => {
if (!session) return
let mounted = true
setConfigsLoading(true)
setConfigsError(null)
listStorageConfigs(arcadia)
.then((rows) => {
if (!mounted) return
@@ -142,16 +153,17 @@ export default function BucketsRoute() {
setConfigId(def?.id ?? "")
}
})
.catch((err) =>
setError(
err instanceof ArcadiaError ? err.message : "Failed to load storage configs.",
),
)
.catch((err) => {
if (mounted) setConfigsError(err)
})
.finally(() => {
if (mounted) setConfigsLoading(false)
})
return () => {
mounted = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session, arcadia])
}, [session, arcadia, configsReloadKey])
useEffect(() => {
if (configId) localStorage.setItem(SELECTED_CONFIG_KEY, configId)
@@ -167,7 +179,7 @@ export default function BucketsRoute() {
try {
setBuckets(await listBuckets(arcadia, configId))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load buckets.")
setError(err)
} finally {
setLoading(false)
}
@@ -177,6 +189,14 @@ export default function BucketsRoute() {
refresh()
}, [refresh])
// The buckets table can't do its job without a config, so a failed config
// load surfaces there too — otherwise it would read as "pick a config" with
// an empty picker and no explanation.
const retryAll = useCallback(() => {
setConfigsReloadKey((n) => n + 1)
refresh()
}, [refresh])
const summary = useMemo(
() => ({
storage_config: activeConfig
@@ -249,17 +269,6 @@ export default function BucketsRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
{view.kind === "list" ? (
<>
<Card>
@@ -317,7 +326,9 @@ export default function BucketsRoute() {
<BucketsTable
buckets={buckets}
loading={loading}
loading={loading || configsLoading}
error={error ?? configsError}
onRetry={retryAll}
hasConfig={!!configId}
onOpen={(b) => setView({ kind: "objects", bucket: b })}
onConfigure={(b) => setEditor({ kind: "configure", bucket: b })}
@@ -325,11 +336,7 @@ export default function BucketsRoute() {
/>
</>
) : (
<ObjectBrowser
storageConfigId={configId}
bucket={view.bucket}
onError={setError}
/>
<ObjectBrowser storageConfigId={configId} bucket={view.bucket} />
)}
</div>
@@ -338,12 +345,11 @@ export default function BucketsRoute() {
open={editor?.kind === "create"}
configId={configId}
onClose={() => setEditor(null)}
onCreated={async (msg) => {
onCreated={async (name) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(`Created bucket ${name}`)
}}
onError={setError}
/>
{/* Configure (versioning / CORS / policy) */}
@@ -352,10 +358,9 @@ export default function BucketsRoute() {
configId={configId}
onClose={() => setEditor(null)}
onChanged={async (msg) => {
if (msg) setInfo(msg)
await refresh()
toast.success(msg)
}}
onError={setError}
/>
{/* Delete */}
@@ -363,12 +368,11 @@ export default function BucketsRoute() {
bucket={pendingDelete}
configId={configId}
onClose={() => setPendingDelete(null)}
onDeleted={async (msg) => {
onDeleted={async (name) => {
setPendingDelete(null)
if (msg) setInfo(msg)
await refresh()
toast.success(`Deleted bucket ${name}`)
}}
onError={setError}
/>
</AppShell>
)
@@ -379,6 +383,8 @@ export default function BucketsRoute() {
function BucketsTable({
buckets,
loading,
error,
onRetry,
hasConfig,
onOpen,
onConfigure,
@@ -386,6 +392,9 @@ function BucketsTable({
}: {
buckets: Bucket[]
loading: boolean
/** Raw thrown value from either the buckets load or the configs load. */
error: unknown
onRetry: () => void
hasConfig: boolean
onOpen: (b: Bucket) => void
onConfigure: (b: Bucket) => void
@@ -525,23 +534,30 @@ function BucketsTable({
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && buckets.length === 0} label="Loading buckets…" />
{!hasConfig ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRetry}
loadingLabel="Loading buckets…"
empty={
!hasConfig ? (
<EmptyState
icon={<Boxes className="size-6" />}
title="Pick a storage configuration"
description="Buckets are scoped to a credential. Add one under Storage if you don't have any yet."
className="py-12"
/>
) : table.total === 0 && !loading ? (
) : (
<EmptyState
icon={<Boxes className="size-6" />}
title={search ? "No buckets match." : "No buckets in this account."}
description={search ? "Try a different search." : "Create your first bucket."}
className="py-12"
/>
) : (
<>
)
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -558,8 +574,7 @@ function BucketsTable({
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
)
@@ -570,23 +585,25 @@ function BucketsTable({
function ObjectBrowser({
storageConfigId,
bucket,
onError,
}: {
storageConfigId: string
bucket: Bucket
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [objects, setObjects] = useState<BucketObject[]>([])
const [prefix, setPrefix] = useState("")
const [loading, setLoading] = useState(true)
// The object listing owns its failure: a 403 on this bucket must not read as
// "Empty bucket."
const [error, setError] = useState<unknown>(null)
const [layout, setLayout] = useState<"grid" | "list">("list")
const [previewUrl, setPreviewUrl] = useState<{ url: string; key: string } | null>(null)
const [search, setSearch] = useState("")
const refresh = useCallback(async () => {
setLoading(true)
onError(null)
setError(null)
try {
const res = await listObjects(arcadia, {
storage_config_id: storageConfigId,
@@ -596,11 +613,11 @@ function ObjectBrowser({
})
setObjects(res.objects ?? [])
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Failed to load objects.")
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, storageConfigId, bucket.name, prefix, onError])
}, [arcadia, storageConfigId, bucket.name, prefix])
useEffect(() => {
refresh()
@@ -635,10 +652,10 @@ function ObjectBrowser({
})
setPreviewUrl({ url: res.url, key })
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Presign failed.")
toast.error(errorMessage(err, `sign a link for ${key}`))
}
},
[arcadia, storageConfigId, bucket.name, onError],
[arcadia, storageConfigId, bucket.name, toast],
)
return (
@@ -697,8 +714,13 @@ function ObjectBrowser({
</CardHeader>
<CardContent className="relative p-4">
<LoadingOverlay active={loading && objects.length === 0} label="Loading objects…" />
{fileItems.length === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={fileItems.length === 0}
onRetry={refresh}
loadingLabel="Loading objects…"
empty={
<EmptyState
icon={<FolderOpen className="size-6" />}
title={search || prefix ? "No matches." : "Empty bucket."}
@@ -709,7 +731,9 @@ function ObjectBrowser({
}
className="py-12"
/>
) : layout === "list" ? (
}
>
{layout === "list" ? (
<FileList
files={fileItems}
onItemClick={(f) => openPresigned(f.id)}
@@ -735,6 +759,7 @@ function ObjectBrowser({
minItemWidth={180}
/>
)}
</DataState>
</CardContent>
<PresignDialog reveal={previewUrl} onClose={() => setPreviewUrl(null)} />
@@ -812,13 +837,11 @@ function CreateBucketDialog({
configId,
onClose,
onCreated,
onError,
}: {
open: boolean
configId: string
onClose: () => void
onCreated: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onCreated: (name: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [name, setName] = useState("")
@@ -827,6 +850,10 @@ function CreateBucketDialog({
const [versioning, setVersioning] = useState(false)
const [regions, setRegions] = useState<string[]>([])
const [saving, setSaving] = useState(false)
// "Bucket names must be globally unique" is the single most likely failure
// here, and the provider's message is the only thing that explains it — so
// it renders in the dialog, next to the name the operator chose.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) {
@@ -834,8 +861,10 @@ function CreateBucketDialog({
setRegion("")
setAcl("private")
setVersioning(false)
setError(null)
return
}
setError(null)
if (configId) {
listRegions(arcadia, configId)
.then(setRegions)
@@ -844,7 +873,7 @@ function CreateBucketDialog({
}, [open, arcadia, configId])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
await createBucket(arcadia, {
@@ -854,15 +883,9 @@ function CreateBucketDialog({
acl,
versioning,
})
await onCreated(`Bucket ${name} created.`)
await onCreated(name)
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Create failed.",
)
setError(err)
} finally {
setSaving(false)
}
@@ -942,6 +965,8 @@ function CreateBucketDialog({
</div>
</div>
{error ? <DialogError error={error} context="create the bucket" /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="bucket-form-cancel">
Cancel
@@ -961,13 +986,11 @@ function ConfigureBucketDialog({
configId,
onClose,
onChanged,
onError,
}: {
state: { kind: "configure"; bucket: Bucket } | null
configId: string
onClose: () => void
onChanged: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onChanged: (msg: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [versioningOn, setVersioningOn] = useState(false)
@@ -976,38 +999,58 @@ function ConfigureBucketDialog({
const [corsRules, setCorsRules] = useState<CorsRule[]>([])
const [corsSaving, setCorsSaving] = useState(false)
const [corsLoading, setCorsLoading] = useState(false)
// The CORS load used to fail *silently* into an empty rule list — so a 500
// looked exactly like "this bucket has no CORS rules", and saving from that
// state would have wiped the real ones.
const [corsError, setCorsError] = useState<unknown>(null)
const [corsReloadKey, setCorsReloadKey] = useState(0)
const [policyText, setPolicyText] = useState("")
const [policySaving, setPolicySaving] = useState(false)
// Each section saves independently, so each failure speaks in its own tab.
const [error, setError] = useState<unknown>(null)
const [errorContext, setErrorContext] = useState("save")
const open = state !== null
useEffect(() => {
if (!open || !state) return
setCorsLoading(true)
setCorsError(null)
getCors(arcadia, configId, state.bucket.name)
.then((res) => {
setCorsRules(res?.rules ?? [])
})
.catch(() => setCorsRules([]))
.catch((err) => {
setCorsRules([])
setCorsError(err)
})
.finally(() => setCorsLoading(false))
}, [open, state, arcadia, configId])
}, [open, state, arcadia, configId, corsReloadKey])
if (!state) return null
const { bucket } = state
const fail = (err: unknown, context: string) => {
setError(err)
setErrorContext(context)
}
const saveVersioning = async () => {
setVersioningSaving(true)
onError(null)
setError(null)
try {
await configureVersioning(arcadia, {
storage_config_id: configId,
bucket_name: bucket.name,
enabled: versioningOn,
})
await onChanged(`Versioning ${versioningOn ? "enabled" : "suspended"}.`)
await onChanged(
`${versioningOn ? "Enabled" : "Suspended"} versioning on ${bucket.name}`,
)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
fail(err, `save versioning on ${bucket.name}`)
} finally {
setVersioningSaving(false)
}
@@ -1015,16 +1058,16 @@ function ConfigureBucketDialog({
const saveCors = async () => {
setCorsSaving(true)
onError(null)
setError(null)
try {
await configureCors(arcadia, {
storage_config_id: configId,
bucket_name: bucket.name,
rules: corsRules,
})
await onChanged("CORS rules saved.")
await onChanged(`Saved CORS rules on ${bucket.name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
fail(err, `save the CORS rules on ${bucket.name}`)
} finally {
setCorsSaving(false)
}
@@ -1032,23 +1075,19 @@ function ConfigureBucketDialog({
const savePolicy = async () => {
setPolicySaving(true)
onError(null)
setError(null)
try {
// Parsed here so malformed JSON never reaches the API — and so the
// SyntaxError describeError() surfaces names the real problem.
const policy = policyText.trim() === "" ? {} : JSON.parse(policyText)
await configurePolicy(arcadia, {
storage_config_id: configId,
bucket_name: bucket.name,
policy,
})
await onChanged("Bucket policy saved.")
await onChanged(`Saved the bucket policy on ${bucket.name}`)
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? `Invalid JSON or save failed: ${err.message}`
: "Save failed.",
)
fail(err, `save the bucket policy on ${bucket.name}`)
} finally {
setPolicySaving(false)
}
@@ -1112,16 +1151,22 @@ function ConfigureBucketDialog({
</TabsContent>
<TabsContent value="cors" className="pt-4">
{corsLoading ? (
<p className="py-4 text-center text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading rules…
</p>
) : (
<DataState
loading={corsLoading}
error={corsError}
isEmpty={corsRules.length === 0}
onRetry={() => setCorsReloadKey((n) => n + 1)}
loadingLabel="Loading CORS rules…"
// A bucket with no rules is a real, reachable state — CorsEditor
// already says so. It just must never be shown for a failed read.
empty={<CorsEditor rules={[]} onChange={setCorsRules} />}
>
<CorsEditor rules={corsRules} onChange={setCorsRules} />
)}
</DataState>
<div className="mt-3 flex justify-end gap-2">
<Button
variant="outline"
disabled={corsLoading || !!corsError}
onClick={() =>
setCorsRules([
...corsRules,
@@ -1140,7 +1185,9 @@ function ConfigureBucketDialog({
</Button>
<Button
onClick={saveCors}
disabled={corsSaving}
// Saving rules we never managed to read would silently wipe
// whatever is actually on the bucket.
disabled={corsSaving || corsLoading || !!corsError}
data-action="configure-cors-save"
>
{corsSaving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
@@ -1182,6 +1229,8 @@ function ConfigureBucketDialog({
</TabsContent>
</Tabs>
{error ? <DialogError error={error} context={errorContext} /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="configure-close">
Close
@@ -1303,38 +1352,38 @@ function DeleteBucketFlow({
configId,
onClose,
onDeleted,
onError,
}: {
bucket: Bucket | null
configId: string
onClose: () => void
onDeleted: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onDeleted: (name: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [code, setCode] = useState("")
const [forceEmpty, setForceEmpty] = useState(false)
const [issuingCode, setIssuingCode] = useState(false)
const [deleting, setDeleting] = useState(false)
// A refused delete (wrong code, bucket not empty) has to be readable right
// where the operator typed the code — this dialog is the whole confirmation.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!bucket) {
setCode("")
setForceEmpty(false)
}
setError(null)
}, [bucket])
const requestCode = async () => {
if (!bucket) return
setIssuingCode(true)
onError(null)
setError(null)
try {
const res = await generateConfirmationCode(arcadia, configId, bucket.name)
setCode(res.code ?? "")
} catch (err) {
onError(
err instanceof ArcadiaError ? err.message : "Failed to generate confirmation code.",
)
setError(err)
} finally {
setIssuingCode(false)
}
@@ -1343,7 +1392,7 @@ function DeleteBucketFlow({
const doDelete = async () => {
if (!bucket) return
setDeleting(true)
onError(null)
setError(null)
try {
await deleteBucket(arcadia, {
storage_config_id: configId,
@@ -1352,9 +1401,9 @@ function DeleteBucketFlow({
force_empty: forceEmpty,
dry_run: false,
})
await onDeleted(`${bucket.name} deleted.`)
await onDeleted(bucket.name)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setError(err)
} finally {
setDeleting(false)
}
@@ -1417,8 +1466,20 @@ function DeleteBucketFlow({
</div>
</div>
{error ? (
<DialogError
error={error}
context={bucket ? `delete ${bucket.name}` : "delete the bucket"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={deleting}>
<Button
variant="outline"
onClick={onClose}
disabled={deleting}
data-action="bucket-delete-cancel"
>
Cancel
</Button>
<Button
@@ -1468,3 +1529,5 @@ function guessMime(key: string): string {
}
return m[ext] ?? "application/octet-stream"
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -1,42 +0,0 @@
// Tenant entitlements — placeholder. Lists the metered allowances
// (AI tokens, storage GB, etc.) granted to the active tenant and how
// much of each has been consumed. Data source not wired yet.
import { Gauge } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
export default function EntitlementsRoute() {
return (
<AppShell>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Gauge className="size-5" />
</div>
<div>
<h1 className="text-2xl font-semibold">Entitlements</h1>
<p className="text-sm text-muted-foreground">
Metered allowances for this tenant included units and usage to
date per meter.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Coming soon</CardTitle>
<CardDescription>
Personal-cloud entitlements are tracked per account today. A
tenant-rollup endpoint is pending.
</CardDescription>
</CardHeader>
<CardContent />
</Card>
</AppShell>
)
}

View File

@@ -428,3 +428,5 @@ function timeAgo(iso: string): string {
const d = Math.round(hr / 24)
return `${d}d ago`
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -630,3 +630,5 @@ function Field({
</div>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -1,205 +0,0 @@
import { useState } from "react"
import { BookOpen, Copy, Download, Trash2, MessagesSquare } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import { pageTitle } from "~/lib/page-meta"
import {
deleteLibraryItem,
useLibrary,
type LibraryItem,
} from "~/lib/library"
export const meta = () => pageTitle("Library")
export default function LibraryRoute() {
const items = useLibrary()
const [query, setQuery] = useState("")
const [openId, setOpenId] = useState<string | null>(null)
const filtered = items.filter((it) => {
if (!query.trim()) return true
const q = query.toLowerCase()
return (
it.title.toLowerCase().includes(q) ||
it.content.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
})
const open = items.find((x) => x.id === openId) ?? null
return (
<AppShell>
<Card>
<CardHeader>
<CardTitle>Library</CardTitle>
<CardDescription>
Saved items and templates. Save a chat from the Assistant via the
menu "Save to Library".
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Input
data-action="library-search"
placeholder="Search saved items…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{items.length === 0 ? (
<EmptyState />
) : (
<div className="grid gap-3 md:grid-cols-[18rem_1fr]">
<ul className="flex max-h-[60vh] flex-col gap-1 overflow-y-auto rounded-lg border bg-card/40 p-2">
{filtered.length === 0 && (
<li className="px-2 py-3 text-sm text-muted-foreground">
No matches.
</li>
)}
{filtered.map((it) => (
<li key={it.id}>
<button
type="button"
data-action={`library-open-${it.id}`}
onClick={() => setOpenId(it.id)}
className={
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors " +
(openId === it.id
? "bg-accent text-accent-foreground"
: "hover:bg-accent hover:text-accent-foreground")
}
>
<span className="mt-0.5 shrink-0">
{it.kind === "conversation" ? (
<MessagesSquare className="size-4 text-muted-foreground" />
) : (
<BookOpen className="size-4 text-muted-foreground" />
)}
</span>
<span className="flex min-w-0 flex-col">
<span className="line-clamp-1 text-sm font-medium">
{it.title}
</span>
<span className="line-clamp-1 text-[11px] text-muted-foreground">
{it.agentName ? `${it.agentName} · ` : ""}
{it.messageCount
? `${it.messageCount} msg · `
: ""}
{new Date(it.createdAt).toLocaleDateString()}
</span>
</span>
</button>
</li>
))}
</ul>
<div className="min-w-0">
{open ? <Detail item={open} /> : <PickAnItem />}
</div>
</div>
)}
</CardContent>
</Card>
</AppShell>
)
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
<BookOpen className="size-6" />
</div>
<div className="max-w-md">
<p className="font-medium">Library is empty</p>
<p className="mt-1 text-sm text-muted-foreground">
Save a conversation from the Assistant via the menu {" "}
<span className="font-medium">Save to Library</span>.
</p>
</div>
</div>
)
}
function PickAnItem() {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-muted-foreground/20 p-12 text-center text-sm text-muted-foreground">
Pick an item to view.
</div>
)
}
function Detail({ item }: { item: LibraryItem }) {
const copy = async () => {
try {
await navigator.clipboard.writeText(item.content)
} catch {
/* ignore */
}
}
const download = () => {
const blob = new Blob([item.content], {
type: "text/markdown;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const slug = item.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 60) || "item"
a.download = `${slug}.md`
a.click()
URL.revokeObjectURL(url)
}
const remove = () => {
if (window.confirm(`Delete "${item.title}"?`)) deleteLibraryItem(item.id)
}
return (
<div className="flex max-h-[60vh] flex-col rounded-lg border bg-card/40">
<div className="flex items-start gap-2 border-b px-3 py-2">
<div className="flex flex-1 flex-col">
<span className="font-medium">{item.title}</span>
<span className="text-xs text-muted-foreground">
{item.agentName ? `${item.agentName} · ` : ""}
{item.messageCount ? `${item.messageCount} msg · ` : ""}
{new Date(item.createdAt).toLocaleString()}
</span>
</div>
<Button
data-action={`library-copy-${item.id}`}
variant="ghost"
size="sm"
onClick={copy}
>
<Copy className="size-3.5" /> Copy
</Button>
<Button
data-action={`library-download-${item.id}`}
variant="ghost"
size="sm"
onClick={download}
>
<Download className="size-3.5" /> Download
</Button>
<Button
data-action={`library-delete-${item.id}`}
variant="ghost"
size="sm"
onClick={remove}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
<pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed">
{item.content}
</pre>
</div>
)
}

View File

@@ -21,12 +21,20 @@ export default function LoginRoute() {
if (session) navigate(next, { replace: true })
}, [session, next, navigate])
// This is an operator console: don't advertise dev seed credentials or a
// self-serve "Sign up" path in production. Both are dev conveniences.
const isDev = import.meta.env.DEV
return (
<AuthShell>
<LoginForm
brand={<AuthBrand />}
heading={`Sign in to ${brand.name}`}
subhead="Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
subhead={
isDev
? "Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
: "Use your arcadia credentials."
}
onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
if (twoFactorRequired && twoFactorChallenge) {
navigate(
@@ -38,7 +46,7 @@ export default function LoginRoute() {
navigate(next, { replace: true })
}}
onForgotPassword={() => navigate("/login/forgot")}
onSignup={() => navigate("/signup")}
onSignup={isDev ? () => navigate("/signup") : undefined}
/>
</AuthShell>
)

View File

@@ -9,7 +9,8 @@ import {
Trash2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -22,18 +23,14 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Card, CardContent, CardHeader } from "~/components/ui/card"
import {
Dialog,
DialogContent,
@@ -42,7 +39,6 @@ import {
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Select,
@@ -74,16 +70,22 @@ type Editor =
| { kind: "edit"; membership: Membership }
| null
/** Who the membership is for, in the operator's words. */
function memberLabel(m: Membership): string {
return m.user?.email ?? m.user_id.slice(0, 8) + "…"
}
export default function MembershipsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [memberships, setMemberships] = useState<Membership[]>([])
const [users, setUsers] = useState<User[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Raw thrown value — DataState turns the status code into plain language.
const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | MembershipStatus>("all")
const [editor, setEditor] = useState<Editor>(null)
@@ -93,6 +95,8 @@ export default function MembershipsRoute() {
setError(null)
setLoading(true)
try {
// Users and roles are only needed to populate the editor's pickers; a
// failure there shouldn't blank the table, so they degrade to empty.
const [m, u, r] = await Promise.all([
listMemberships(arcadia),
listUsers(arcadia).catch(() => [] as User[]),
@@ -102,7 +106,7 @@ export default function MembershipsRoute() {
setUsers(u)
setRoles(r)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load memberships.")
setError(err)
} finally {
setLoading(false)
}
@@ -207,6 +211,7 @@ export default function MembershipsRoute() {
header: "",
align: "right",
cell: (m) => {
const who = memberLabel(m)
const items: ActionItem[] = [
{
id: "edit",
@@ -223,10 +228,10 @@ export default function MembershipsRoute() {
onSelect: async () => {
try {
await suspendMembership(arcadia, m.id)
setInfo("Membership suspended.")
await refresh()
toast.success(`Suspended ${who}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Suspend failed.")
toast.error(errorMessage(err, `suspend ${who}`))
}
},
}
@@ -238,10 +243,10 @@ export default function MembershipsRoute() {
onSelect: async () => {
try {
await activateMembership(arcadia, m.id)
setInfo("Membership activated.")
await refresh()
toast.success(`Activated ${who}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
toast.error(errorMessage(err, `activate ${who}`))
}
},
},
@@ -260,7 +265,7 @@ export default function MembershipsRoute() {
},
},
],
[arcadia, refresh],
[arcadia, refresh, toast],
)
const summary = useMemo(
@@ -318,17 +323,6 @@ export default function MembershipsRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
@@ -358,11 +352,13 @@ export default function MembershipsRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && memberships.length === 0}
label="Loading memberships…"
/>
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading memberships…"
empty={
<EmptyState
icon={<Network className="size-6" />}
title={
@@ -377,8 +373,8 @@ export default function MembershipsRoute() {
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -395,8 +391,7 @@ export default function MembershipsRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</div>
@@ -407,21 +402,23 @@ export default function MembershipsRoute() {
title="Remove membership?"
description={
pendingDelete
? `${pendingDelete.user?.email ?? pendingDelete.user_id} will lose access to ${pendingDelete.tenant?.name ?? "this tenant"}.`
? `${memberLabel(pendingDelete)} immediately loses access to ${pendingDelete.tenant?.name ?? "this tenant"}, along with any roles they hold there. Suspend instead if this is temporary.`
: ""
}
confirmLabel="Remove"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const who = memberLabel(pendingDelete)
const where = pendingDelete.tenant?.name ?? "this tenant"
try {
await deleteMembership(arcadia, pendingDelete.id)
setPendingDelete(null)
setInfo("Membership removed.")
await refresh()
toast.success(`Removed ${who} from ${where}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Remove failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `remove ${who} from ${where}`))
}
}}
/>
@@ -432,12 +429,11 @@ export default function MembershipsRoute() {
roles={roles}
existingUserIds={new Set(memberships.map((m) => m.user_id))}
onClose={() => setEditor(null)}
onSaved={async (msg) => {
onSaved={async (message) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(message)
}}
onError={setError}
/>
</AppShell>
)
@@ -456,15 +452,13 @@ function MembershipEditorDialog({
existingUserIds,
onClose,
onSaved,
onError,
}: {
state: Editor
users: User[]
roles: Role[]
existingUserIds: Set<string>
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -475,9 +469,13 @@ function MembershipEditorDialog({
const [status, setStatus] = useState<MembershipStatus>("active")
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
// Rendered inside the dialog: a page banner would sit behind the scrim.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) return
setError(null)
setSaving(false)
if (initial) {
setUserId(initial.user_id)
setStatus(initial.status)
@@ -495,7 +493,7 @@ function MembershipEditorDialog({
)
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const input = {
@@ -503,22 +501,18 @@ function MembershipEditorDialog({
status,
role_ids: Array.from(selectedRoles),
}
const who =
users.find((u) => u.id === userId)?.email ?? initial?.user?.email ?? "the member"
if (isEdit && initial) {
await updateMembership(arcadia, initial.id, input)
await onSaved("Membership updated.")
await onSaved(`Saved ${who}'s membership`)
} else {
await createMembership(arcadia, input)
await onSaved("Member added.")
await onSaved(`Added ${who}`)
}
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
} finally {
// Form state survives so the operator can fix and resubmit.
setError(err)
setSaving(false)
}
}
@@ -614,8 +608,20 @@ function MembershipEditorDialog({
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the membership" : "add the member"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="membership-form-cancel"
>
Cancel
</Button>
<Button
@@ -640,8 +646,4 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
}, {})
}
// File-local alias just to keep the Editor type narrowable inside the dialog.
type Editor =
| { kind: "create" }
| { kind: "edit"; membership: Membership }
| null
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -1210,3 +1210,5 @@ function severityColor(s: string): string {
if (s === "warning") return "#f59e0b"
return "#94a3b8"
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -10,10 +10,13 @@ import {
Wifi,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -77,30 +80,59 @@ export default function NetworkingRoute() {
const [floatingIps, setFloatingIps] = useState<FloatingIp[]>([])
const [droplets, setDroplets] = useState<Droplet[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// One error per tab. These endpoints legitimately 503 when DigitalOcean isn't
// configured on a deployment — which must read as "arcadia hit a server
// error", never as the flat lie "No firewalls." A single Promise.all also
// used to mean one 503 wiped all four tabs; allSettled keeps them apart.
const [firewallsError, setFirewallsError] = useState<unknown>(null)
const [vpcsError, setVpcsError] = useState<unknown>(null)
const [domainsError, setDomainsError] = useState<unknown>(null)
const [floatingIpsError, setFloatingIpsError] = useState<unknown>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const [f, v, d, fi, dr] = await Promise.all([
setFirewallsError(null)
setVpcsError(null)
setDomainsError(null)
setFloatingIpsError(null)
const [f, v, d, fi, dr] = await Promise.allSettled([
listFirewalls(arcadia),
listVpcs(arcadia),
listDomains(arcadia),
listFloatingIps(arcadia),
listDroplets(arcadia),
])
setFirewalls(f)
setVpcs(v)
setDomains(d)
setFloatingIps(fi)
setDroplets(dr)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load networking.")
} finally {
setLoading(false)
if (f.status === "fulfilled") setFirewalls(f.value)
else {
setFirewalls([])
setFirewallsError(f.reason)
}
if (v.status === "fulfilled") setVpcs(v.value)
else {
setVpcs([])
setVpcsError(v.reason)
}
if (d.status === "fulfilled") setDomains(d.value)
else {
setDomains([])
setDomainsError(d.reason)
}
if (fi.status === "fulfilled") setFloatingIps(fi.value)
else {
setFloatingIps([])
setFloatingIpsError(fi.reason)
}
// Droplets only populate the "assign to" picker; the picker already says
// "No droplets" when it's empty, so a failure needs no error surface here.
setDroplets(dr.status === "fulfilled" ? dr.value : [])
setLoading(false)
}, [arcadia])
useEffect(() => {
@@ -137,17 +169,6 @@ export default function NetworkingRoute() {
</Button>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Tabs defaultValue="firewalls">
<TabsList>
<TabsTrigger value="firewalls" data-action="networking-tab-firewalls">
@@ -168,22 +189,25 @@ export default function NetworkingRoute() {
<FirewallsPanel
firewalls={firewalls}
loading={loading}
error={firewallsError}
onChanged={refresh}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="vpcs" className="pt-4">
<VpcsPanel vpcs={vpcs} loading={loading} />
<VpcsPanel
vpcs={vpcs}
loading={loading}
error={vpcsError}
onRetry={refresh}
/>
</TabsContent>
<TabsContent value="domains" className="pt-4">
<DomainsPanel
domains={domains}
loading={loading}
onError={setError}
onInfo={setInfo}
error={domainsError}
onChanged={refresh}
/>
</TabsContent>
@@ -193,9 +217,8 @@ export default function NetworkingRoute() {
ips={floatingIps}
droplets={droplets}
loading={loading}
error={floatingIpsError}
onChanged={refresh}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
</Tabs>
@@ -209,31 +232,27 @@ export default function NetworkingRoute() {
function FirewallsPanel({
firewalls,
loading,
error,
onChanged,
onError,
onInfo,
}: {
firewalls: Firewall[]
loading: boolean
error: unknown
onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [pendingDelete, setPendingDelete] = useState<Firewall | null>(null)
if (loading && firewalls.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading firewalls…" />
</CardContent>
</Card>
)
}
if (firewalls.length === 0) {
return (
<>
<DataState
loading={loading}
error={error}
isEmpty={firewalls.length === 0}
onRetry={onChanged}
loadingLabel="Loading firewalls…"
empty={
<Card>
<CardContent>
<EmptyState
@@ -244,11 +263,8 @@ function FirewallsPanel({
/>
</CardContent>
</Card>
)
}
return (
<>
>
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{firewalls.map((f) => (
<Card key={String(f.id)}>
@@ -275,6 +291,7 @@ function FirewallsPanel({
</Card>
))}
</ul>
</DataState>
<ConfirmDialog
open={pendingDelete !== null}
@@ -289,14 +306,15 @@ function FirewallsPanel({
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const name = pendingDelete.name
try {
await deleteFirewall(arcadia, pendingDelete.id)
setPendingDelete(null)
onInfo("Firewall deleted.")
await onChanged()
toast.success(`Deleted firewall ${name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete firewall ${name}`))
}
}}
/>
@@ -306,18 +324,25 @@ function FirewallsPanel({
// --- VPCs panel --------------------------------------------------------
function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
if (loading && vpcs.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading VPCs" />
</CardContent>
</Card>
)
}
if (vpcs.length === 0) {
function VpcsPanel({
vpcs,
loading,
error,
onRetry,
}: {
vpcs: Vpc[]
loading: boolean
error: unknown
onRetry: () => void
}) {
return (
<DataState
loading={loading}
error={error}
isEmpty={vpcs.length === 0}
onRetry={onRetry}
loadingLabel="Loading VPCs"
empty={
<Card>
<CardContent>
<EmptyState
@@ -328,9 +353,8 @@ function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
/>
</CardContent>
</Card>
)
}
return (
>
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{vpcs.map((v) => (
<Card key={v.id}>
@@ -352,6 +376,7 @@ function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
</Card>
))}
</ul>
</DataState>
)
}
@@ -360,20 +385,22 @@ function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
function DomainsPanel({
domains,
loading,
onError,
onInfo,
error,
onChanged,
}: {
domains: Domain[]
loading: boolean
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
error: unknown
onChanged: () => Promise<void>
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [selectedName, setSelectedName] = useState<string>(() => domains[0]?.name ?? "")
const [records, setRecords] = useState<DnsRecord[]>([])
const [loadingRecords, setLoadingRecords] = useState(false)
// The record list loads separately from the domain list, so it carries its
// own error: a 500 on records must not claim the domain has no records.
const [recordsError, setRecordsError] = useState<unknown>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<DnsRecord | null>(null)
@@ -387,34 +414,33 @@ function DomainsPanel({
setRecords([])
return
}
setRecordsError(null)
setLoadingRecords(true)
try {
setRecords(await listDnsRecords(arcadia, name))
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Failed to load DNS records.")
setRecords([])
setRecordsError(err)
} finally {
setLoadingRecords(false)
}
},
[arcadia, onError],
[arcadia],
)
useEffect(() => {
loadRecords(selectedName)
}, [selectedName, loadRecords])
if (loading && domains.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading domains" />
</CardContent>
</Card>
)
}
if (domains.length === 0) {
if (loading || error || domains.length === 0) {
return (
<DataState
loading={loading}
error={error}
isEmpty={domains.length === 0}
onRetry={onChanged}
loadingLabel="Loading domains"
empty={
<Card>
<CardContent>
<EmptyState
@@ -425,6 +451,10 @@ function DomainsPanel({
/>
</CardContent>
</Card>
}
>
{null}
</DataState>
)
}
@@ -470,14 +500,21 @@ function DomainsPanel({
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{records.length === 0 && !loadingRecords ? (
<CardContent className="relative p-0">
<DataState
loading={loadingRecords}
error={recordsError}
isEmpty={records.length === 0}
onRetry={() => loadRecords(selectedName)}
loadingLabel="Loading DNS records"
empty={
<EmptyState
icon={<Globe className="size-6" />}
title="No records on this domain."
className="py-8"
/>
) : (
}
>
<ul className="divide-y border-y">
{records.map((r) => (
<li key={String(r.id)} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
@@ -505,20 +542,19 @@ function DomainsPanel({
</li>
))}
</ul>
)}
</DataState>
</CardContent>
<DnsCreateDialog
open={createOpen}
domainName={selectedName}
onClose={() => setCreateOpen(false)}
onCreated={async () => {
onCreated={async (label) => {
setCreateOpen(false)
onInfo("DNS record created.")
await loadRecords(selectedName)
await onChanged()
toast.success(`Created ${label}`)
}}
onError={onError}
/>
<ConfirmDialog
@@ -534,14 +570,15 @@ function DomainsPanel({
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const label = `${pendingDelete.type} ${pendingDelete.name}`
try {
await deleteDnsRecord(arcadia, selectedName, pendingDelete.id)
setPendingDelete(null)
onInfo("Record deleted.")
await loadRecords(selectedName)
toast.success(`Deleted ${label}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${label}`))
}
}}
/>
@@ -554,13 +591,11 @@ function DnsCreateDialog({
domainName,
onClose,
onCreated,
onError,
}: {
open: boolean
domainName: string
onClose: () => void
onCreated: () => Promise<void>
onError: (msg: string | null) => void
onCreated: (label: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [type, setType] = useState("A")
@@ -569,6 +604,7 @@ function DnsCreateDialog({
const [ttl, setTtl] = useState("3600")
const [priority, setPriority] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) {
@@ -577,11 +613,12 @@ function DnsCreateDialog({
setData("")
setTtl("3600")
setPriority("")
setError(null)
}
}, [open])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
await createDnsRecord(arcadia, domainName, {
@@ -591,9 +628,10 @@ function DnsCreateDialog({
ttl: ttl ? Number(ttl) : undefined,
priority: priority ? Number(priority) : undefined,
})
await onCreated()
await onCreated(`${type} ${name} → ${data}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Create failed.")
// A rejected record (bad target, duplicate name) is fixable right here.
setError(err)
} finally {
setSaving(false)
}
@@ -679,8 +717,15 @@ function DnsCreateDialog({
) : null}
</div>
{error ? <DialogError error={error} context="create the record" /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="dns-form-cancel"
>
Cancel
</Button>
<Button onClick={submit} disabled={saving || !data} data-action="dns-form-save">
@@ -699,47 +744,37 @@ function FloatingIpsPanel({
ips,
droplets,
loading,
error,
onChanged,
onError,
onInfo,
}: {
ips: FloatingIp[]
droplets: Droplet[]
loading: boolean
error: unknown
onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [assigning, setAssigning] = useState<{ ip: string; dropletId: string } | null>(null)
if (loading && ips.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading floating IPs" />
</CardContent>
</Card>
)
}
if (ips.length === 0) {
return (
<Card>
<CardContent>
<CardContent className="relative p-0">
<DataState
loading={loading}
error={error}
isEmpty={ips.length === 0}
onRetry={onChanged}
loadingLabel="Loading floating IPs"
empty={
<EmptyState
icon={<Wifi className="size-6" />}
title="No floating IPs."
description="Reserve a floating IP on your provider to surface it here."
className="py-8"
/>
</CardContent>
</Card>
)
}
return (
<Card>
<CardContent className="p-0">
>
<ul className="divide-y border-y">
{ips.map((ip) => {
const region =
@@ -764,12 +799,10 @@ function FloatingIpsPanel({
onClick={async () => {
try {
await unassignFloatingIp(arcadia, ip.ip)
onInfo("Floating IP unassigned.")
await onChanged()
toast.success(`Unassigned ${ip.ip}`)
} catch (err) {
onError(
err instanceof ArcadiaError ? err.message : "Unassign failed.",
)
toast.error(errorMessage(err, `unassign ${ip.ip}`))
}
}}
data-action={`fip-${ip.ip}-unassign`}
@@ -809,14 +842,17 @@ function FloatingIpsPanel({
}
onClick={async () => {
if (!assigning || assigning.ip !== ip.ip) return
const dropletName =
droplets.find((d) => String(d.id) === assigning.dropletId)
?.name ?? assigning.dropletId
try {
await assignFloatingIp(arcadia, ip.ip, assigning.dropletId)
setAssigning(null)
onInfo("Floating IP assigned.")
await onChanged()
toast.success(`Assigned ${ip.ip} to ${dropletName}`)
} catch (err) {
onError(
err instanceof ArcadiaError ? err.message : "Assign failed.",
toast.error(
errorMessage(err, `assign ${ip.ip} to ${dropletName}`),
)
}
}}
@@ -831,7 +867,10 @@ function FloatingIpsPanel({
)
})}
</ul>
</DataState>
</CardContent>
</Card>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -11,7 +11,8 @@ import {
Users as UsersIcon,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -24,18 +25,14 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Card, CardContent, CardHeader } from "~/components/ui/card"
import {
Dialog,
DialogContent,
@@ -82,14 +79,20 @@ const ON_OWNER_REMOVAL_LABEL: Record<OnOwnerRemoval, string> = {
freeze_until_new_owner: "Freeze until new owner",
}
/** Members are keyed by user_id only; show the short form consistently. */
function memberLabel(m: OrgMembership): string {
return `${m.user_id.slice(0, 8)}`
}
export default function OrganizationsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [orgs, setOrgs] = useState<Organization[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Raw thrown value: the status code is what makes the message useful.
const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | OrgStatus>("all")
@@ -102,7 +105,7 @@ export default function OrganizationsRoute() {
try {
setOrgs(await listAllOrganizations(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load organizations.")
setError(err)
} finally {
setLoading(false)
}
@@ -179,7 +182,7 @@ export default function OrganizationsRoute() {
onSelect: () => setSettingsDialog({ org: o }),
},
]
return <ActionsCell items={items} />
return <ActionsCell items={items} triggerDataAction={`org-${o.id}-actions`} />
},
},
],
@@ -222,17 +225,6 @@ export default function OrganizationsRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
@@ -262,11 +254,13 @@ export default function OrganizationsRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && orgs.length === 0}
label="Loading organizations…"
/>
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading organizations…"
empty={
<EmptyState
icon={<Building className="size-6" />}
title={
@@ -281,8 +275,8 @@ export default function OrganizationsRoute() {
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -299,27 +293,20 @@ export default function OrganizationsRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</div>
<MembersDialog
state={membersDialog}
onClose={() => setMembersDialog(null)}
onInfo={setInfo}
onError={setError}
/>
<MembersDialog state={membersDialog} onClose={() => setMembersDialog(null)} />
<SettingsDialog
state={settingsDialog}
onClose={() => setSettingsDialog(null)}
onSaved={async (msg) => {
onSaved={async (message) => {
setSettingsDialog(null)
if (msg) setInfo(msg)
await refresh()
toast.success(message)
}}
onError={setError}
/>
</AppShell>
)
@@ -347,38 +334,42 @@ type InvitePane = "none" | "invite_existing" | "add_restricted"
function MembersDialog({
state,
onClose,
onInfo,
onError,
}: {
state: MembersDialogState
onClose: () => void
onInfo: (msg: string | null) => void
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const open = state !== null
const org = state?.org
const [members, setMembers] = useState<OrgMembership[]>([])
const [loading, setLoading] = useState(false)
// The member list has its own load error — a failed fetch inside this dialog
// must not read as "no members yet".
const [error, setError] = useState<unknown>(null)
const [pendingRemove, setPendingRemove] = useState<OrgMembership | null>(null)
const [transferTarget, setTransferTarget] = useState<OrgMembership | null>(null)
const [pane, setPane] = useState<InvitePane>("none")
const refresh = useCallback(async () => {
if (!org) return
setError(null)
setLoading(true)
try {
setMembers(await listMembers(arcadia, org.id))
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Failed to load members.")
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, org, onError])
}, [arcadia, org])
useEffect(() => {
if (open) refresh()
if (open) {
setPane("none")
refresh()
}
}, [open, refresh])
return (
@@ -417,36 +408,40 @@ function MembersDialog({
<InviteByEmailForm
orgId={org!.id}
onCancel={() => setPane("none")}
onSaved={async (msg) => {
onSaved={async (message) => {
setPane("none")
onInfo(msg)
await refresh()
toast.success(message)
}}
onError={onError}
/>
) : (
<AddRestrictedForm
orgId={org!.id}
onCancel={() => setPane("none")}
onSaved={async (msg) => {
onSaved={async (message) => {
setPane("none")
onInfo(msg)
await refresh()
toast.success(message)
}}
onError={onError}
/>
)}
<div className="relative">
<LoadingOverlay active={loading && members.length === 0} label="Loading members…" />
{members.length === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={members.length === 0}
onRetry={refresh}
loadingLabel="Loading members…"
empty={
<EmptyState
icon={<UsersIcon className="size-6" />}
title="No members yet."
description="Invite someone or add a restricted sub-user to get started."
className="py-8"
/>
) : (
}
>
<div className="rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-left text-xs text-muted-foreground">
@@ -461,7 +456,7 @@ function MembersDialog({
<tbody>
{members.map((m) => (
<tr key={m.id} className="border-t border-border">
<td className="px-3 py-2 font-mono text-xs">{m.user_id.slice(0, 8)}</td>
<td className="px-3 py-2 font-mono text-xs">{memberLabel(m)}</td>
<td className="px-3 py-2">
<Badge variant={roleBadgeVariant(m.role)}>{m.role}</Badge>
</td>
@@ -477,11 +472,7 @@ function MembersDialog({
orgId={org!.id}
onTransfer={() => setTransferTarget(m)}
onRemove={() => setPendingRemove(m)}
onRoleChanged={async (msg) => {
onInfo(msg)
await refresh()
}}
onError={onError}
onRoleChanged={refresh}
/>
</td>
</tr>
@@ -489,7 +480,7 @@ function MembersDialog({
</tbody>
</table>
</div>
)}
</DataState>
</div>
<DialogFooter>
@@ -505,22 +496,25 @@ function MembersDialog({
description={
pendingRemove
? pendingRemove.role === "owner"
? "This member is the owner. Removal will follow the org's owner-removal policy."
: "They will lose access to this organization."
? `This member owns ${org?.name ?? "the organization"}. Removing them applies its owner-removal policy${
org ? ON_OWNER_REMOVAL_LABEL[org.on_owner_removal].toLowerCase() : "the configured policy"
} — which may delete or freeze the whole workspace. Transfer ownership first if you only mean to remove the person.`
: `They immediately lose access to ${org?.name ?? "this organization"} and anything shared inside it.`
: ""
}
confirmLabel="Remove"
variant="danger"
onConfirm={async () => {
if (!pendingRemove || !org) return
const who = memberLabel(pendingRemove)
try {
await removeMember(arcadia, org.id, pendingRemove.user_id)
setPendingRemove(null)
onInfo("Member removed.")
await refresh()
toast.success(`Removed ${who} from ${org.name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Remove failed.")
setPendingRemove(null)
toast.error(errorMessage(err, `remove ${who} from ${org.name}`))
}
}}
/>
@@ -531,21 +525,22 @@ function MembersDialog({
title="Transfer ownership?"
description={
transferTarget
? `The current owner will be demoted to admin. ${transferTarget.user_id.slice(0, 8)}… will become owner.`
? `${memberLabel(transferTarget)} becomes the owner of ${org?.name ?? "this organization"}, and the current owner is demoted to admin.`
: ""
}
confirmLabel="Transfer"
variant="default"
onConfirm={async () => {
if (!transferTarget || !org) return
const who = memberLabel(transferTarget)
try {
await transferOwnership(arcadia, org.id, transferTarget.user_id)
setTransferTarget(null)
onInfo("Ownership transferred.")
await refresh()
toast.success(`${org.name} is now owned by ${who}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Transfer failed.")
setTransferTarget(null)
toast.error(errorMessage(err, `transfer ${org.name} to ${who}`))
}
}}
/>
@@ -560,16 +555,15 @@ function MemberRowActions({
onTransfer,
onRemove,
onRoleChanged,
onError,
}: {
member: OrgMembership
orgId: string
onTransfer: () => void
onRemove: () => void
onRoleChanged: (msg: string) => Promise<void>
onError: (msg: string | null) => void
onRoleChanged: () => Promise<void>
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const items: ActionItem[] = []
@@ -583,9 +577,10 @@ function MemberRowActions({
const next = member.role === "admin" ? "member" : "admin"
try {
await changeMemberRole(arcadia, orgId, member.user_id, next)
await onRoleChanged(`Role set to ${next}.`)
await onRoleChanged()
toast.success(`${memberLabel(member)} is now ${next === "admin" ? "an admin" : "a member"}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Role change failed.")
toast.error(errorMessage(err, `change ${memberLabel(member)} to ${next}`))
}
},
})
@@ -607,7 +602,9 @@ function MemberRowActions({
onSelect: onRemove,
})
return <ActionsCell items={items} />
return (
<ActionsCell items={items} triggerDataAction={`org-${orgId}-member-${member.id}-actions`} />
)
}
// ============================================================================
@@ -618,17 +615,33 @@ function InviteByEmailForm({
orgId,
onCancel,
onSaved,
onError,
}: {
orgId: string
onCancel: () => void
onSaved: (msg: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [email, setEmail] = useState("")
const [role, setRole] = useState<OrgRole>("member")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
const submit = async () => {
setError(null)
setSaving(true)
try {
const res = await inviteMember(arcadia, orgId, { email, role })
await onSaved(
res.type === "membership"
? `Added ${email.trim()} — they already had an account`
: `Invitation sent to ${email.trim()}`,
)
} catch (err) {
// The form stays filled in; the error lands right under it.
setError(err)
setSaving(false)
}
}
return (
<div className="rounded-md border border-border bg-muted/20 p-3">
@@ -637,9 +650,10 @@ function InviteByEmailForm({
placeholder="email@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
data-action={`org-${orgId}-invite-email`}
/>
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
<SelectTrigger>
<SelectTrigger data-action={`org-${orgId}-invite-role`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -647,31 +661,31 @@ function InviteByEmailForm({
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={onCancel} disabled={saving}>
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={saving}
data-action={`org-${orgId}-invite-cancel`}
>
Cancel
</Button>
<Button
size="sm"
disabled={!email || saving}
onClick={async () => {
setSaving(true)
try {
const res = await inviteMember(arcadia, orgId, { email, role })
await onSaved(
res.type === "membership"
? "Invited existing user."
: "Email invitation sent.",
)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Invite failed.")
} finally {
setSaving(false)
}
}}
onClick={submit}
data-action={`org-${orgId}-invite-submit`}
>
Send invite
</Button>
</div>
{error ? (
<div className="mt-2">
<DialogError error={error} context="send the invitation" />
</div>
) : null}
<p className="mt-2 text-xs text-muted-foreground">
If an account with that email already exists in this tenant, an invited membership is
created; otherwise an email invitation is sent and the user is materialized on accept.
@@ -684,12 +698,10 @@ function AddRestrictedForm({
orgId,
onCancel,
onSaved,
onError,
}: {
orgId: string
onCancel: () => void
onSaved: (msg: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [email, setEmail] = useState("")
@@ -698,52 +710,10 @@ function AddRestrictedForm({
const [password, setPassword] = useState("")
const [role, setRole] = useState<OrgRole>("member")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
return (
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="grid gap-2 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="r-email">Email</Label>
<Input id="r-email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="r-password">Initial password</Label>
<Input
id="r-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="r-first">First name</Label>
<Input id="r-first" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="r-last">Last name</Label>
<Input id="r-last" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="r-role">Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
<SelectTrigger id="r-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancel} disabled={saving}>
Cancel
</Button>
<Button
size="sm"
disabled={!email || !password || !firstName || !lastName || saving}
onClick={async () => {
const submit = async () => {
setError(null)
setSaving(true)
try {
await addRestrictedMember(arcadia, orgId, {
@@ -753,13 +723,88 @@ function AddRestrictedForm({
last_name: lastName,
role,
})
await onSaved("Restricted user added.")
await onSaved(`Added ${email.trim()} as a restricted user`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Add failed.")
} finally {
setError(err)
setSaving(false)
}
}}
}
return (
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="grid gap-2 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="r-email">Email</Label>
<Input
id="r-email"
value={email}
onChange={(e) => setEmail(e.target.value)}
data-action={`org-${orgId}-restricted-email`}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="r-password">Initial password</Label>
<Input
id="r-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
data-action={`org-${orgId}-restricted-password`}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="r-first">First name</Label>
<Input
id="r-first"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
data-action={`org-${orgId}-restricted-first-name`}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="r-last">Last name</Label>
<Input
id="r-last"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
data-action={`org-${orgId}-restricted-last-name`}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="r-role">Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
<SelectTrigger id="r-role" data-action={`org-${orgId}-restricted-role`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{error ? (
<div className="mt-3">
<DialogError error={error} context="add the restricted user" />
</div>
) : null}
<div className="mt-3 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={saving}
data-action={`org-${orgId}-restricted-cancel`}
>
Cancel
</Button>
<Button
size="sm"
disabled={!email || !password || !firstName || !lastName || saving}
onClick={submit}
data-action={`org-${orgId}-restricted-submit`}
>
Add user
</Button>
@@ -780,12 +825,10 @@ function SettingsDialog({
state,
onClose,
onSaved,
onError,
}: {
state: SettingsDialogState
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -795,15 +838,35 @@ function SettingsDialog({
const [status, setStatus] = useState<OrgStatus>("active")
const [onOwnerRemoval, setOnOwnerRemoval] = useState<OnOwnerRemoval>("require_transfer")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (org) {
setName(org.name)
setStatus(org.status)
setOnOwnerRemoval(org.on_owner_removal)
setError(null)
setSaving(false)
}
}, [org])
const submit = async () => {
if (!org) return
setError(null)
setSaving(true)
try {
await updateOrganization(arcadia, org.id, {
name,
status,
on_owner_removal: onOwnerRemoval,
})
await onSaved(`Saved ${name.trim() || org.name}`)
} catch (err) {
setError(err)
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
@@ -815,13 +878,18 @@ function SettingsDialog({
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<Label htmlFor="o-name">Name</Label>
<Input id="o-name" value={name} onChange={(e) => setName(e.target.value)} />
<Input
id="o-name"
value={name}
onChange={(e) => setName(e.target.value)}
data-action="org-settings-name"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="o-status">Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as OrgStatus)}>
<SelectTrigger id="o-status">
<SelectTrigger id="o-status" data-action="org-settings-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -838,7 +906,7 @@ function SettingsDialog({
value={onOwnerRemoval}
onValueChange={(v) => setOnOwnerRemoval(v as OnOwnerRemoval)}
>
<SelectTrigger id="o-policy">
<SelectTrigger id="o-policy" data-action="org-settings-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -853,33 +921,24 @@ function SettingsDialog({
</div>
</div>
{error ? <DialogError error={error} context="save the organization" /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="org-settings-cancel"
>
Cancel
</Button>
<Button
disabled={saving}
onClick={async () => {
if (!org) return
setSaving(true)
try {
await updateOrganization(arcadia, org.id, {
name,
status,
on_owner_removal: onOwnerRemoval,
})
await onSaved("Organization updated.")
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
} finally {
setSaving(false)
}
}}
>
Save
<Button disabled={saving} onClick={submit} data-action="org-settings-save">
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -1,40 +0,0 @@
// Tenant subscription + billing — placeholder. Real surface lists the
// active plan, renewal date, invoices, and payment method for the
// active tenant. Data source not wired yet.
import { CreditCard } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
export default function PlanRoute() {
return (
<AppShell>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CreditCard className="size-5" />
</div>
<div>
<h1 className="text-2xl font-semibold">Plan</h1>
<p className="text-sm text-muted-foreground">
Your tenant's subscription, billing details, and invoice history.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Coming soon</CardTitle>
<CardDescription>
Billing is not yet wired to a payment provider on this deployment.
</CardDescription>
</CardHeader>
<CardContent />
</Card>
</AppShell>
)
}

View File

@@ -621,3 +621,5 @@ function Field({
</label>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -12,7 +12,8 @@ import {
Zap,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -25,9 +26,11 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -84,11 +87,13 @@ type EditorState =
export default function ScheduledTasksRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [tasks, setTasks] = useState<ScheduledTask[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Raw thrown value — the task list's own error. The run-history dialog keeps
// its own, so a failing run log never blanks the task table.
const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<EditorState>(null)
const [pendingDelete, setPendingDelete] = useState<ScheduledTask | null>(null)
@@ -100,7 +105,7 @@ export default function ScheduledTasksRoute() {
try {
setTasks(await listScheduledTasks(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load scheduled tasks.")
setError(err)
} finally {
setLoading(false)
}
@@ -199,15 +204,14 @@ export default function ScheduledTasksRoute() {
setEditor,
setPendingDelete,
setRunsFor,
setError,
setInfo,
toast,
})}
triggerDataAction={`task-${t.id}-actions`}
/>
),
},
],
[arcadia, refresh],
[arcadia, refresh, toast],
)
const summary = useMemo(
@@ -273,17 +277,6 @@ export default function ScheduledTasksRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
@@ -299,8 +292,13 @@ export default function ScheduledTasksRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && tasks.length === 0} label="Loading tasks…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading tasks…"
empty={
<EmptyState
icon={<CalendarClock className="size-6" />}
title={search ? "No tasks match." : "No scheduled tasks yet."}
@@ -311,8 +309,8 @@ export default function ScheduledTasksRoute() {
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -329,8 +327,7 @@ export default function ScheduledTasksRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</div>
@@ -348,14 +345,15 @@ export default function ScheduledTasksRoute() {
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const name = pendingDelete.name
try {
await deleteScheduledTask(arcadia, pendingDelete.id)
setPendingDelete(null)
setInfo("Task deleted.")
await refresh()
toast.success(`Deleted ${name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${name}`))
}
}}
/>
@@ -363,14 +361,14 @@ export default function ScheduledTasksRoute() {
<TaskEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async () => {
onSaved={async (msg) => {
setEditor(null)
await refresh()
toast.success(msg)
}}
onError={setError}
/>
<RunsDialog task={runsFor} onClose={() => setRunsFor(null)} onError={setError} />
<RunsDialog task={runsFor} onClose={() => setRunsFor(null)} />
</AppShell>
)
}
@@ -383,11 +381,10 @@ function rowActions(
setEditor: (s: EditorState) => void
setPendingDelete: (t: ScheduledTask | null) => void
setRunsFor: (t: ScheduledTask | null) => void
setError: (m: string | null) => void
setInfo: (m: string | null) => void
toast: ReturnType<typeof useToast>
},
): ActionItem[] {
const { arcadia, refresh, setEditor, setPendingDelete, setRunsFor, setError, setInfo } = ctx
const { arcadia, refresh, setEditor, setPendingDelete, setRunsFor, toast } = ctx
const items: ActionItem[] = []
items.push({
@@ -398,10 +395,12 @@ function rowActions(
onSelect: async () => {
try {
await triggerScheduledTask(arcadia, t.id)
setInfo(`${t.name} triggered. Check the run log for status.`)
await refresh()
toast.success(`Triggered ${t.name}`, {
description: "Check the run log for status.",
})
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Trigger failed.")
toast.error(errorMessage(err, `trigger ${t.name}`))
}
},
})
@@ -428,10 +427,10 @@ function rowActions(
onSelect: async () => {
try {
await disableScheduledTask(arcadia, t.id)
setInfo(`${t.name} disabled.`)
await refresh()
toast.success(`Disabled ${t.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Disable failed.")
toast.error(errorMessage(err, `disable ${t.name}`))
}
},
})
@@ -444,10 +443,10 @@ function rowActions(
onSelect: async () => {
try {
await enableScheduledTask(arcadia, t.id)
setInfo(`${t.name} enabled.`)
await refresh()
toast.success(`Enabled ${t.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Enable failed.")
toast.error(errorMessage(err, `enable ${t.name}`))
}
},
})
@@ -469,12 +468,10 @@ function TaskEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: EditorState
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
onSaved: (msg: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -492,6 +489,13 @@ function TaskEditorDialog({
const [maxRetries, setMaxRetries] = useState("3")
const [timeoutSeconds, setTimeoutSeconds] = useState("30")
const [saving, setSaving] = useState(false)
// The dialog owns its failures — including the local "config isn't valid
// JSON" throw, which the operator can only fix in this very textarea.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) setError(null)
}, [open])
useEffect(() => {
if (!open) return
@@ -523,7 +527,7 @@ function TaskEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
let parsedConfig: Record<string, unknown>
@@ -551,17 +555,17 @@ function TaskEditorDialog({
timeout_seconds: Math.max(1, Number(timeoutSeconds) || 30),
}
if (isEdit && initial) await updateScheduledTask(arcadia, initial.id, input)
else await createScheduledTask(arcadia, input)
await onSaved()
if (isEdit && initial) {
await updateScheduledTask(arcadia, initial.id, input)
await onSaved(`Saved ${name}`)
} else {
await createScheduledTask(arcadia, input)
await onSaved(`Created ${name}`)
}
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
// Stay open with the form intact: cron and JSON config are fiddly enough
// that retyping them after a failure would be its own bug report.
setError(err)
} finally {
setSaving(false)
}
@@ -701,6 +705,13 @@ function TaskEditorDialog({
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the task" : "create the task"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="task-form-cancel">
Cancel
@@ -722,31 +733,38 @@ function TaskEditorDialog({
function RunsDialog({
task,
onClose,
onError,
}: {
task: ScheduledTask | null
onClose: () => void
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [runs, setRuns] = useState<TaskRun[]>([])
const [loading, setLoading] = useState(true)
// Independent of the task list's error: a 500 on the run log must not blank
// the table behind this dialog, and must not read as "no runs yet".
const [error, setError] = useState<unknown>(null)
const [expanded, setExpanded] = useState<string | null>(null)
useEffect(() => {
if (!task) return
let mounted = true
const taskId = task?.id
const load = useCallback(async () => {
if (!taskId) return
setError(null)
setLoading(true)
listTaskRuns(arcadia, task.id, { limit: 50 })
.then((r) => mounted && setRuns(r))
.catch((err) =>
onError(err instanceof ArcadiaError ? err.message : "Failed to load runs."),
)
.finally(() => mounted && setLoading(false))
return () => {
mounted = false
try {
setRuns(await listTaskRuns(arcadia, taskId, { limit: 50 }))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, task, onError])
}, [arcadia, taskId])
useEffect(() => {
if (!taskId) return
setRuns([])
load()
}, [taskId, load])
if (!task) return null
@@ -760,13 +778,21 @@ function RunsDialog({
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="py-6 text-center text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
) : runs.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No runs yet.</p>
) : (
<DataState
loading={loading}
error={error}
isEmpty={runs.length === 0}
onRetry={load}
loadingLabel="Loading runs…"
empty={
<EmptyState
icon={<History className="size-6" />}
title="No runs yet."
description="Trigger the task to see its first run here."
className="py-8"
/>
}
>
<ul className="flex flex-col divide-y rounded-md border">
{runs.map((r) => {
const open = expanded === r.id
@@ -828,7 +854,7 @@ function RunsDialog({
)
})}
</ul>
)}
</DataState>
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="task-runs-close">
@@ -854,3 +880,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return acc
}, {})
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -18,15 +18,13 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import {
AlertBanner,
ConfirmDialog,
EmptyState,
LoadingOverlay,
} from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { useToast } from "@crema/notification-ui"
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError, ErrorState } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -54,7 +52,6 @@ import {
import { Textarea } from "~/components/ui/textarea"
import {
searchAdmin,
SearchAdminError,
type CorpusSummary,
type TenantSummary,
} from "~/lib/search-admin"
@@ -74,12 +71,16 @@ type EditorState =
export default function SearchRoute() {
const session = useSession()
const toast = useToast()
const [tenants, setTenants] = useState<TenantSummary[]>([])
const [corpora, setCorpora] = useState<Row[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// arcadia-search is a separate sidecar, so this is often a plain Error /
// TypeError (connection refused) rather than an ArcadiaError. Pass it through
// raw — `describeError` already turns that into "Can't reach arcadia".
const [error, setError] = useState<unknown>(null)
const [corporaError, setCorporaError] = useState<unknown>(null)
const [editor, setEditor] = useState<EditorState>(null)
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<string | null>(
null,
@@ -91,44 +92,42 @@ export default function SearchRoute() {
const [restartConfirm, setRestartConfirm] = useState(false)
const [rebuilding, setRebuilding] = useState<string | null>(null)
const reportError = useCallback((err: unknown, fallback: string) => {
setError(
err instanceof SearchAdminError
? `${err.status}: ${err.message}`
: err instanceof Error
? err.message
: fallback,
)
}, [])
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
setCorporaError(null)
try {
const tRes = await searchAdmin.listTenants()
setTenants(tRes.tenants)
// Fan out per-tenant corpus lookups in parallel.
const cByT = await Promise.all(
tRes.tenants.map(async (t) => {
try {
const r = await searchAdmin.listCorpora(t.id)
return r.corpora
} catch {
return []
}
}),
// Fan out per-tenant corpus lookups in parallel. A tenant whose lookup
// fails no longer disappears silently: if every lookup failed we surface
// the failure instead of rendering "No corpora yet."
const settled = await Promise.allSettled(
tRes.tenants.map((t) => searchAdmin.listCorpora(t.id)),
)
const flat: Row[] = cByT.flat().map((c) => ({
...c,
rowId: `${c.tenant}/${c.corpus}`,
}))
setCorpora(flat)
const ok = settled.filter(
(r): r is PromiseFulfilledResult<{ corpora: CorpusSummary[] }> =>
r.status === "fulfilled",
)
const firstFailure = settled.find((r) => r.status === "rejected")
setCorpora(
ok
.flatMap((r) => r.value.corpora)
.map((c) => ({ ...c, rowId: `${c.tenant}/${c.corpus}` })),
)
if (settled.length > 0 && ok.length === 0 && firstFailure) {
setCorporaError((firstFailure as PromiseRejectedResult).reason)
}
} catch (err) {
reportError(err, "Failed to load search admin state.")
setError(err)
setTenants([])
setCorpora([])
} finally {
setLoading(false)
}
}, [reportError])
}, [])
useEffect(() => {
if (!session) return
@@ -163,20 +162,19 @@ export default function SearchRoute() {
async (tenant: string, corpus: string) => {
const id = `${tenant}/${corpus}`
setRebuilding(id)
setError(null)
try {
const out = await searchAdmin.rebuild(tenant, corpus)
setInfo(
`Rebuilt ${tenant}/${corpus}${out.chunk_count} chunks indexed.`,
)
await refresh()
toast.success(`Rebuilt ${id}`, {
description: `${out.chunk_count} chunks indexed.`,
})
} catch (err) {
reportError(err, "Rebuild failed.")
toast.error(errorMessage(err, `rebuild ${id}`))
} finally {
setRebuilding(null)
}
},
[refresh, reportError],
[refresh, toast],
)
return (
@@ -238,32 +236,34 @@ export default function SearchRoute() {
</div>
</header>
{/* A standing configuration fact, not a load failure — so it isn't an
error state and it isn't dismissible. It stays until it's fixed. */}
{!searchAdmin.hasToken ? (
<AlertBanner variant="warning">
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset. The Search section will
return 401 until the bearer token is configured. Endpoint:{" "}
<div
role="note"
className="rounded-md border bg-muted/40 px-3 py-2.5 text-sm"
data-action="search-token-missing"
>
<p className="font-medium">Admin token not configured</p>
<p className="text-muted-foreground">
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset, so every call below will
come back 401. Endpoint:{" "}
<code className="font-mono">{searchAdmin.baseUrl}</code>
</AlertBanner>
) : null}
{error ? (
<AlertBanner
variant="error"
dismissible
onDismiss={() => setError(null)}
>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner
variant="success"
dismissible
onDismiss={() => setInfo(null)}
>
{info}
</AlertBanner>
</p>
</div>
) : null}
{/* The whole screen hangs off one call to the sidecar. If that call
failed, nothing here loaded — say so once, rather than rendering
three cheerful empty states over a dead connection. */}
{error ? (
<Card>
<CardContent className="p-0">
<ErrorState error={error} onRetry={refresh} />
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
<div className="grid grid-cols-3 gap-3 min-w-0">
@@ -282,17 +282,23 @@ export default function SearchRoute() {
<TenantsCard
tenants={tenants}
loading={loading}
onRetry={refresh}
onDelete={(id) => setPendingDeleteTenant(id)}
/>
<CorporaCard
corpora={corpora}
loading={loading}
error={corporaError}
onRetry={refresh}
rebuildingId={rebuilding}
onRebuild={rebuild}
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
/>
</>
)}
</div>
{/* New tenant */}
@@ -301,10 +307,9 @@ export default function SearchRoute() {
onClose={() => setEditor(null)}
onCreated={async (msg) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(msg)
}}
onError={(msg) => setError(msg)}
/>
{/* New / edit corpus */}
@@ -318,10 +323,9 @@ export default function SearchRoute() {
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(msg)
}}
onError={(msg) => setError(msg)}
/>
{/* Delete tenant */}
@@ -334,14 +338,15 @@ export default function SearchRoute() {
variant="danger"
onConfirm={async () => {
if (!pendingDeleteTenant) return
const id = pendingDeleteTenant
try {
await searchAdmin.deleteTenant(pendingDeleteTenant)
setInfo(`Tenant ${pendingDeleteTenant} deleted.`)
await searchAdmin.deleteTenant(id)
setPendingDeleteTenant(null)
await refresh()
toast.success(`Deleted tenant ${id}`)
} catch (err) {
reportError(err, "Delete failed.")
setPendingDeleteTenant(null)
toast.error(errorMessage(err, `delete tenant ${id}`))
}
}}
/>
@@ -363,12 +368,12 @@ export default function SearchRoute() {
const { tenant, corpus } = pendingDeleteCorpus
try {
await searchAdmin.deleteCorpus(tenant, corpus)
setInfo(`Deleted ${tenant}/${corpus}.`)
setPendingDeleteCorpus(null)
await refresh()
toast.success(`Deleted ${tenant}/${corpus}`)
} catch (err) {
reportError(err, "Delete failed.")
setPendingDeleteCorpus(null)
toast.error(errorMessage(err, `delete ${tenant}/${corpus}`))
}
}}
/>
@@ -385,9 +390,9 @@ export default function SearchRoute() {
setRestartConfirm(false)
try {
await searchAdmin.restart()
setInfo("Restart requested.")
toast.success("Requested a restart of arcadia-search")
} catch (err) {
reportError(err, "Restart request failed.")
toast.error(errorMessage(err, "restart arcadia-search"))
}
}}
/>
@@ -399,9 +404,13 @@ export default function SearchRoute() {
function TenantsCard({
tenants,
loading,
onRetry,
onDelete,
}: {
tenants: TenantSummary[]
loading: boolean
onRetry: () => void
onDelete: (id: string) => void
}) {
return (
@@ -409,14 +418,23 @@ function TenantsCard({
<CardHeader>
<h2 className="text-base font-semibold">Tenants</h2>
</CardHeader>
<CardContent className="p-4">
{tenants.length === 0 ? (
<CardContent className="relative p-4">
{/* The sidecar's own failure is rendered once at page level, so by the
time we get here the load succeeded — this empty state is true. */}
<DataState
loading={loading}
error={null}
isEmpty={tenants.length === 0}
onRetry={onRetry}
loadingLabel="Loading tenants…"
empty={
<EmptyState
title="No tenants yet."
description="Create one to start adding corpora."
className="py-8"
/>
) : (
}
>
<ul className="flex flex-wrap gap-2">
{tenants.map((t) => (
<li
@@ -439,7 +457,7 @@ function TenantsCard({
</li>
))}
</ul>
)}
</DataState>
</CardContent>
</Card>
)
@@ -450,6 +468,8 @@ function TenantsCard({
function CorporaCard({
corpora,
loading,
error,
onRetry,
rebuildingId,
onRebuild,
onEdit,
@@ -457,6 +477,8 @@ function CorporaCard({
}: {
corpora: Row[]
loading: boolean
error: unknown
onRetry: () => void
rebuildingId: string | null
onRebuild: (tenant: string, corpus: string) => void
onEdit: (tenant: string, corpus: string) => void
@@ -593,11 +615,13 @@ function CorporaCard({
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && corpora.length === 0}
label="Loading corpora…"
/>
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRetry}
loadingLabel="Loading corpora…"
empty={
<EmptyState
icon={<Database className="size-6" />}
title={search ? "No matches." : "No corpora yet."}
@@ -606,8 +630,8 @@ function CorporaCard({
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -624,8 +648,7 @@ function CorporaCard({
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
)
@@ -637,31 +660,30 @@ function NewTenantDialog({
open,
onClose,
onCreated,
onError,
}: {
open: boolean
onClose: () => void
onCreated: (msg?: string) => Promise<void>
onError: (msg: string) => void
onCreated: (msg: string) => Promise<void>
}) {
const [id, setId] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) setId("")
if (!open) {
setId("")
setError(null)
}
}, [open])
const submit = async () => {
setError(null)
setSaving(true)
try {
await searchAdmin.createTenant(id)
await onCreated(`Tenant ${id} created.`)
await onCreated(`Created tenant ${id}`)
} catch (err) {
onError(
err instanceof SearchAdminError
? `${err.status}: ${err.message}`
: "Create failed.",
)
setError(err)
} finally {
setSaving(false)
}
@@ -691,6 +713,9 @@ function NewTenantDialog({
data-action="tenant-form-id"
/>
</div>
{error ? <DialogError error={error} context="create the tenant" /> : null}
<DialogFooter>
<Button
variant="outline"
@@ -738,7 +763,6 @@ function CorpusEditor({
tenants,
onClose,
onSaved,
onError,
}: {
editor:
| { kind: "new-corpus"; tenant: string }
@@ -746,13 +770,15 @@ function CorpusEditor({
| null
tenants: TenantSummary[]
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string) => void
onSaved: (msg: string) => Promise<void>
}) {
const [tenant, setTenant] = useState("")
const [text, setText] = useState("")
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
// Covers both the hydrate-on-open failure and the save failure. Either way
// the operator is looking at this dialog, so this is where it has to speak.
const [error, setError] = useState<unknown>(null)
const isEdit = editor?.kind === "edit-corpus"
const headerCorpus = isEdit ? editor.corpus : ""
@@ -761,6 +787,7 @@ function CorpusEditor({
useEffect(() => {
if (!editor) return
setTenant(editor.tenant)
setError(null)
if (editor.kind === "edit-corpus") {
setLoading(true)
searchAdmin
@@ -768,22 +795,17 @@ function CorpusEditor({
.then((res) => {
setText(JSON.stringify(res.config, null, 2))
})
.catch((err) => {
onError(
err instanceof SearchAdminError
? `${err.status}: ${err.message}`
: "Load failed.",
)
})
.catch((err) => setError(err))
.finally(() => setLoading(false))
} else {
setText(CORPUS_CONFIG_TEMPLATE)
}
}, [editor, onError])
}, [editor])
if (!editor) return null
const submit = async () => {
setError(null)
setSaving(true)
try {
const parsed = JSON.parse(text)
@@ -796,19 +818,14 @@ function CorpusEditor({
throw new Error('config must have a string "corpus" field')
}
await searchAdmin.createCorpus(tenant, parsed)
await onSaved(`Created ${tenant}/${corpus}.`)
await onSaved(`Created ${tenant}/${corpus}`)
} else {
await searchAdmin.updateCorpus(editor.tenant, editor.corpus, parsed)
await onSaved(`Updated ${editor.tenant}/${editor.corpus}.`)
await onSaved(`Updated ${editor.tenant}/${editor.corpus}`)
}
} catch (err) {
onError(
err instanceof SearchAdminError
? `${err.status}: ${err.message}`
: err instanceof Error
? err.message
: "Save failed.",
)
// The JSON the operator just wrote stays in the textarea.
setError(err)
} finally {
setSaving(false)
}
@@ -865,6 +882,13 @@ function CorpusEditor({
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the corpus" : "create the corpus"}
/>
) : null}
<DialogFooter>
<Button
variant="outline"
@@ -891,3 +915,5 @@ function CorpusEditor({
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -12,7 +12,8 @@ import {
Trash2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -25,9 +26,11 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Button } from "~/components/ui/button"
import {
Card,
@@ -91,11 +94,13 @@ type EditorState =
export default function SecretsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [secrets, setSecrets] = useState<Secret[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// The raw thrown value — `DataState` normalises it into plain language. A
// secrets list that failed to load must never read as "no secrets yet".
const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("")
const [categoryFilter, setCategoryFilter] = useState<"all" | SecretCategory>("all")
const [editor, setEditor] = useState<EditorState>(null)
@@ -107,7 +112,7 @@ export default function SecretsRoute() {
try {
setSecrets(await listSecrets(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load secrets.")
setError(err)
} finally {
setLoading(false)
}
@@ -204,15 +209,14 @@ export default function SecretsRoute() {
refresh,
setEditor,
setPendingDelete,
setError,
setInfo,
toast,
})}
triggerDataAction={`secret-${s.name}-actions`}
/>
),
},
],
[arcadia, refresh],
[arcadia, refresh, toast],
)
const summary = useMemo(
@@ -279,17 +283,6 @@ export default function SecretsRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row flex-wrap items-center gap-3">
<SearchInput
@@ -321,8 +314,13 @@ export default function SecretsRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && secrets.length === 0} label="Loading secrets…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading secrets…"
empty={
<EmptyState
title={
search || categoryFilter !== "all"
@@ -336,8 +334,8 @@ export default function SecretsRoute() {
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -354,8 +352,7 @@ export default function SecretsRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</div>
@@ -373,14 +370,15 @@ export default function SecretsRoute() {
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const target = pendingDelete
try {
await deleteSecret(arcadia, pendingDelete.id)
await deleteSecret(arcadia, target.id)
setPendingDelete(null)
setInfo("Secret deleted.")
await refresh()
toast.success(`Deleted ${target.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${target.name}`))
}
}}
/>
@@ -388,12 +386,13 @@ export default function SecretsRoute() {
<SecretEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) setInfo(msg)
onSaved={async (msg, opts) => {
// The versions dialog stays open across a rollback — only the
// create/edit/rotate flows close on success.
if (!opts?.keepOpen) setEditor(null)
await refresh()
toast.success(msg)
}}
onError={setError}
/>
</AppShell>
)
@@ -420,11 +419,10 @@ function rowActions(
refresh: () => Promise<void>
setEditor: (e: EditorState) => void
setPendingDelete: (s: Secret | null) => void
setError: (m: string | null) => void
setInfo: (m: string | null) => void
toast: ReturnType<typeof useToast>
},
): ActionItem[] {
const { arcadia, refresh, setEditor, setPendingDelete, setError, setInfo } = ctx
const { arcadia, refresh, setEditor, setPendingDelete, toast } = ctx
const items: ActionItem[] = []
items.push({
@@ -457,10 +455,10 @@ function rowActions(
onSelect: async () => {
try {
await disableSecret(arcadia, s.id)
setInfo(`${s.name} disabled.`)
await refresh()
toast.success(`Disabled ${s.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Disable failed.")
toast.error(errorMessage(err, `disable ${s.name}`))
}
},
})
@@ -473,10 +471,10 @@ function rowActions(
onSelect: async () => {
try {
await enableSecret(arcadia, s.id)
setInfo(`${s.name} enabled.`)
await refresh()
toast.success(`Enabled ${s.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Enable failed.")
toast.error(errorMessage(err, `enable ${s.name}`))
}
},
})
@@ -494,41 +492,43 @@ function rowActions(
return items
}
/** What the parent does when a dialog reports success. */
type OnSaved = (message: string, opts?: { keepOpen?: boolean }) => Promise<void>
function SecretEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: EditorState
onClose: () => void
onSaved: (info?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: OnSaved
}) {
if (state?.mode === "versions") {
return <VersionsDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
return <VersionsDialog state={state} onClose={onClose} onSaved={onSaved} />
}
if (state?.mode === "rotate") {
return <RotateDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
return <RotateDialog state={state} onClose={onClose} onSaved={onSaved} />
}
return <UpsertDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
return <UpsertDialog state={state} onClose={onClose} onSaved={onSaved} />
}
function UpsertDialog({
state,
onClose,
onSaved,
onError,
}: {
state: EditorState
onClose: () => void
onSaved: (info?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: OnSaved
}) {
const arcadia = useArcadiaClient()
const open = state?.mode === "create" || state?.mode === "edit"
const isEdit = state?.mode === "edit"
const initial = isEdit ? state.secret : null
// A failed save renders here, not on the page behind the scrim — and the
// form keeps its state, including the value the operator just pasted.
const [error, setError] = useState<unknown>(null)
const [name, setName] = useState("")
const [value, setValue] = useState("")
@@ -545,7 +545,11 @@ function UpsertDialog({
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
if (!open) {
setError(null)
return
}
setError(null)
if (initial) {
setName(initial.name)
setValue("")
@@ -577,18 +581,19 @@ function UpsertDialog({
const generate = async () => {
setGenerating(true)
setError(null)
try {
const v = await generateSecretValue(arcadia, { length: 48 })
setValue(v)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Generate failed.")
setError(err)
} finally {
setGenerating(false)
}
}
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const tags = csv(tagsText)
@@ -613,7 +618,7 @@ function UpsertDialog({
expires_at,
rotation_interval_days,
})
await onSaved("Secret metadata updated.")
await onSaved(`Updated ${initial.name}`)
} else {
if (!value) throw new Error("A value is required for new secrets.")
const input: SecretCreateInput = {
@@ -630,10 +635,10 @@ function UpsertDialog({
rotation_interval_days,
}
await createSecret(arcadia, input)
await onSaved("Secret created.")
await onSaved(`Created ${name}`)
}
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
setError(err)
} finally {
setSaving(false)
}
@@ -812,6 +817,13 @@ function UpsertDialog({
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the secret" : "create the secret"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="secret-form-cancel">
Cancel
@@ -834,43 +846,46 @@ function RotateDialog({
state,
onClose,
onSaved,
onError,
}: {
state: { mode: "rotate"; secret: Secret }
onClose: () => void
onSaved: (info?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: OnSaved
}) {
const arcadia = useArcadiaClient()
const [value, setValue] = useState("")
const [note, setNote] = useState("")
const [saving, setSaving] = useState(false)
const [generating, setGenerating] = useState(false)
// Rotation is destructive-adjacent: if it fails, the operator must see why
// *here*, with the new value still in the field.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
setValue("")
setNote("")
setError(null)
}, [state])
const generate = async () => {
setGenerating(true)
setError(null)
try {
setValue(await generateSecretValue(arcadia, { length: 48 }))
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Generate failed.")
setError(err)
} finally {
setGenerating(false)
}
}
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
await rotateSecret(arcadia, state.secret.id, { value, note: note || undefined })
await onSaved(`${state.secret.name} rotated.`)
await onSaved(`Rotated ${state.secret.name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Rotate failed.")
setError(err)
} finally {
setSaving(false)
}
@@ -924,6 +939,10 @@ function RotateDialog({
</div>
</div>
{error ? (
<DialogError error={error} context={`rotate ${state.secret.name}`} />
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="secret-rotate-cancel">
Cancel
@@ -942,28 +961,31 @@ function VersionsDialog({
state,
onClose,
onSaved,
onError,
}: {
state: { mode: "versions"; secret: Secret }
onClose: () => void
onSaved: (info?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: OnSaved
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [versions, setVersions] = useState<SecretVersion[]>([])
const [loading, setLoading] = useState(true)
// A versions load that failed is not a secret with no history. Own error
// state, rendered in place of the list.
const [error, setError] = useState<unknown>(null)
const [reloadKey, setReloadKey] = useState(0)
const [pendingRollback, setPendingRollback] = useState<SecretVersion | null>(null)
useEffect(() => {
let mounted = true
setLoading(true)
setError(null)
listSecretVersions(arcadia, state.secret.id)
.then((v) => {
if (mounted) setVersions(v.sort((a, b) => b.version - a.version))
})
.catch((err) => {
if (mounted)
onError(err instanceof ArcadiaError ? err.message : "Failed to load versions.")
if (mounted) setError(err)
})
.finally(() => {
if (mounted) setLoading(false)
@@ -971,7 +993,7 @@ function VersionsDialog({
return () => {
mounted = false
}
}, [arcadia, state.secret.id, onError])
}, [arcadia, state.secret.id, reloadKey])
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
@@ -983,15 +1005,18 @@ function VersionsDialog({
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
<RefreshCw className="mr-2 size-4 animate-spin" /> Loading
</div>
) : versions.length === 0 ? (
<DataState
loading={loading}
error={error}
isEmpty={versions.length === 0}
onRetry={() => setReloadKey((n) => n + 1)}
loadingLabel="Loading versions…"
empty={
<p className="py-6 text-center text-sm text-muted-foreground">
No previous versions yet. Rotate the value to create one.
</p>
) : (
}
>
<ul className="flex flex-col divide-y rounded-md border">
{versions.map((v) => (
<li key={v.id} className="flex items-center justify-between gap-3 px-3 py-2">
@@ -1015,7 +1040,7 @@ function VersionsDialog({
</li>
))}
</ul>
)}
</DataState>
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="secret-versions-close">
@@ -1036,13 +1061,25 @@ function VersionsDialog({
variant="default"
onConfirm={async () => {
if (!pendingRollback) return
const target = pendingRollback
try {
await rollbackSecret(arcadia, state.secret.id, pendingRollback.version)
await rollbackSecret(arcadia, state.secret.id, target.version)
setPendingRollback(null)
await onSaved(`Rolled back to version ${pendingRollback.version}.`)
setReloadKey((n) => n + 1)
// Keep the versions dialog open — the rollback minted a new
// version, and the operator is looking right at the list.
await onSaved(
`Rolled ${state.secret.name} back to version ${target.version}`,
{ keepOpen: true },
)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Rollback failed.")
setPendingRollback(null)
toast.error(
errorMessage(
err,
`roll ${state.secret.name} back to version ${target.version}`,
),
)
}
}}
/>
@@ -1065,3 +1102,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return acc
}, {})
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -22,6 +22,8 @@ import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
import {
Card,
CardContent,
@@ -483,3 +485,5 @@ function AgentsPanel() {
</Card>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -9,7 +9,8 @@ import {
X,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -18,21 +19,16 @@ import {
Pagination,
useTable,
type ActionItem,
type BadgeTone,
type Column,
} from "@crema/table-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Card, CardContent } from "~/components/ui/card"
import {
Dialog,
DialogContent,
@@ -68,40 +64,65 @@ type Editor =
| { kind: "edit"; idp: IdentityProvider }
| null
/** Who a SAML session belongs to, in the operator's words. */
function sessionLabel(s: SamlSession): string {
return s.name_id ?? s.user_id
}
export default function SsoRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
// Two tabs, two independent fetches. They used to share one load that
// swallowed each failure with `.catch(() => [])`, so a broken endpoint was
// indistinguishable from "no identity providers".
const [idps, setIdps] = useState<IdentityProvider[]>([])
const [idpsLoading, setIdpsLoading] = useState(true)
const [idpsError, setIdpsError] = useState<unknown>(null)
const [sessions, setSessions] = useState<SamlSession[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [sessionsLoading, setSessionsLoading] = useState(true)
const [sessionsError, setSessionsError] = useState<unknown>(null)
const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<IdentityProvider | null>(null)
const [pendingSessionDestroy, setPendingSessionDestroy] = useState<SamlSession | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
const refreshIdps = useCallback(async () => {
setIdpsError(null)
setIdpsLoading(true)
try {
const [i, s] = await Promise.all([
listIdentityProviders(arcadia).catch(() => [] as IdentityProvider[]),
listSamlSessions(arcadia).catch(() => [] as SamlSession[]),
])
setIdps(i)
setSessions(s)
setIdps(await listIdentityProviders(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load SSO data.")
setIdpsError(err)
} finally {
setLoading(false)
setIdpsLoading(false)
}
}, [arcadia])
const refreshSessions = useCallback(async () => {
setSessionsError(null)
setSessionsLoading(true)
try {
setSessions(await listSamlSessions(arcadia))
} catch (err) {
setSessionsError(err)
} finally {
setSessionsLoading(false)
}
}, [arcadia])
const refresh = useCallback(async () => {
await Promise.all([refreshIdps(), refreshSessions()])
}, [refreshIdps, refreshSessions])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const loading = idpsLoading || sessionsLoading
useRegisterContext("sso", {
identity_providers: idps.length,
enabled_idps: idps.filter((i) => i.enabled).length,
@@ -176,12 +197,13 @@ export default function SsoRoute() {
label: i.enabled ? "Disable" : "Enable",
dataAction: `idp-${i.id}-toggle`,
onSelect: async () => {
const verb = i.enabled ? "disable" : "enable"
try {
await updateIdentityProvider(arcadia, i.id, { enabled: !i.enabled })
setInfo(`${i.name} ${i.enabled ? "disabled" : "enabled"}.`)
await refresh()
await refreshIdps()
toast.success(`${i.enabled ? "Disabled" : "Enabled"} ${i.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Toggle failed.")
toast.error(errorMessage(err, `${verb} ${i.name}`))
}
},
},
@@ -198,7 +220,7 @@ export default function SsoRoute() {
},
},
],
[arcadia, refresh],
[arcadia, refreshIdps, toast],
)
const idpTable = useTable<IdentityProvider>({
@@ -231,17 +253,6 @@ export default function SsoRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Tabs defaultValue="idps">
<TabsList>
<TabsTrigger value="idps" data-action="sso-tab-idps">
@@ -255,23 +266,28 @@ export default function SsoRoute() {
<TabsContent value="idps" className="pt-4">
<Card>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && idps.length === 0} label="Loading IdPs…" />
{idpTable.total === 0 && !loading ? (
<DataState
loading={idpsLoading}
error={idpsError}
isEmpty={idpTable.total === 0}
onRetry={refreshIdps}
loadingLabel="Loading IdPs…"
empty={
<EmptyState
icon={<KeyRound className="size-6" />}
title="No identity providers."
description="Connect a SAML IdP (Okta, Azure AD, Google Workspace, etc.) to enable SSO for this tenant."
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={idpColumns}
rows={idpTable.pageRows}
getRowId={(i) => i.id}
sort={idpTable.sort}
onSortToggle={idpTable.toggleSort}
loading={loading && idps.length > 0}
loading={idpsLoading && idps.length > 0}
stickyHeader
/>
<Pagination
@@ -281,22 +297,28 @@ export default function SsoRoute() {
onPageChange={idpTable.setPage}
onPageSizeChange={idpTable.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="sessions" className="pt-4">
<Card>
<CardContent className="p-0">
{sessions.length === 0 ? (
<CardContent className="relative p-0">
<DataState
loading={sessionsLoading}
error={sessionsError}
isEmpty={sessions.length === 0}
onRetry={refreshSessions}
loadingLabel="Loading sessions…"
empty={
<EmptyState
title="No active SAML sessions."
description="Sessions appear here once users authenticate via the IdP."
className="py-12"
/>
) : (
}
>
<ul className="divide-y border-y">
{sessions.map((s) => (
<li
@@ -305,7 +327,7 @@ export default function SsoRoute() {
>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2">
<code className="font-mono text-xs">{s.name_id ?? s.user_id}</code>
<code className="font-mono text-xs">{sessionLabel(s)}</code>
{s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? (
<Badge variant="destructive">expired</Badge>
) : (
@@ -333,7 +355,7 @@ export default function SsoRoute() {
</li>
))}
</ul>
)}
</DataState>
</CardContent>
</Card>
</TabsContent>
@@ -346,21 +368,22 @@ export default function SsoRoute() {
title="Delete identity provider?"
description={
pendingDelete
? `${pendingDelete.name} will be removed. Existing SAML sessions remain valid until they expire.`
? `Nobody can sign in through ${pendingDelete.name} once it's deleted, and its configuration — including the certificate — is gone for good. Existing SAML sessions stay valid until they expire. Disable it instead if this is temporary.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const name = pendingDelete.name
try {
await deleteIdentityProvider(arcadia, pendingDelete.id)
setPendingDelete(null)
setInfo("Identity provider deleted.")
await refresh()
toast.success(`Deleted ${name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${name}`))
}
}}
/>
@@ -371,21 +394,22 @@ export default function SsoRoute() {
title="Destroy SAML session?"
description={
pendingSessionDestroy
? `Session for ${pendingSessionDestroy.name_id ?? pendingSessionDestroy.user_id} will be revoked.`
? `${sessionLabel(pendingSessionDestroy)} is signed out immediately and has to authenticate with the IdP again.`
: ""
}
confirmLabel="Destroy"
variant="danger"
onConfirm={async () => {
if (!pendingSessionDestroy) return
const who = sessionLabel(pendingSessionDestroy)
try {
await destroySamlSession(arcadia, pendingSessionDestroy.id)
setPendingSessionDestroy(null)
setInfo("Session destroyed.")
await refresh()
await refreshSessions()
toast.success(`Destroyed the session for ${who}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Destroy failed.")
setPendingSessionDestroy(null)
toast.error(errorMessage(err, `destroy the session for ${who}`))
}
}}
/>
@@ -393,12 +417,11 @@ export default function SsoRoute() {
<IdpEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async (msg) => {
onSaved={async (message) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
await refreshIdps()
toast.success(message)
}}
onError={setError}
/>
</AppShell>
)
@@ -408,12 +431,10 @@ function IdpEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: Editor
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -431,9 +452,14 @@ function IdpEditorDialog({
const [certificate, setCertificate] = useState("")
const [attrJson, setAttrJson] = useState("{}")
const [saving, setSaving] = useState(false)
// Inside the dialog, above the buttons — a page banner here would sit behind
// the scrim, and a rejected certificate would look like nothing happened.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) return
setError(null)
setSaving(false)
if (initial) {
setName(initial.name)
setEntityId(initial.entity_id)
@@ -460,7 +486,7 @@ function IdpEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
let attribute_mapping: Record<string, string> = {}
@@ -484,20 +510,15 @@ function IdpEditorDialog({
if (isEdit && initial) {
await updateIdentityProvider(arcadia, initial.id, input)
await onSaved("Identity provider updated.")
await onSaved(`Saved ${name.trim()}`)
} else {
await createIdentityProvider(arcadia, input)
await onSaved("Identity provider created.")
await onSaved(`Created ${name.trim()}`)
}
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
} finally {
// Keep the pasted certificate and JSON on screen — retyping them is the
// last thing an operator should have to do after a failed save.
setError(err)
setSaving(false)
}
}
@@ -636,8 +657,15 @@ function IdpEditorDialog({
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the identity provider" : "create the identity provider"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="idp-form-cancel">
Cancel
</Button>
<Button
@@ -653,3 +681,5 @@ function IdpEditorDialog({
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -8,8 +8,9 @@ import {
Trash2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import {
IncidentTimeline,
StatusBoard,
@@ -20,6 +21,8 @@ import {
} from "@crema/status-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -103,26 +106,45 @@ export default function StatusPageRoute() {
const [incidents, setIncidents] = useState<Incident[]>([])
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Three independent endpoints, three independent errors. These used to be
// `.catch(() => [])` — every failure silently became an empty list, so a
// broken incidents endpoint rendered as the reassuring "No incidents. No
// drama is the right state." One failing tab must not blank the other two.
const [componentsError, setComponentsError] = useState<unknown>(null)
const [incidentsError, setIncidentsError] = useState<unknown>(null)
const [subscribersError, setSubscribersError] = useState<unknown>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
const [c, i, s] = await Promise.all([
listComponents(arcadia).catch(() => [] as StatusComponent[]),
listIncidents(arcadia).catch(() => [] as Incident[]),
listSubscribers(arcadia).catch(() => [] as Subscriber[]),
setComponentsError(null)
setIncidentsError(null)
setSubscribersError(null)
const [c, i, s] = await Promise.allSettled([
listComponents(arcadia),
listIncidents(arcadia),
listSubscribers(arcadia),
])
setComponents(c)
setIncidents(i)
setSubscribers(s)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load status page.")
} finally {
setLoading(false)
if (c.status === "fulfilled") setComponents(c.value)
else {
setComponents([])
setComponentsError(c.reason)
}
if (i.status === "fulfilled") setIncidents(i.value)
else {
setIncidents([])
setIncidentsError(i.reason)
}
if (s.status === "fulfilled") setSubscribers(s.value)
else {
setSubscribers([])
setSubscribersError(s.reason)
}
setLoading(false)
}, [arcadia])
useEffect(() => {
@@ -193,17 +215,6 @@ export default function StatusPageRoute() {
</Button>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
{/* Live preview using the public-facing widget */}
{uiComponents.length > 0 ? (
<Card>
@@ -242,9 +253,8 @@ export default function StatusPageRoute() {
<ComponentsPanel
components={components}
loading={loading}
error={componentsError}
onChanged={refresh}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
@@ -253,14 +263,18 @@ export default function StatusPageRoute() {
incidents={incidents}
components={components}
loading={loading}
error={incidentsError}
onChanged={refresh}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="subscribers" className="pt-4">
<SubscribersPanel subscribers={subscribers} loading={loading} />
<SubscribersPanel
subscribers={subscribers}
loading={loading}
error={subscribersError}
onRetry={refresh}
/>
</TabsContent>
</Tabs>
</div>
@@ -296,17 +310,16 @@ function impactToSeverity(i: IncidentImpact): Severity {
function ComponentsPanel({
components,
loading,
error,
onChanged,
onError,
onInfo,
}: {
components: StatusComponent[]
loading: boolean
error: unknown
onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [editor, setEditor] = useState<ComponentEditor>(null)
const [pendingDelete, setPendingDelete] = useState<StatusComponent | null>(null)
@@ -323,13 +336,20 @@ function ComponentsPanel({
</Button>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && components.length === 0}
label="Loading components…"
<DataState
loading={loading}
error={error}
isEmpty={components.length === 0}
onRetry={onChanged}
loadingLabel="Loading components…"
empty={
<EmptyState
title="No components yet."
description="Add the first one to seed the public board."
className="py-8"
/>
{components.length === 0 && !loading ? (
<EmptyState title="No components yet." description="Add the first one to seed the public board." className="py-8" />
) : (
}
>
<ul className="divide-y border-y">
{components.map((c) => (
<li
@@ -371,7 +391,7 @@ function ComponentsPanel({
</li>
))}
</ul>
)}
</DataState>
</CardContent>
<ComponentEditorDialog
@@ -379,10 +399,9 @@ function ComponentsPanel({
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) onInfo(msg)
await onChanged()
toast.success(msg)
}}
onError={onError}
/>
<ConfirmDialog
@@ -398,14 +417,15 @@ function ComponentsPanel({
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const name = pendingDelete.name
try {
await deleteComponent(arcadia, pendingDelete.id)
setPendingDelete(null)
onInfo("Component deleted.")
await onChanged()
toast.success(`Deleted ${name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${name}`))
}
}}
/>
@@ -424,12 +444,10 @@ function ComponentEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: ComponentEditor
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (msg: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -442,6 +460,11 @@ function ComponentEditorDialog({
const [groupName, setGroupName] = useState("")
const [displayOrder, setDisplayOrder] = useState("0")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) setError(null)
}, [open])
useEffect(() => {
if (!open) return
@@ -461,7 +484,7 @@ function ComponentEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const input: ComponentInput = {
@@ -473,13 +496,13 @@ function ComponentEditorDialog({
}
if (isEdit && initial) {
await updateComponent(arcadia, initial.id, input)
await onSaved("Component updated.")
await onSaved(`Updated ${name}`)
} else {
await createComponent(arcadia, input)
await onSaved("Component created.")
await onSaved(`Created ${name}`)
}
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
setError(err)
} finally {
setSaving(false)
}
@@ -547,8 +570,21 @@ function ComponentEditorDialog({
/>
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the component" : "create the component"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="status-component-form-cancel"
>
Cancel
</Button>
<Button onClick={submit} disabled={saving || !name} data-action="status-component-form-save">
@@ -567,18 +603,17 @@ function IncidentsPanel({
incidents,
components,
loading,
error,
onChanged,
onError,
onInfo,
}: {
incidents: Incident[]
components: StatusComponent[]
loading: boolean
error: unknown
onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [editor, setEditor] = useState<IncidentEditor>(null)
return (
@@ -594,15 +629,24 @@ function IncidentsPanel({
</Button>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && incidents.length === 0} label="Loading incidents…" />
{incidents.length === 0 && !loading ? (
{/* "No drama is the right state" is a lovely thing to say — and a
dangerous thing to say when the endpoint just 500'd. DataState makes
sure it's only ever said about a load that actually succeeded. */}
<DataState
loading={loading}
error={error}
isEmpty={incidents.length === 0}
onRetry={onChanged}
loadingLabel="Loading incidents…"
empty={
<EmptyState
icon={<AlertTriangle className="size-6" />}
title="No incidents."
description="No drama is the right state."
className="py-8"
/>
) : (
}
>
<ul className="flex flex-col divide-y border-y">
{incidents.map((i) => (
<li key={i.id} className="flex flex-col gap-2 px-3 py-3 text-sm">
@@ -639,12 +683,10 @@ function IncidentsPanel({
onClick={async () => {
try {
await resolveIncident(arcadia, i.id)
onInfo("Incident resolved.")
await onChanged()
toast.success(`Resolved "${i.title}"`)
} catch (err) {
onError(
err instanceof ArcadiaError ? err.message : "Resolve failed.",
)
toast.error(errorMessage(err, `resolve "${i.title}"`))
}
}}
data-action={`status-incident-${i.id}-resolve`}
@@ -683,7 +725,7 @@ function IncidentsPanel({
</li>
))}
</ul>
)}
</DataState>
</CardContent>
<IncidentEditorDialog
@@ -692,10 +734,9 @@ function IncidentsPanel({
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) onInfo(msg)
await onChanged()
toast.success(msg)
}}
onError={onError}
/>
</Card>
)
@@ -714,13 +755,11 @@ function IncidentEditorDialog({
components,
onClose,
onSaved,
onError,
}: {
state: IncidentEditor
components: StatusComponent[]
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (msg: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -730,8 +769,7 @@ function IncidentEditorDialog({
<PostUpdateDialog
incident={state.incident}
onClose={onClose}
onSaved={() => onSaved("Update posted.")}
onError={onError}
onSaved={() => onSaved(`Posted an update on "${state.incident.title}"`)}
/>
)
}
@@ -744,6 +782,11 @@ function IncidentEditorDialog({
const [impact, setImpact] = useState<IncidentImpact>("minor")
const [componentIds, setComponentIds] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) setError(null)
}, [open])
useEffect(() => {
if (!open) return
@@ -761,7 +804,7 @@ function IncidentEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const input: IncidentInput = {
@@ -772,13 +815,13 @@ function IncidentEditorDialog({
}
if (isEdit && initial) {
await updateIncident(arcadia, initial.id, input)
await onSaved("Incident updated.")
await onSaved(`Updated "${title}"`)
} else {
await createIncident(arcadia, input)
await onSaved("Incident opened.")
await onSaved(`Opened incident "${title}"`)
}
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
setError(err)
} finally {
setSaving(false)
}
@@ -871,8 +914,21 @@ function IncidentEditorDialog({
)}
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the incident" : "open the incident"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="status-incident-form-cancel"
>
Cancel
</Button>
<Button
@@ -893,26 +949,26 @@ function PostUpdateDialog({
incident,
onClose,
onSaved,
onError,
}: {
incident: Incident
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [status, setStatus] = useState<IncidentStatus>(incident.status)
const [body, setBody] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
await addIncidentUpdate(arcadia, incident.id, { status, body })
await onSaved()
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Post failed.")
// The body the operator just wrote stays in the textarea.
setError(err)
} finally {
setSaving(false)
}
@@ -955,8 +1011,16 @@ function PostUpdateDialog({
/>
</div>
</div>
{error ? <DialogError error={error} context="post the update" /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="status-incident-update-cancel"
>
Cancel
</Button>
<Button onClick={submit} disabled={saving || !body} data-action="status-incident-update-save">
@@ -974,30 +1038,32 @@ function PostUpdateDialog({
function SubscribersPanel({
subscribers,
loading,
error,
onRetry,
}: {
subscribers: Subscriber[]
loading: boolean
error: unknown
onRetry: () => void
}) {
if (loading && subscribers.length === 0) {
return (
<Card>
<CardContent className="relative py-8">
<LoadingOverlay active label="Loading subscribers" />
</CardContent>
</Card>
)
}
return (
<Card>
<CardContent className="p-0">
{subscribers.length === 0 ? (
<DataState
loading={loading}
error={error}
isEmpty={subscribers.length === 0}
onRetry={onRetry}
loadingLabel="Loading subscribers"
empty={
<EmptyState
icon={<Mail className="size-6" />}
title="No subscribers yet."
description="They appear here once they confirm via the public status page."
className="py-8"
/>
) : (
}
>
<ul className="divide-y border-y">
{subscribers.map((s) => (
<li
@@ -1020,8 +1086,10 @@ function SubscribersPanel({
</li>
))}
</ul>
)}
</DataState>
</CardContent>
</Card>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -12,7 +12,8 @@ import {
Wrench,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -25,9 +26,11 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Button } from "~/components/ui/button"
import {
Card,
@@ -90,14 +93,27 @@ type EditorState =
| { mode: "edit"; config: StorageConfig }
| null
const ACTION_PAST_TENSE: Record<
Exclude<NonNullable<PendingAction>["kind"], never>,
string
> = {
deactivate: "Deactivated",
degraded: "Marked degraded",
maintenance: "Marked in maintenance",
delete: "Deleted",
}
export default function StorageRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [configs, setConfigs] = useState<StorageConfig[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// The raw thrown value — `DataState` normalises it into plain language. A
// 500 here used to render "Internal Server Error" *and* "No storage configs
// yet." side by side, which reads as an empty deployment.
const [error, setError] = useState<unknown>(null)
const [pending, setPending] = useState<PendingAction>(null)
const [editor, setEditor] = useState<EditorState>(null)
const [search, setSearch] = useState("")
@@ -109,7 +125,7 @@ export default function StorageRoute() {
const list = await listStorageConfigs(arcadia)
setConfigs(list)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load storage configs.")
setError(err)
} finally {
setLoading(false)
}
@@ -131,30 +147,35 @@ export default function StorageRoute() {
else if (action.kind === "delete") await deleteStorageConfig(arcadia, action.config.id)
setPending(null)
await refresh()
toast.success(`${ACTION_PAST_TENSE[action.kind]} ${action.config.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Action failed.")
setPending(null)
toast.error(errorMessage(err, `${action.kind} ${action.config.name}`))
}
},
[arcadia, refresh],
[arcadia, refresh, toast],
)
const validate = useCallback(
async (config: StorageConfig) => {
setError(null)
setInfo(null)
try {
const result = await validateStorageConfig(arcadia, config.id)
if (result?.ok) {
setInfo(`${config.name}: validation passed.`)
toast.success(`${config.name} validated`, {
description: "The backend answered with the credentials on file.",
})
} else {
setError(`${config.name}: ${result?.message ?? "validation failed."}`)
// The call succeeded; the *credentials* didn't. That's not a load
// failure, so it belongs in a toast, not the table's error slot.
toast.error(`${config.name} failed validation`, {
description: result?.message ?? undefined,
})
}
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Validation failed.")
toast.error(errorMessage(err, `validate ${config.name}`))
}
},
[arcadia],
[arcadia, toast],
)
const columns = useMemo<Column<StorageConfig>[]>(
@@ -216,7 +237,7 @@ export default function StorageRoute() {
refresh,
setPending,
setEditor,
setError,
toast,
validate,
})}
triggerDataAction={`storage-${slugify(c.name)}-actions`}
@@ -224,7 +245,7 @@ export default function StorageRoute() {
),
},
],
[arcadia, refresh, validate],
[arcadia, refresh, toast, validate],
)
const summary = useMemo(
@@ -295,17 +316,6 @@ export default function StorageRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<SearchInput
@@ -321,8 +331,13 @@ export default function StorageRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && configs.length === 0} label="Loading storage configs…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading storage configs…"
empty={
<EmptyState
title={search ? "No configs match that search." : "No storage configs yet."}
description={
@@ -332,8 +347,8 @@ export default function StorageRoute() {
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -350,8 +365,7 @@ export default function StorageRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</div>
@@ -412,11 +426,11 @@ export default function StorageRoute() {
<StorageEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async () => {
onSaved={async (name, wasEdit) => {
setEditor(null)
await refresh()
toast.success(wasEdit ? `Saved ${name}` : `Created ${name}`)
}}
onError={setError}
/>
</AppShell>
)
@@ -437,11 +451,11 @@ function rowActions(
refresh: () => Promise<void>
setPending: (p: PendingAction) => void
setEditor: (s: EditorState) => void
setError: (msg: string | null) => void
toast: ReturnType<typeof useToast>
validate: (c: StorageConfig) => Promise<void>
},
): ActionItem[] {
const { arcadia, refresh, setPending, setEditor, setError, validate } = ctx
const { arcadia, refresh, setPending, setEditor, toast, validate } = ctx
const slug = slugify(c.name)
const items: ActionItem[] = []
@@ -478,8 +492,9 @@ function rowActions(
try {
await activateStorageConfig(arcadia, c.id)
await refresh()
toast.success(`Activated ${c.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
toast.error(errorMessage(err, `activate ${c.name}`))
}
},
})
@@ -495,8 +510,9 @@ function rowActions(
try {
await setDefaultStorageConfig(arcadia, c.id)
await refresh()
toast.success(`${c.name} is now the default backend`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Set default failed.")
toast.error(errorMessage(err, `set ${c.name} as the default backend`))
}
},
})
@@ -533,17 +549,18 @@ function StorageEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: EditorState
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
onSaved: (name: string, wasEdit: boolean) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
const isEdit = state?.mode === "edit"
const initial = isEdit ? state.config : null
// Errors belong to the dialog, not the page: a page-level banner renders
// behind the modal scrim, where nobody reads it.
const [error, setError] = useState<unknown>(null)
const [name, setName] = useState("")
const [backend, setBackend] = useState<StorageBackend>("s3")
@@ -556,7 +573,11 @@ function StorageEditorDialog({
// Reset form whenever the dialog opens / target changes.
useEffect(() => {
if (!open) return
if (!open) {
setError(null)
return
}
setError(null)
if (initial) {
setName(initial.name)
setBackend(initial.backend_type)
@@ -595,7 +616,7 @@ function StorageEditorDialog({
}
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const config: Record<string, unknown> = {}
@@ -633,9 +654,11 @@ function StorageEditorDialog({
} else {
await createStorageConfig(arcadia, input)
}
await onSaved()
await onSaved(name, isEdit)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
// Keep the dialog open with the form intact — including any secret the
// operator just pasted — so they can fix and resubmit.
setError(err)
} finally {
setSaving(false)
}
@@ -749,6 +772,13 @@ function StorageEditorDialog({
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the storage config" : "create the storage config"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="storage-form-cancel">
Cancel
@@ -841,3 +871,5 @@ function formatBytes(n: number | null): string {
}
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

375
app/routes/tenants.$id.tsx Normal file
View File

@@ -0,0 +1,375 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useParams } from "react-router"
import { ArrowLeft, Pause, Play, RefreshCw } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { BadgeCell, type BadgeTone } from "@crema/table-ui"
import { ConfirmDialog } from "@crema/feedback-ui"
import { useToast } from "@crema/notification-ui"
import { useRegisterContext } from "@crema/aifirst-ui/context"
import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { DataState } from "~/components/data-state"
import { TenantSection, Field } from "~/components/tenant-detail/section"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import {
activateTenant,
deactivateTenant,
getTenant,
suspendTenant,
updateTenant,
type Tenant,
type TenantStatus,
} from "~/lib/arcadia/tenants"
import { errorMessage } from "~/lib/errors"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
// The eight tabs. Each is its own component so the file stays legible and the
// tabs can evolve independently; they share the TenantSection frame and take
// the loaded tenant + a `reload` callback.
import { PlanTab } from "~/components/tenant-detail/plan-tab"
import { BrandingTab } from "~/components/tenant-detail/branding-tab"
import { LocalizationTab } from "~/components/tenant-detail/localization-tab"
import { DeliveryTab } from "~/components/tenant-detail/delivery-tab"
import { FeatureFlagsTab } from "~/components/tenant-detail/feature-flags-tab"
import { IpRulesTab } from "~/components/tenant-detail/ip-rules-tab"
import { InboundWebhooksTab } from "~/components/tenant-detail/inbound-webhooks-tab"
export const meta = () => pageTitle("Tenant")
export type TenantTabProps = {
tenant: Tenant
/** Re-fetch the tenant after a mutation that changes the header fields. */
reload: () => Promise<void>
}
type PendingAction = { kind: "suspend" | "deactivate" } | null
const TABS = [
{ value: "overview", label: "Overview" },
{ value: "plan", label: "Plan & quotas" },
{ value: "branding", label: "Branding" },
{ value: "localization", label: "Localization" },
{ value: "delivery", label: "Email & SMS" },
{ value: "flags", label: "Feature flags" },
{ value: "ip-rules", label: "IP rules" },
{ value: "webhooks", label: "Inbound webhooks" },
]
export default function TenantDetailRoute() {
const { id = "" } = useParams()
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const navigate = useNavigate()
const [tenant, setTenant] = useState<Tenant | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const [tab, setTab] = useState("overview")
const [pending, setPending] = useState<PendingAction>(null)
const reload = useCallback(async () => {
setError(null)
setLoading(true)
try {
setTenant(await getTenant(arcadia, id))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, id])
useEffect(() => {
if (session && id) reload()
}, [session, id, reload])
useRegisterContext(
"tenantDetail",
useMemo(
() => ({
loaded: !!tenant,
id: tenant?.id ?? id,
slug: tenant?.slug ?? null,
name: tenant?.name ?? null,
status: tenant?.status ?? null,
plan: tenant?.plan?.name ?? null,
activeTab: tab,
}),
[tenant, tab, id],
),
)
const runLifecycle = useCallback(
async (action: PendingAction) => {
if (!action || !tenant) return
const verb = action.kind === "suspend" ? "Suspended" : "Deactivated"
try {
if (action.kind === "suspend") await suspendTenant(arcadia, tenant.id)
else await deactivateTenant(arcadia, tenant.id)
setPending(null)
await reload()
toast.success(`${verb} ${tenant.name}`)
} catch (err) {
setPending(null)
toast.error(errorMessage(err, `${action.kind} ${tenant.name}`))
}
},
[arcadia, tenant, reload, toast],
)
const activate = useCallback(async () => {
if (!tenant) return
try {
await activateTenant(arcadia, tenant.id)
await reload()
toast.success(`Activated ${tenant.name}`)
} catch (err) {
toast.error(errorMessage(err, `activate ${tenant.name}`))
}
}, [arcadia, tenant, reload, toast])
return (
<AppShell>
<PageHeader
title={
<span className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate("/tenants")}
aria-label="Back to tenants"
data-action="tenant-detail-back"
>
<ArrowLeft className="size-4" />
</Button>
{tenant?.name ?? "Tenant"}
</span>
}
badges={
tenant ? (
<>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{tenant.slug}
</code>
<BadgeCell label={tenant.status} tone={statusTone(tenant.status)} />
</>
) : null
}
description={
<Link to="/tenants" className="inline-flex items-center gap-1 hover:underline">
<ArrowLeft className="size-3" /> All tenants
</Link>
}
actions={
tenant ? (
<>
<Button
variant="outline"
size="sm"
onClick={reload}
disabled={loading}
data-action="tenant-detail-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
{tenant.status === "active" ? (
<Button
variant="outline"
size="sm"
onClick={() => setPending({ kind: "suspend" })}
data-action="tenant-detail-suspend"
>
<Pause className="size-4" />
Suspend
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={activate}
data-action="tenant-detail-activate"
>
<Play className="size-4" />
Activate
</Button>
)}
<Button
variant="outline"
size="sm"
className="text-destructive"
onClick={() => setPending({ kind: "deactivate" })}
data-action="tenant-detail-deactivate"
>
Deactivate
</Button>
</>
) : null
}
/>
<DataState
loading={loading}
error={error}
isEmpty={!tenant}
onRetry={reload}
loadingLabel="Loading tenant…"
empty={<div className="py-12 text-center text-muted-foreground">Tenant not found.</div>}
>
{tenant ? (
<Tabs value={tab} onValueChange={setTab}>
<div className="overflow-x-auto">
<TabsList>
{TABS.map((t) => (
<TabsTrigger
key={t.value}
value={t.value}
data-action={`tenant-detail-tab-${t.value}`}
>
{t.label}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value="overview">
<OverviewTab tenant={tenant} reload={reload} />
</TabsContent>
<TabsContent value="plan">
<PlanTab tenant={tenant} reload={reload} />
</TabsContent>
<TabsContent value="branding">
<BrandingTab tenant={tenant} reload={reload} />
</TabsContent>
<TabsContent value="localization">
<LocalizationTab tenant={tenant} reload={reload} />
</TabsContent>
<TabsContent value="delivery">
<DeliveryTab tenant={tenant} reload={reload} />
</TabsContent>
<TabsContent value="flags">
<FeatureFlagsTab tenant={tenant} reload={reload} />
</TabsContent>
<TabsContent value="ip-rules">
<IpRulesTab tenant={tenant} reload={reload} />
</TabsContent>
<TabsContent value="webhooks">
<InboundWebhooksTab tenant={tenant} reload={reload} />
</TabsContent>
</Tabs>
) : null}
</DataState>
<ConfirmDialog
open={pending?.kind === "suspend"}
onOpenChange={(o) => !o && setPending(null)}
title="Suspend tenant?"
description={
tenant
? `${tenant.name} will be suspended. Members won't be able to sign in until you reactivate.`
: ""
}
confirmLabel="Suspend"
variant="default"
onConfirm={() => runLifecycle(pending)}
/>
<ConfirmDialog
open={pending?.kind === "deactivate"}
onOpenChange={(o) => !o && setPending(null)}
title="Deactivate tenant?"
description={
tenant
? `${tenant.name} will be taken offline: nobody can sign in and its apps stop serving. Its data is kept, and you can reactivate it from here. Suspend instead if this is temporary.`
: ""
}
confirmLabel="Deactivate"
variant="danger"
onConfirm={() => runLifecycle(pending)}
/>
</AppShell>
)
}
function statusTone(status: TenantStatus): BadgeTone {
if (status === "active") return "success"
if (status === "suspended") return "warning"
if (status === "deactivated") return "danger"
return "default"
}
// --- Overview tab (reference implementation for the other seven) ---
function OverviewTab({ tenant, reload }: TenantTabProps) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [name, setName] = useState(tenant.name)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
const dirty = name.trim() !== tenant.name && name.trim().length > 0
const save = async () => {
setSaving(true)
setError(null)
try {
await updateTenant(arcadia, tenant.id, { name: name.trim() })
await reload()
toast.success("Tenant name updated")
} catch (err) {
setError(err)
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<TenantSection
title="Identity"
description="The tenant's display name. The slug is fixed once created — it's baked into URLs and the X-Tenant-ID header."
onSubmit={save}
saving={saving}
error={error}
errorContext="rename the tenant"
dirty={dirty}
dataAction="tenant-detail-overview-save"
>
<Field label="Name" htmlFor="tenant-name">
<Input
id="tenant-name"
value={name}
onChange={(e) => setName(e.target.value)}
data-action="tenant-detail-overview-name"
/>
</Field>
<Field label="Slug">
<Input value={tenant.slug} readOnly disabled className="font-mono" />
</Field>
</TenantSection>
<div className="grid gap-3 sm:grid-cols-2">
<FactCard label="Status" value={tenant.status} />
<FactCard label="Plan" value={tenant.plan?.name ?? "—"} />
<FactCard label="Created" value={new Date(tenant.inserted_at).toLocaleString()} />
<FactCard label="Last updated" value={new Date(tenant.updated_at).toLocaleString()} />
</div>
</div>
)
}
function FactCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-card/40 px-4 py-3">
<div className="text-xs uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="mt-0.5 font-medium capitalize">{value}</div>
</div>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"
import { Pause, Play, Plus, RefreshCw } from "lucide-react"
import { Link, useNavigate } from "react-router"
import { Pause, Play, Plus, RefreshCw, Settings2 } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -14,10 +16,12 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { PageHeader } from "~/components/layout/page-header"
import { errorMessage } from "~/lib/errors"
import { Button } from "~/components/ui/button"
import {
Card,
@@ -59,10 +63,14 @@ type PendingAction = {
export default function TenantsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const navigate = useNavigate()
const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// The raw thrown value — `DataState` normalises it into plain language. We
// deliberately don't stringify here; the status code carries the meaning.
const [error, setError] = useState<unknown>(null)
const [pending, setPending] = useState<PendingAction>(null)
const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false)
@@ -74,7 +82,7 @@ export default function TenantsRoute() {
const list = await listTenants(arcadia)
setTenants(list)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load tenants.")
setError(err)
} finally {
setLoading(false)
}
@@ -87,17 +95,19 @@ export default function TenantsRoute() {
const runAction = useCallback(
async (action: PendingAction) => {
if (!action) return
const verb = action.kind === "suspend" ? "Suspended" : "Deactivated"
try {
if (action.kind === "suspend") await suspendTenant(arcadia, action.tenant.id)
else await deactivateTenant(arcadia, action.tenant.id)
setPending(null)
await refresh()
toast.success(`${verb} ${action.tenant.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Action failed.")
setPending(null)
toast.error(errorMessage(err, `${action.kind} ${action.tenant.name}`))
}
},
[arcadia, refresh],
[arcadia, refresh, toast],
)
const columns = useMemo<Column<Tenant>[]>(
@@ -107,7 +117,15 @@ export default function TenantsRoute() {
header: "Name",
accessor: "name",
sortable: true,
cell: (t) => <span className="font-medium">{t.name}</span>,
cell: (t) => (
<Link
to={`/tenants/${t.id}`}
className="font-medium hover:underline"
data-action={`tenant-${t.slug}-open`}
>
{t.name}
</Link>
),
},
{
id: "slug",
@@ -145,13 +163,13 @@ export default function TenantsRoute() {
align: "right",
cell: (t) => (
<ActionsCell
items={rowActions(t, arcadia, refresh, setPending, setError)}
items={rowActions(t, arcadia, refresh, setPending, toast, navigate)}
triggerDataAction={`tenant-${t.slug}-actions`}
/>
),
},
],
[arcadia, refresh],
[arcadia, refresh, toast, navigate],
)
const tenantSummary = useMemo(
@@ -215,12 +233,6 @@ export default function TenantsRoute() {
}
/>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<SearchInput
@@ -236,17 +248,24 @@ export default function TenantsRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && tenants.length === 0} label="Loading tenants…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading tenants…"
empty={
<EmptyState
title={search ? "No tenants match that search." : "No tenants yet."}
description={
search ? "Try a different name, slug, or status." : "Create your first tenant to get started."
search
? "Try a different name, slug, or status."
: "Create your first tenant to get started."
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -263,19 +282,22 @@ export default function TenantsRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
<TenantCreateDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={async () => {
onCreated={async (tenant, adminEmail) => {
setCreateOpen(false)
await refresh()
// The money moment. Say what happened and what the operator can do
// next — previously the dialog just closed with no confirmation at all.
toast.success(`Tenant "${tenant.name}" created`, {
description: `${adminEmail} can now sign in with tenant ID "${tenant.slug}".`,
})
}}
onError={setError}
/>
<ConfirmDialog
open={pending?.kind === "suspend"}
@@ -296,7 +318,7 @@ export default function TenantsRoute() {
title="Deactivate tenant?"
description={
pending
? `${pending.tenant.name} will be deactivated. This is more severe than suspending.`
? `${pending.tenant.name} will be taken offline: nobody can sign in, and its apps stop serving. Its data is kept, and you can reactivate it from this table. Suspend instead if this is temporary.`
: ""
}
confirmLabel="Deactivate"
@@ -319,9 +341,18 @@ function rowActions(
arcadia: ReturnType<typeof useArcadiaClient>,
refresh: () => Promise<void>,
setPending: (p: PendingAction) => void,
setError: (msg: string | null) => void,
toast: ReturnType<typeof useToast>,
navigate: (to: string) => void,
): ActionItem[] {
const items: ActionItem[] = []
const items: ActionItem[] = [
{
id: "manage",
label: "Manage",
icon: <Settings2 className="size-4" />,
dataAction: `tenant-${t.slug}-manage`,
onSelect: () => navigate(`/tenants/${t.id}`),
},
]
if (t.status === "active") {
items.push({
id: "suspend",
@@ -340,8 +371,9 @@ function rowActions(
try {
await activateTenant(arcadia, t.id)
await refresh()
toast.success(`Activated ${t.name}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
toast.error(errorMessage(err, `activate ${t.name}`))
}
},
})
@@ -356,29 +388,6 @@ function rowActions(
return items
}
function formatArcadiaError(err: unknown, fallback: string): string {
if (!(err instanceof ArcadiaError)) return fallback
// 422 validation errors carry per-field reasons in `details`. Shape from
// Ecto's FallbackController is typically `{ field: ["msg1", "msg2"] }` or
// nested `{ tenant: { slug: ["has already been taken"] } }`. Flatten so
// the user sees what to fix instead of a generic "validation failed".
if (err.isValidation && err.details) {
const lines: string[] = []
const walk = (obj: unknown, prefix: string) => {
if (Array.isArray(obj)) {
lines.push(`${prefix}: ${obj.join(", ")}`)
} else if (obj && typeof obj === "object") {
for (const [k, v] of Object.entries(obj)) {
walk(v, prefix ? `${prefix}.${k}` : k)
}
}
}
walk(err.details, "")
if (lines.length) return `${err.message}${lines.join("; ")}`
}
return err.message
}
function slugify(name: string): string {
return name
.toLowerCase()
@@ -391,12 +400,10 @@ function TenantCreateDialog({
open,
onClose,
onCreated,
onError,
}: {
open: boolean
onClose: () => void
onCreated: () => Promise<void> | void
onError: (msg: string) => void
onCreated: (tenant: Tenant, adminEmail: string) => Promise<void> | void
}) {
const arcadia = useArcadiaClient()
const [name, setName] = useState("")
@@ -407,6 +414,11 @@ function TenantCreateDialog({
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false)
// Errors belong to the dialog, not the page. They used to be hoisted to a
// page-level banner that rendered *behind* the modal scrim — dimmed, above
// the fold, and unreadable — so a failed provision looked like nothing
// happened at all.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) {
@@ -418,6 +430,7 @@ function TenantCreateDialog({
setEmail("")
setPassword("")
setSubmitting(false)
setError(null)
}
}, [open])
@@ -436,8 +449,9 @@ function TenantCreateDialog({
e.preventDefault()
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
await provisionTenant(arcadia, {
const tenant = await provisionTenant(arcadia, {
tenant: { name: name.trim(), slug },
admin_user: {
email: email.trim(),
@@ -446,9 +460,11 @@ function TenantCreateDialog({
last_name: lastName.trim(),
},
})
await onCreated()
await onCreated(tenant, email.trim())
} catch (err) {
onError(formatArcadiaError(err, "Failed to create tenant."))
// Keep the dialog open with the form intact so the operator can fix and
// resubmit without retyping.
setError(err)
setSubmitting(false)
}
}
@@ -460,7 +476,8 @@ function TenantCreateDialog({
<DialogHeader>
<DialogTitle>New tenant</DialogTitle>
<DialogDescription>
Provisions the tenant with default roles, quotas, and an initial admin user.
Creates the tenant with its system roles and an initial admin user who can
sign in straight away.
</DialogDescription>
</DialogHeader>
@@ -547,6 +564,8 @@ function TenantCreateDialog({
</div>
</div>
{error ? <DialogError error={error} context="create the tenant" /> : null}
<DialogFooter>
<Button
type="button"
@@ -570,3 +589,5 @@ function TenantCreateDialog({
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
Eye,
@@ -14,7 +13,8 @@ import {
X,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -27,17 +27,13 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
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 { Card, CardContent, CardHeader } from "~/components/ui/card"
import {
Dialog,
DialogContent,
@@ -99,44 +95,53 @@ export default function UsersRoute() {
const arcadia = useArcadiaClient()
const [tab, setTab] = useState<Tab>("users")
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Three independent lists, three independent load errors. A broken /roles
// call must not make the users table claim there are no users.
const [users, setUsers] = useState<User[]>([])
const [usersLoading, setUsersLoading] = useState(true)
const [usersError, setUsersError] = useState<unknown>(null)
const [invitations, setInvitations] = useState<Invitation[]>([])
const [invitationsLoading, setInvitationsLoading] = useState(true)
const [invitationsError, setInvitationsError] = useState<unknown>(null)
const [roles, setRoles] = useState<Role[]>([])
const [rolesLoading, setRolesLoading] = useState(true)
const [rolesError, setRolesError] = useState<unknown>(null)
const refreshUsers = useCallback(async () => {
setUsersError(null)
setUsersLoading(true)
try {
setUsers(await listUsers(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load users.")
// Raw throw: describeError() reads the status off it.
setUsersError(err)
} finally {
setUsersLoading(false)
}
}, [arcadia])
const refreshInvitations = useCallback(async () => {
setInvitationsError(null)
setInvitationsLoading(true)
try {
setInvitations(await listInvitations(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load invitations.")
setInvitationsError(err)
} finally {
setInvitationsLoading(false)
}
}, [arcadia])
const refreshRoles = useCallback(async () => {
setRolesError(null)
setRolesLoading(true)
try {
setRoles(await listRoles(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load roles.")
setRolesError(err)
} finally {
setRolesLoading(false)
}
@@ -179,17 +184,6 @@ export default function UsersRoute() {
</p>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
<TabsList>
<TabsTrigger value="users" data-action="users-tab-users">
@@ -208,9 +202,8 @@ export default function UsersRoute() {
users={users}
roles={roles}
loading={usersLoading}
error={usersError}
onRefresh={refreshUsers}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="invitations">
@@ -218,18 +211,16 @@ export default function UsersRoute() {
invitations={invitations}
roles={roles}
loading={invitationsLoading}
error={invitationsError}
onRefresh={refreshInvitations}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="roles">
<RolesPanel
roles={roles}
loading={rolesLoading}
error={rolesError}
onRefresh={refreshRoles}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
</Tabs>
@@ -244,18 +235,17 @@ function UsersPanel({
users,
roles,
loading,
error,
onRefresh,
onError,
onInfo,
}: {
users: User[]
roles: Role[]
loading: boolean
error: unknown
onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all")
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null)
@@ -344,15 +334,14 @@ function UsersPanel({
setEditor,
setPendingDelete,
setDetailUser,
setError: onError,
setInfo: onInfo,
toast,
})}
triggerDataAction={`user-${u.id}-actions`}
/>
),
},
],
[arcadia, onError, onInfo, onRefresh],
[arcadia, onRefresh, toast],
)
const table = useTable<User>({
@@ -406,10 +395,17 @@ function UsersPanel({
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && users.length === 0} label="Loading users…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRefresh}
loadingLabel="Loading users…"
empty={
<EmptyState
title={search || statusFilter !== "all" ? "No users match those filters." : "No users yet."}
title={
search || statusFilter !== "all" ? "No users match those filters." : "No users yet."
}
description={
search || statusFilter !== "all"
? "Try a different search or status filter."
@@ -417,8 +413,8 @@ function UsersPanel({
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -435,8 +431,7 @@ function UsersPanel({
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
<ConfirmDialog
@@ -445,20 +440,22 @@ function UsersPanel({
title="Delete user?"
description={
pendingDelete
? `${pendingDelete.email} will be permanently removed. Their objects and audit history remain.`
? `${pendingDelete.email} will be permanently removed and can no longer sign in. Their objects and audit history are kept. Suspend instead if this is temporary.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const email = pendingDelete.email
try {
await deleteUser(arcadia, pendingDelete.id)
setPendingDelete(null)
await onRefresh()
toast.success(`Deleted ${email}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${email}`))
}
}}
/>
@@ -467,11 +464,11 @@ function UsersPanel({
state={editor}
roles={roles}
onClose={() => setEditor(null)}
onSaved={async () => {
onSaved={async (message) => {
setEditor(null)
await onRefresh()
toast.success(message)
}}
onError={onError}
/>
<UserDetailSheet
@@ -504,11 +501,10 @@ function userRowActions(
setEditor: (s: { mode: "edit"; user: User } | null) => void
setPendingDelete: (u: User | null) => void
setDetailUser: (u: User | null) => void
setError: (msg: string | null) => void
setInfo: (msg: string | null) => void
toast: ReturnType<typeof useToast>
},
): ActionItem[] {
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, setError, setInfo } = ctx
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, toast } = ctx
const items: ActionItem[] = []
items.push({
@@ -535,10 +531,10 @@ function userRowActions(
onSelect: async () => {
try {
await setUserStatus(arcadia, u.id, "suspended")
setInfo(`${u.email} suspended.`)
await refresh()
toast.success(`Suspended ${u.email}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Suspend failed.")
toast.error(errorMessage(err, `suspend ${u.email}`))
}
},
})
@@ -551,10 +547,10 @@ function userRowActions(
onSelect: async () => {
try {
await setUserStatus(arcadia, u.id, "active")
setInfo(`${u.email} activated.`)
await refresh()
toast.success(`Activated ${u.email}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
toast.error(errorMessage(err, `activate ${u.email}`))
}
},
})
@@ -577,13 +573,11 @@ function UserEditorDialog({
roles,
onClose,
onSaved,
onError,
}: {
state: { mode: "create" } | { mode: "edit"; user: User } | null
roles: Role[]
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -597,9 +591,14 @@ function UserEditorDialog({
const [password, setPassword] = useState("")
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
// The failure belongs where the operator is looking — inside this dialog,
// with the form still filled in — not behind the modal scrim.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) return
setError(null)
setSaving(false)
if (initial) {
setEmail(initial.email)
setFirstName(initial.first_name ?? "")
@@ -627,7 +626,7 @@ function UserEditorDialog({
}
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const input: UserInput = {
@@ -637,18 +636,18 @@ function UserEditorDialog({
status,
role_ids: Array.from(selectedRoleIds),
}
if (!isEdit && password) input.password = password
else if (isEdit && password) input.password = password
if (password) input.password = password
if (isEdit && initial) {
await updateUser(arcadia, initial.id, input)
await onSaved(`Saved ${initial.email}`)
} else {
await createUser(arcadia, input)
await onSaved(`Created ${email.trim()}`)
}
await onSaved()
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
} finally {
// Keep the dialog open and the form intact so it can be fixed and resubmitted.
setError(err)
setSaving(false)
}
}
@@ -759,6 +758,10 @@ function UserEditorDialog({
</div>
</div>
{error ? (
<DialogError error={error} context={isEdit ? "save the user" : "create the user"} />
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="user-form-cancel">
Cancel
@@ -783,18 +786,17 @@ function InvitationsPanel({
invitations,
roles,
loading,
error,
onRefresh,
onError,
onInfo,
}: {
invitations: Invitation[]
roles: Role[]
loading: boolean
error: unknown
onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [search, setSearch] = useState("")
const [inviteOpen, setInviteOpen] = useState(false)
const [pendingRevoke, setPendingRevoke] = useState<Invitation | null>(null)
@@ -850,15 +852,14 @@ function InvitationsPanel({
arcadia,
refresh: onRefresh,
setPendingRevoke,
setError: onError,
setInfo: onInfo,
toast,
})}
triggerDataAction={`invitation-${i.id}-actions`}
/>
),
},
],
[arcadia, onError, onInfo, onRefresh],
[arcadia, onRefresh, toast],
)
const table = useTable<Invitation>({
@@ -906,8 +907,13 @@ function InvitationsPanel({
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && invitations.length === 0} label="Loading invitations…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRefresh}
loadingLabel="Loading invitations…"
empty={
<EmptyState
title={search ? "No invitations match." : "No invitations yet."}
description={
@@ -919,8 +925,8 @@ function InvitationsPanel({
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -937,8 +943,7 @@ function InvitationsPanel({
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
<ConfirmDialog
@@ -947,21 +952,22 @@ function InvitationsPanel({
title="Revoke invitation?"
description={
pendingRevoke
? `${pendingRevoke.email} will no longer be able to accept this invitation.`
? `The link sent to ${pendingRevoke.email} stops working immediately, and they can't accept it. You can invite them again later.`
: ""
}
confirmLabel="Revoke"
variant="danger"
onConfirm={async () => {
if (!pendingRevoke) return
const email = pendingRevoke.email
try {
await revokeInvitation(arcadia, pendingRevoke.id)
setPendingRevoke(null)
onInfo("Invitation revoked.")
await onRefresh()
toast.success(`Revoked the invitation to ${email}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Revoke failed.")
setPendingRevoke(null)
toast.error(errorMessage(err, `revoke the invitation to ${email}`))
}
}}
/>
@@ -970,12 +976,13 @@ function InvitationsPanel({
open={inviteOpen}
roles={roles}
onClose={() => setInviteOpen(false)}
onSent={async () => {
onSent={async (email) => {
setInviteOpen(false)
onInfo("Invitation sent.")
await onRefresh()
toast.success(`Invitation sent to ${email}`, {
description: "They'll pick their own password when they accept.",
})
}}
onError={onError}
/>
</Card>
)
@@ -994,11 +1001,10 @@ function invitationRowActions(
arcadia: ReturnType<typeof useArcadiaClient>
refresh: () => Promise<void>
setPendingRevoke: (i: Invitation | null) => void
setError: (msg: string | null) => void
setInfo: (msg: string | null) => void
toast: ReturnType<typeof useToast>
},
): ActionItem[] {
const { arcadia, refresh, setPendingRevoke, setError, setInfo } = ctx
const { arcadia, refresh, setPendingRevoke, toast } = ctx
const status = invitationStatus(inv)
const items: ActionItem[] = []
@@ -1011,10 +1017,10 @@ function invitationRowActions(
onSelect: async () => {
try {
await resendInvitation(arcadia, inv.id)
setInfo(`Resent invitation to ${inv.email}.`)
await refresh()
toast.success(`Resent the invitation to ${inv.email}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Resend failed.")
toast.error(errorMessage(err, `resend the invitation to ${inv.email}`))
}
},
})
@@ -1039,37 +1045,37 @@ function InviteDialog({
roles,
onClose,
onSent,
onError,
}: {
open: boolean
roles: Role[]
onClose: () => void
onSent: () => Promise<void>
onError: (msg: string | null) => void
onSent: (email: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [email, setEmail] = useState("")
const [roleId, setRoleId] = useState<string>("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) {
setEmail("")
setRoleId(roles[0]?.id ?? "")
setError(null)
setSaving(false)
} else {
setRoleId((prev) => prev || roles[0]?.id || "")
}
}, [open, roles])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
await createInvitation(arcadia, { email, role_id: roleId })
await onSent()
await onSent(email.trim())
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Invite failed.")
} finally {
setError(err)
setSaving(false)
}
}
@@ -1113,6 +1119,8 @@ function InviteDialog({
</div>
</div>
{error ? <DialogError error={error} context="send the invitation" /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="invite-form-cancel">
Cancel
@@ -1136,17 +1144,16 @@ function InviteDialog({
function RolesPanel({
roles,
loading,
error,
onRefresh,
onError,
onInfo,
}: {
roles: Role[]
loading: boolean
error: unknown
onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; role: Role } | null>(null)
const [pendingDelete, setPendingDelete] = useState<Role | null>(null)
@@ -1253,15 +1260,20 @@ function RolesPanel({
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && roles.length === 0} label="Loading roles…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRefresh}
loadingLabel="Loading roles…"
empty={
<EmptyState
title={search ? "No roles match." : "No roles yet."}
description={search ? "Try a different search." : "Create your first role."}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -1278,8 +1290,7 @@ function RolesPanel({
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
<ConfirmDialog
@@ -1288,21 +1299,22 @@ function RolesPanel({
title="Delete role?"
description={
pendingDelete
? `Users currently assigned to ${pendingDelete.name} will lose its permissions.`
? `${pendingDelete.name} is removed from every user who has it, and they immediately lose its ${pendingDelete.permissions.length} permission${pendingDelete.permissions.length === 1 ? "" : "s"}. This can't be undone.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const name = pendingDelete.name
try {
await deleteRole(arcadia, pendingDelete.id)
setPendingDelete(null)
onInfo("Role deleted.")
await onRefresh()
toast.success(`Deleted ${name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${name}`))
}
}}
/>
@@ -1310,11 +1322,11 @@ function RolesPanel({
<RoleEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async () => {
onSaved={async (message) => {
setEditor(null)
await onRefresh()
toast.success(message)
}}
onError={onError}
/>
</Card>
)
@@ -1351,12 +1363,10 @@ function RoleEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: { mode: "create" } | { mode: "edit"; role: Role } | null
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -1369,9 +1379,12 @@ function RoleEditorDialog({
const [description, setDescription] = useState("")
const [permissionsText, setPermissionsText] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) return
setError(null)
setSaving(false)
if (initial) {
setName(initial.name)
setSlug(initial.slug)
@@ -1386,7 +1399,7 @@ function RoleEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const permissions = permissionsText
@@ -1394,12 +1407,15 @@ function RoleEditorDialog({
.map((s) => s.trim())
.filter(Boolean)
const input: RoleInput = { name, slug, description: description || null, permissions }
if (isEdit && initial) await updateRole(arcadia, initial.id, input)
else await createRole(arcadia, input)
await onSaved()
if (isEdit && initial) {
await updateRole(arcadia, initial.id, input)
await onSaved(`Saved ${name.trim()}`)
} else {
await createRole(arcadia, input)
await onSaved(`Created ${name.trim()}`)
}
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
} finally {
setError(err)
setSaving(false)
}
}
@@ -1468,6 +1484,10 @@ function RoleEditorDialog({
</div>
</div>
{error ? (
<DialogError error={error} context={isEdit ? "save the role" : "create the role"} />
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="role-form-cancel">
{readOnly ? "Close" : "Cancel"}
@@ -1497,3 +1517,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return acc
}, {})
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -15,7 +15,8 @@ import {
Webhook as WebhookIcon,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -28,9 +29,11 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -89,11 +92,12 @@ type EditorState =
export default function WebhooksRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [webhooks, setWebhooks] = useState<Webhook[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// The raw thrown value — `DataState` normalises it into plain language.
const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<EditorState>(null)
const [pendingDelete, setPendingDelete] = useState<Webhook | null>(null)
@@ -110,7 +114,7 @@ export default function WebhooksRoute() {
try {
setWebhooks(await listWebhooks(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load webhooks.")
setError(err)
} finally {
setLoading(false)
}
@@ -200,15 +204,14 @@ export default function WebhooksRoute() {
setPendingDelete,
setDeliveriesFor,
setRevealedSecret,
setError,
setInfo,
toast,
})}
triggerDataAction={`webhook-${w.id}-actions`}
/>
),
},
],
[arcadia, refresh],
[arcadia, refresh, toast],
)
const summary = useMemo(
@@ -274,17 +277,6 @@ export default function WebhooksRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
@@ -300,8 +292,13 @@ export default function WebhooksRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && webhooks.length === 0} label="Loading webhooks…" />
{table.total === 0 && !loading ? (
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading webhooks…"
empty={
<EmptyState
icon={<WebhookIcon className="size-6" />}
title={search ? "No webhooks match." : "No webhooks yet."}
@@ -312,8 +309,8 @@ export default function WebhooksRoute() {
}
className="py-12"
/>
) : (
<>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
@@ -330,8 +327,7 @@ export default function WebhooksRoute() {
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</DataState>
</CardContent>
</Card>
</div>
@@ -349,14 +345,15 @@ export default function WebhooksRoute() {
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const target = pendingDelete
try {
await deleteWebhook(arcadia, pendingDelete.id)
await deleteWebhook(arcadia, target.id)
setPendingDelete(null)
setInfo("Webhook deleted.")
await refresh()
toast.success(`Deleted webhook ${target.url}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete webhook ${target.url}`))
}
}}
/>
@@ -364,21 +361,19 @@ export default function WebhooksRoute() {
<WebhookEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async (created) => {
onSaved={async (saved, wasEdit) => {
setEditor(null)
if (created?.secret) {
setRevealedSecret({ webhookId: created.id, secret: created.secret, isNew: true })
if (saved?.secret) {
setRevealedSecret({ webhookId: saved.id, secret: saved.secret, isNew: true })
}
await refresh()
toast.success(
wasEdit ? `Saved webhook ${saved.url}` : `Created webhook ${saved.url}`,
)
}}
onError={setError}
/>
<DeliveriesDialog
webhook={deliveriesFor}
onClose={() => setDeliveriesFor(null)}
onError={setError}
/>
<DeliveriesDialog webhook={deliveriesFor} onClose={() => setDeliveriesFor(null)} />
<RevealSecretDialog reveal={revealedSecret} onClose={() => setRevealedSecret(null)} />
</AppShell>
@@ -402,8 +397,7 @@ function rowActions(
setRevealedSecret: (
r: { webhookId: string; secret: string; isNew?: boolean } | null,
) => void
setError: (m: string | null) => void
setInfo: (m: string | null) => void
toast: ReturnType<typeof useToast>
},
): ActionItem[] {
const {
@@ -413,8 +407,7 @@ function rowActions(
setPendingDelete,
setDeliveriesFor,
setRevealedSecret,
setError,
setInfo,
toast,
} = ctx
const items: ActionItem[] = []
@@ -439,9 +432,17 @@ function rowActions(
onSelect: async () => {
try {
const r = await testWebhook(arcadia, w.id)
setInfo(r.ok === false ? r.message ?? "Test failed." : "Test event sent.")
// A 200 from arcadia can still carry a failed delivery — the endpoint
// answered, the *webhook* didn't. Say which.
if (r.ok === false) {
toast.error(`Test event to ${w.url} failed`, {
description: r.message ?? undefined,
})
} else {
toast.success(`Sent test event to ${w.url}`)
}
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Test failed.")
toast.error(errorMessage(err, `send a test event to ${w.url}`))
}
},
})
@@ -455,10 +456,10 @@ function rowActions(
onSelect: async () => {
try {
await pauseWebhook(arcadia, w.id)
setInfo("Webhook paused.")
await refresh()
toast.success(`Paused webhook ${w.url}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Pause failed.")
toast.error(errorMessage(err, `pause webhook ${w.url}`))
}
},
})
@@ -471,10 +472,10 @@ function rowActions(
onSelect: async () => {
try {
await resumeWebhook(arcadia, w.id)
setInfo("Webhook resumed.")
await refresh()
toast.success(`Resumed webhook ${w.url}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Resume failed.")
toast.error(errorMessage(err, `resume webhook ${w.url}`))
}
},
})
@@ -492,8 +493,9 @@ function rowActions(
setRevealedSecret({ webhookId: updated.id, secret: updated.secret })
}
await refresh()
toast.success(`Regenerated the secret for ${w.url}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Regenerate failed.")
toast.error(errorMessage(err, `regenerate the secret for ${w.url}`))
}
},
})
@@ -514,17 +516,18 @@ function WebhookEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: EditorState
onClose: () => void
onSaved: (created?: Webhook) => Promise<void>
onError: (msg: string | null) => void
onSaved: (saved: Webhook, wasEdit: boolean) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
const isEdit = state?.mode === "edit"
const initial = isEdit ? state.webhook : null
// A failed submit speaks inside the dialog — a page banner would sit behind
// the scrim, dimmed and unread.
const [error, setError] = useState<unknown>(null)
const [url, setUrl] = useState("")
const [description, setDescription] = useState("")
@@ -535,7 +538,11 @@ function WebhookEditorDialog({
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
if (!open) {
setError(null)
return
}
setError(null)
if (initial) {
setUrl(initial.url)
setDescription(initial.description ?? "")
@@ -558,7 +565,7 @@ function WebhookEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const events = eventsText
@@ -583,19 +590,15 @@ function WebhookEditorDialog({
}
if (isEdit && initial) {
const updated = await updateWebhook(arcadia, initial.id, input)
await onSaved(updated)
await onSaved(updated, true)
} else {
const created = await createWebhook(arcadia, input)
await onSaved(created)
await onSaved(created, false)
}
} catch (err) {
onError(
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
// Keep the dialog open with the form intact so the operator can fix and
// resubmit without retyping.
setError(err)
} finally {
setSaving(false)
}
@@ -713,6 +716,13 @@ function WebhookEditorDialog({
</div>
</div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the webhook" : "create the webhook"}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="webhook-form-cancel">
Cancel
@@ -730,30 +740,36 @@ function WebhookEditorDialog({
function DeliveriesDialog({
webhook,
onClose,
onError,
}: {
webhook: Webhook | null
onClose: () => void
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([])
const [loading, setLoading] = useState(true)
// A failed deliveries load is not an empty delivery log — say which one it is.
const [error, setError] = useState<unknown>(null)
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
if (!webhook) return
let mounted = true
setLoading(true)
setError(null)
listWebhookDeliveries(arcadia, webhook.id, { limit: 50 })
.then((d) => mounted && setDeliveries(d))
.catch((err) =>
onError(err instanceof ArcadiaError ? err.message : "Failed to load deliveries."),
)
.finally(() => mounted && setLoading(false))
.then((d) => {
if (mounted) setDeliveries(d)
})
.catch((err) => {
if (mounted) setError(err)
})
.finally(() => {
if (mounted) setLoading(false)
})
return () => {
mounted = false
}
}, [arcadia, webhook, onError])
}, [arcadia, webhook, reloadKey])
if (!webhook) return null
@@ -767,15 +783,18 @@ function DeliveriesDialog({
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="py-6 text-center text-sm text-muted-foreground">
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading
</p>
) : deliveries.length === 0 ? (
<DataState
loading={loading}
error={error}
isEmpty={deliveries.length === 0}
onRetry={() => setReloadKey((n) => n + 1)}
loadingLabel="Loading deliveries…"
empty={
<p className="py-6 text-center text-sm text-muted-foreground">
No deliveries recorded yet.
</p>
) : (
}
>
<ul className="flex flex-col divide-y rounded-md border">
{deliveries.map((d) => (
<li key={d.id} className="flex items-start justify-between gap-3 px-3 py-2 text-sm">
@@ -825,7 +844,7 @@ function DeliveriesDialog({
</li>
))}
</ul>
)}
</DataState>
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="webhook-deliveries-close">
@@ -898,3 +917,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return acc
}, {})
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"