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 # Generated by `npm run build:docs` — regenerated on every full build
# (prebuild) and on demand during dev. Don't commit the artifact. # (prebuild) and on demand during dev. Don't commit the artifact.
/public/docs-index.json /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 dev` — Vite dev server (React Router 7).
- `npm run build` — production build (`react-router build`). - `npm run build` — production build (`react-router build`).
- `npm run start` — serve the built app (`react-router-serve ./build/server/index.js`). - `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. - `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). - `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 ## 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. - 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. - 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, PanelLeftOpen,
User as UserIcon, User as UserIcon,
LogOut, LogOut,
HelpCircle,
Menu, Menu,
Play, Play,
HardDrive, HardDrive,
@@ -38,8 +37,6 @@ import {
Database, Database,
Plug, Plug,
MessageSquare, MessageSquare,
Eye,
LayoutGrid,
CreditCard, CreditCard,
// CREMA:NAV-ICONS // CREMA:NAV-ICONS
} from "lucide-react" } from "lucide-react"
@@ -76,7 +73,6 @@ import {
dismissAll, dismissAll,
markAllRead, markAllRead,
markRead, markRead,
seedIfEmpty,
unreadCount, unreadCount,
useNotifications, useNotifications,
} from "~/lib/notifications" } from "~/lib/notifications"
@@ -121,7 +117,10 @@ const pinnedTop: NavItem[] = [
] ]
// Pinned items render flat at the bottom of the rail, below all groups. // 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[] = [ const pinnedBottom: NavItem[] = [
{ to: "/audit-log", icon: Activity, label: "Audit log" },
{ to: "/settings", icon: Settings, label: "Settings" }, { to: "/settings", icon: Settings, label: "Settings" },
] ]
@@ -132,9 +131,9 @@ const navGroups: NavGroup[] = [
icon: Building2, icon: Building2,
items: [ items: [
{ to: "/tenants", icon: Building2, label: "Tenants" }, { to: "/tenants", icon: Building2, label: "Tenants" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/organizations", icon: Building, label: "Organizations" }, { to: "/organizations", icon: Building, label: "Organizations" },
{ to: "/users", icon: UsersIcon, label: "Users" }, { to: "/users", icon: UsersIcon, label: "Users" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" }, { to: "/sso", icon: ShieldCheck, label: "SSO" },
], ],
}, },
@@ -142,11 +141,9 @@ const navGroups: NavGroup[] = [
key: "billing", key: "billing",
label: "Billing", label: "Billing",
icon: CreditCard, icon: CreditCard,
items: [ // One item today (Plan/Entitlements/Apps collapsed here — none has a live
{ to: "/apps", icon: LayoutGrid, label: "Apps" }, // endpoint yet). They split back into siblings under this group once wired.
{ to: "/plan", icon: CreditCard, label: "Plan" }, items: [{ to: "/billing", icon: CreditCard, label: "Plan & usage" }],
{ to: "/entitlements", icon: Gauge, label: "Entitlements" },
],
}, },
{ {
key: "data", key: "data",
@@ -156,17 +153,25 @@ const navGroups: NavGroup[] = [
{ to: "/storage", icon: HardDrive, label: "Storage" }, { to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" }, { to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" }, { to: "/secrets", icon: KeyRound, label: "Secrets" },
{ to: "/integrations", icon: Plug, label: "Integrations" },
], ],
}, },
{ {
key: "integrations", key: "automation",
label: "Integrations", label: "Automation",
icon: Plug, icon: Plug,
items: [ items: [
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" }, { to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" }, { 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: "/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" }, { 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", key: "ai",
label: "AI & Search", label: "AI & Search",
@@ -260,8 +256,12 @@ export function AppShell({
// short-circuit so a sign-out doesn't reduce the hook count and trip // short-circuit so a sign-out doesn't reduce the hook count and trip
// React's "rendered fewer hooks than expected" check. // React's "rendered fewer hooks than expected" check.
const [expanded, setExpanded] = useState<boolean>(() => { const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false if (typeof window === "undefined") return true
return localStorage.getItem(SIDEBAR_KEY) === "1" // 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(() => { useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0") localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
@@ -627,9 +627,6 @@ export function AppShell({
> >
<Settings /> Settings <Settings /> Settings
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem data-action="avatar-help">
<HelpCircle /> Help
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
data-action="avatar-signout" data-action="avatar-signout"
@@ -795,10 +792,6 @@ function NotificationsBell() {
const unread = unreadCount(items) const unread = unreadCount(items)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => {
seedIfEmpty()
}, [])
return ( return (
<Popover> <Popover>
<PopoverTrigger <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" /> <ShieldAlert className="size-10 text-muted-foreground" />
<h2 className="text-lg font-semibold">You can't access this page</h2> <h2 className="text-lg font-semibold">You can't access this page</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This view requires the <code className="font-mono text-xs">{capability}</code>{" "} This view needs the{" "}
capability on your active tenant. If you think you should have it, <code className="font-mono text-xs">{capability}</code> capability,
switch tenants from the avatar menu or ask an admin. which your account doesn't hold on the current tenant. Ask a platform
administrator to grant it.
</p> </p>
</CardContent> </CardContent>
</Card> </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", ...)` // gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
// and drop these manual types. // 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 export type TenantStatus = "active" | "suspended" | "deactivated" | string
@@ -111,3 +111,427 @@ export async function provisionTenant(
}) })
return res.data 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", "/memberships": "tenant.memberships",
"/storage": "tenant.storage", "/storage": "tenant.storage",
"/buckets": "tenant.buckets", "/buckets": "tenant.buckets",
"/activity": "tenant.activity", "/audit-log": "tenant.activity",
"/activity": "tenant.activity", // legacy path → redirects to /audit-log
"/settings": "tenant.settings", "/settings": "tenant.settings",
"/apps": "tenant.apps", // Plan, Entitlements, and Apps collapsed into one Billing surface (Phase 3).
"/plan": "tenant.plan", // They split back out under this same capability set once wired (Phase 5).
"/entitlements": "tenant.entitlements", "/billing": "tenant.plan",
"/tenants": "platform.tenants", "/tenants": "platform.tenants",
"/organizations": "platform.organizations", "/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; // Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
// this store is for the appbar bell's persistent inbox. // 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" export type NotificationKind = "info" | "success" | "warning" | "error"
@@ -95,11 +95,27 @@ export function dismissAll() {
writeToStorage([]) 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 { function subscribe(cb: () => void): () => void {
const onChange = () => { const onChange = () => {
cached = null primed = false
cb() cb()
} }
window.addEventListener(CHANGE_EVENT, onChange) window.addEventListener(CHANGE_EVENT, onChange)
@@ -109,7 +125,12 @@ function subscribe(cb: () => void): () => void {
return () => window.removeEventListener(CHANGE_EVENT, onChange) return () => window.removeEventListener(CHANGE_EVENT, onChange)
} }
function getSnapshot(): AppNotification[] { function getSnapshot(): AppNotification[] {
if (!cached) cached = readFromStorage() const raw = readRaw()
if (!primed || raw !== cachedRaw) {
cachedRaw = raw
cached = readFromStorage()
primed = true
}
return cached return cached
} }
function getServerSnapshot(): AppNotification[] { function getServerSnapshot(): AppNotification[] {
@@ -117,39 +138,9 @@ function getServerSnapshot(): AppNotification[] {
} }
export function useNotifications(): AppNotification[] { export function useNotifications(): AppNotification[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
} }
export function unreadCount(items: AppNotification[]): number { export function unreadCount(items: AppNotification[]): number {
return items.filter((n) => !n.readAt).length 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 // routes after a successful arcadia API exchange. The shape here matches what
// AppShell + useUser expect. // AppShell + useUser expect.
import { useEffect, useSyncExternalStore } from "react" import { useSyncExternalStore } from "react"
import { profileInitials } from "~/lib/profile" import { profileInitials } from "~/lib/profile"
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt" import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
@@ -157,12 +157,34 @@ export function hasSession(): boolean {
return !!readFromStorage() 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 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 { function subscribe(cb: () => void): () => void {
const onChange = () => { const onChange = () => {
cacheValid = false // Force the next getSnapshot to reparse, then let React re-render.
primed = false
cb() cb()
} }
window.addEventListener(CHANGE_EVENT, onChange) window.addEventListener(CHANGE_EVENT, onChange)
@@ -171,23 +193,25 @@ function subscribe(cb: () => void): () => void {
}) })
return () => window.removeEventListener(CHANGE_EVENT, onChange) return () => window.removeEventListener(CHANGE_EVENT, onChange)
} }
function getSnapshot(): Session | null { 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() cached = readFromStorage()
cacheValid = true primed = true
} }
return cached return cached
} }
function getServerSnapshot(): Session | null { function getServerSnapshot(): Session | null {
return null return null
} }
export function useSession(): Session | null { export function useSession(): Session | null {
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cacheValid = false
}, [])
return s
} }
export function sessionInitials(session: Session | null): string { 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 [ export default [
index("routes/home.tsx"), index("routes/home.tsx"),
route("activity", "routes/activity.tsx"), route("audit-log", "routes/activity.tsx"),
route("assistant", "routes/assistant.tsx"), route("activity", "routes/activity-redirect.tsx"),
route("ai", "routes/ai.tsx"), route("ai", "routes/ai.tsx"),
route("library", "routes/library.tsx"),
route("settings", "routes/settings.tsx"), route("settings", "routes/settings.tsx"),
route("profile", "routes/profile.tsx"), route("profile", "routes/profile.tsx"),
route("login", "routes/login.tsx"), route("login", "routes/login.tsx"),
@@ -14,6 +13,7 @@ export default [
route("login/2fa", "routes/login.2fa.tsx"), route("login/2fa", "routes/login.2fa.tsx"),
route("signup", "routes/signup.tsx"), route("signup", "routes/signup.tsx"),
route("tenants", "routes/tenants.tsx"), route("tenants", "routes/tenants.tsx"),
route("tenants/:id", "routes/tenants.$id.tsx"),
route("storage", "routes/storage.tsx"), route("storage", "routes/storage.tsx"),
route("users", "routes/users.tsx"), route("users", "routes/users.tsx"),
route("secrets", "routes/secrets.tsx"), route("secrets", "routes/secrets.tsx"),
@@ -28,9 +28,7 @@ export default [
route("announcements", "routes/announcements.tsx"), route("announcements", "routes/announcements.tsx"),
route("status-page", "routes/status-page.tsx"), route("status-page", "routes/status-page.tsx"),
route("search", "routes/search.tsx"), route("search", "routes/search.tsx"),
route("apps", "routes/apps.tsx"), route("billing", "routes/billing.tsx"),
route("plan", "routes/plan.tsx"),
route("entitlements", "routes/entitlements.tsx"),
route("integrations", "routes/integrations.tsx"), route("integrations", "routes/integrations.tsx"),
// CREMA:ROUTES // CREMA:ROUTES
] satisfies RouteConfig ] 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 { useCallback, useEffect, useMemo, useState } from "react"
import { Activity, Eye, RefreshCw } from "lucide-react" import { Activity, Eye, RefreshCw } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client" import { useArcadiaClient } from "@crema/arcadia-core-client"
import { import {
ActionsCell, ActionsCell,
BadgeCell, BadgeCell,
@@ -13,9 +13,10 @@ import {
type Column, type Column,
} from "@crema/table-ui" } from "@crema/table-ui"
import { SearchInput } from "@crema/search-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 { AppShell } from "~/components/layout/app-shell"
import { DataState } from "~/components/data-state"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import {
Card, Card,
@@ -57,7 +58,9 @@ export default function ActivityRoute() {
const [logs, setLogs] = useState<AuditLog[]>([]) const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true) 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 [search, setSearch] = useState("")
const [severityFilter, setSeverityFilter] = useState<"all" | AuditSeverity>("all") const [severityFilter, setSeverityFilter] = useState<"all" | AuditSeverity>("all")
const [resourceFilter, setResourceFilter] = useState("") const [resourceFilter, setResourceFilter] = useState("")
@@ -78,7 +81,7 @@ export default function ActivityRoute() {
}) })
setLogs(list) setLogs(list)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load audit logs.") setError(err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -223,12 +226,6 @@ export default function ActivityRoute() {
</Button> </Button>
</header> </header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<Card> <Card>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end"> <CardHeader className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end">
<SearchInput <SearchInput
@@ -300,34 +297,38 @@ export default function ActivityRoute() {
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay active={loading && logs.length === 0} label="Loading audit log…" /> <DataState
{table.total === 0 && !loading ? ( loading={loading}
<EmptyState error={error}
icon={<Activity className="size-6" />} isEmpty={table.total === 0}
title="No events match those filters." onRetry={refresh}
description="Loosen the filter set or wait for new platform activity." loadingLabel="Loading audit log…"
className="py-12" 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}
getRowId={(l) => l.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && logs.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={table.page}
<DataTable pageSize={table.pageSize}
columns={columns} total={table.total}
rows={table.pageRows} onPageChange={table.setPage}
getRowId={(l) => l.id} onPageSizeChange={table.setPageSize}
sort={table.sort} />
onSortToggle={table.toggleSort} </DataState>
loading={loading && logs.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -406,3 +407,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return acc return acc
}, {}) }, {})
} }
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

@@ -7,7 +7,8 @@ import {
Trash2, Trash2,
} from "lucide-react" } 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 { import {
ActionsCell, ActionsCell,
BadgeCell, BadgeCell,
@@ -20,9 +21,14 @@ import {
type Column, type Column,
} from "@crema/table-ui" } from "@crema/table-ui"
import { SearchInput } from "@crema/search-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 { 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 { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import {
@@ -110,12 +116,13 @@ type Editor =
export default function AnnouncementsRoute() { export default function AnnouncementsRoute() {
const session = useSession() const session = useSession()
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [items, setItems] = useState<Announcement[]>([]) const [items, setItems] = useState<Announcement[]>([])
const [tenants, setTenants] = useState<Tenant[]>([]) const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) // Raw thrown value — `DataState` normalises it. Successes are toasts now.
const [info, setInfo] = useState<string | null>(null) const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [editor, setEditor] = useState<Editor>(null) const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<Announcement | null>(null) const [pendingDelete, setPendingDelete] = useState<Announcement | null>(null)
@@ -128,13 +135,16 @@ export default function AnnouncementsRoute() {
try { try {
const [a, t] = await Promise.all([ const [a, t] = await Promise.all([
listAnnouncements(arcadia), 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[]), listTenants(arcadia).catch(() => [] as Tenant[]),
]) ])
setItems(a) setItems(a)
setTenants(t) setTenants(t)
setRefreshedAt(Date.now()) setRefreshedAt(Date.now())
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.") setError(err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -237,10 +247,17 @@ export default function AnnouncementsRoute() {
onSelect: async () => { onSelect: async () => {
try { try {
await updateAnnouncement(arcadia, a.id, { active: !a.active }) await updateAnnouncement(arcadia, a.id, { active: !a.active })
setInfo(a.active ? "Announcement deactivated." : "Announcement activated.")
await refresh() await refresh()
toast.success(
a.active ? `Deactivated "${a.title}"` : `Activated "${a.title}"`,
)
} catch (err) { } 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( const summary = useMemo(
@@ -329,17 +346,6 @@ export default function AnnouncementsRoute() {
</div> </div>
</header> </header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card> <Card>
<CardHeader className="flex flex-row items-center gap-3"> <CardHeader className="flex flex-row items-center gap-3">
<SearchInput <SearchInput
@@ -359,74 +365,75 @@ export default function AnnouncementsRoute() {
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay <DataState
active={loading && items.length === 0} loading={loading}
label="Loading announcements…" error={error}
/> isEmpty={table.total === 0}
{table.total === 0 && !loading ? ( onRetry={refresh}
<EmptyState loadingLabel="Loading announcements…"
icon={ empty={
<div <EmptyState
className="grid size-14 place-items-center rounded-full" icon={
style={{ <div
background: className="grid size-14 place-items-center rounded-full"
"radial-gradient(circle at center, color-mix(in oklch, var(--primary) 22%, transparent), transparent 70%)", style={{
}} background:
> "radial-gradient(circle at center, color-mix(in oklch, var(--primary) 22%, transparent), transparent 70%)",
<Megaphone }}
className="size-6"
style={{ color: "var(--primary)" }}
/>
</div>
}
title={search ? "No announcements match." : "No announcements yet."}
description={
search
? "Try a different search."
: "Post your first banner. Show it to everyone, or scope it to a single tenant."
}
action={
search ? (
<Button
size="sm"
variant="outline"
onClick={() => setSearch("")}
data-action="announcements-clear-search"
> >
Clear search <Megaphone
</Button> className="size-6"
) : ( style={{ color: "var(--primary)" }}
<Button />
size="sm" </div>
onClick={() => setEditor({ kind: "create" })} }
data-action="announcements-create-empty" title={search ? "No announcements match." : "No announcements yet."}
> description={
<Plus className="size-4" /> search
New announcement ? "Try a different search."
</Button> : "Post your first banner. Show it to everyone, or scope it to a single tenant."
) }
} action={
search ? (
<Button
size="sm"
variant="outline"
onClick={() => setSearch("")}
data-action="announcements-clear-search"
>
Clear search
</Button>
) : (
<Button
size="sm"
onClick={() => setEditor({ kind: "create" })}
data-action="announcements-create-empty"
>
<Plus className="size-4" />
New announcement
</Button>
)
}
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(a) => a.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && items.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={table.page}
<DataTable pageSize={table.pageSize}
columns={columns} total={table.total}
rows={table.pageRows} onPageChange={table.setPage}
getRowId={(a) => a.id} onPageSizeChange={table.setPageSize}
sort={table.sort} />
onSortToggle={table.toggleSort} </DataState>
loading={loading && items.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -440,14 +447,15 @@ export default function AnnouncementsRoute() {
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingDelete) return if (!pendingDelete) return
const title = pendingDelete.title
try { try {
await deleteAnnouncement(arcadia, pendingDelete.id) await deleteAnnouncement(arcadia, pendingDelete.id)
setPendingDelete(null) setPendingDelete(null)
setInfo("Announcement deleted.")
await refresh() await refresh()
toast.success(`Deleted "${title}"`)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null) setPendingDelete(null)
toast.error(errorMessage(err, `delete "${title}"`))
} }
}} }}
/> />
@@ -458,10 +466,9 @@ export default function AnnouncementsRoute() {
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onSaved={async (msg) => { onSaved={async (msg) => {
setEditor(null) setEditor(null)
if (msg) setInfo(msg)
await refresh() await refresh()
toast.success(msg)
}} }}
onError={setError}
/> />
</AppShell> </AppShell>
) )
@@ -487,13 +494,11 @@ function AnnouncementEditorDialog({
tenants, tenants,
onClose, onClose,
onSaved, onSaved,
onError,
}: { }: {
state: Editor state: Editor
tenants: Tenant[] tenants: Tenant[]
onClose: () => void onClose: () => void
onSaved: (msg?: string) => Promise<void> onSaved: (msg: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const open = state !== null const open = state !== null
@@ -512,7 +517,9 @@ function AnnouncementEditorDialog({
const [dismissible, setDismissible] = useState(true) const [dismissible, setDismissible] = useState(true)
const [active, setActive] = useState(true) const [active, setActive] = useState(true)
const [saving, setSaving] = useState(false) 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(() => { useEffect(() => {
if (!open) setLocalError(null) if (!open) setLocalError(null)
@@ -548,7 +555,6 @@ function AnnouncementEditorDialog({
}, [open, initial]) }, [open, initial])
const submit = async () => { const submit = async () => {
onError(null)
setLocalError(null) setLocalError(null)
setSaving(true) setSaving(true)
try { try {
@@ -567,19 +573,17 @@ function AnnouncementEditorDialog({
} }
if (isEdit && initial) { if (isEdit && initial) {
await updateAnnouncement(arcadia, initial.id, input) await updateAnnouncement(arcadia, initial.id, input)
await onSaved("Announcement updated.") await onSaved(`Updated "${title}"`)
} else { } else {
await createAnnouncement(arcadia, input) await createAnnouncement(arcadia, input)
await onSaved("Announcement posted.") await onSaved(
active ? `Published "${title}"` : `Saved draft "${title}"`,
)
} }
} catch (err) { } catch (err) {
const msg = // Keep the dialog open with the form intact so the operator can fix and
err instanceof ArcadiaError // resubmit without retyping the whole banner.
? err.message setLocalError(err)
: err instanceof Error
? err.message
: "Save failed."
setLocalError(msg)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -630,16 +634,6 @@ function AnnouncementEditorDialog({
</div> </div>
</div> </div>
{localError ? (
<AlertBanner
variant="error"
dismissible
onDismiss={() => setLocalError(null)}
>
{localError}
</AlertBanner>
) : null}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="col-span-2 flex flex-col gap-1.5"> <div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="ann-title">Title</Label> <Label htmlFor="ann-title">Title</Label>
@@ -784,6 +778,13 @@ function AnnouncementEditorDialog({
</div> </div>
</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"> <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. */} {/* Active = publish state, paired with the publish button. */}
<label <label
@@ -821,3 +822,5 @@ function AnnouncementEditorDialog({
</Dialog> </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, Trash2,
} from "lucide-react" } 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 { import {
ActionsCell, ActionsCell,
DataTable, DataTable,
@@ -28,11 +29,13 @@ import {
type Column, type Column,
} from "@crema/table-ui" } from "@crema/table-ui"
import { SearchInput } from "@crema/search-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 { FileGrid, FileList, formatBytes, type FileItem } from "@crema/file-ui"
import { KpiTile, formatCompact } from "@crema/dashboard-ui" import { KpiTile, formatCompact } from "@crema/dashboard-ui"
import { AppShell } from "~/components/layout/app-shell" 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 { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import {
@@ -102,6 +105,7 @@ type Editor =
export default function BucketsRoute() { export default function BucketsRoute() {
const session = useSession() const session = useSession()
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [configs, setConfigs] = useState<StorageConfig[]>([]) const [configs, setConfigs] = useState<StorageConfig[]>([])
const [configId, setConfigId] = useState<string>(() => const [configId, setConfigId] = useState<string>(() =>
@@ -111,8 +115,13 @@ export default function BucketsRoute() {
) )
const [buckets, setBuckets] = useState<Bucket[]>([]) const [buckets, setBuckets] = useState<Bucket[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) // Two independently-loaded lists, two error slots. A dead /storage_configs
const [info, setInfo] = useState<string | null>(null) // 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 [view, setView] = useState<View>({ kind: "list" })
const [editor, setEditor] = useState<Editor>(null) const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<Bucket | null>(null) const [pendingDelete, setPendingDelete] = useState<Bucket | null>(null)
@@ -126,6 +135,8 @@ export default function BucketsRoute() {
useEffect(() => { useEffect(() => {
if (!session) return if (!session) return
let mounted = true let mounted = true
setConfigsLoading(true)
setConfigsError(null)
listStorageConfigs(arcadia) listStorageConfigs(arcadia)
.then((rows) => { .then((rows) => {
if (!mounted) return if (!mounted) return
@@ -142,16 +153,17 @@ export default function BucketsRoute() {
setConfigId(def?.id ?? "") setConfigId(def?.id ?? "")
} }
}) })
.catch((err) => .catch((err) => {
setError( if (mounted) setConfigsError(err)
err instanceof ArcadiaError ? err.message : "Failed to load storage configs.", })
), .finally(() => {
) if (mounted) setConfigsLoading(false)
})
return () => { return () => {
mounted = false mounted = false
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [session, arcadia]) }, [session, arcadia, configsReloadKey])
useEffect(() => { useEffect(() => {
if (configId) localStorage.setItem(SELECTED_CONFIG_KEY, configId) if (configId) localStorage.setItem(SELECTED_CONFIG_KEY, configId)
@@ -167,7 +179,7 @@ export default function BucketsRoute() {
try { try {
setBuckets(await listBuckets(arcadia, configId)) setBuckets(await listBuckets(arcadia, configId))
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load buckets.") setError(err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -177,6 +189,14 @@ export default function BucketsRoute() {
refresh() refresh()
}, [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( const summary = useMemo(
() => ({ () => ({
storage_config: activeConfig storage_config: activeConfig
@@ -249,17 +269,6 @@ export default function BucketsRoute() {
</div> </div>
</header> </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" ? ( {view.kind === "list" ? (
<> <>
<Card> <Card>
@@ -317,7 +326,9 @@ export default function BucketsRoute() {
<BucketsTable <BucketsTable
buckets={buckets} buckets={buckets}
loading={loading} loading={loading || configsLoading}
error={error ?? configsError}
onRetry={retryAll}
hasConfig={!!configId} hasConfig={!!configId}
onOpen={(b) => setView({ kind: "objects", bucket: b })} onOpen={(b) => setView({ kind: "objects", bucket: b })}
onConfigure={(b) => setEditor({ kind: "configure", bucket: b })} onConfigure={(b) => setEditor({ kind: "configure", bucket: b })}
@@ -325,11 +336,7 @@ export default function BucketsRoute() {
/> />
</> </>
) : ( ) : (
<ObjectBrowser <ObjectBrowser storageConfigId={configId} bucket={view.bucket} />
storageConfigId={configId}
bucket={view.bucket}
onError={setError}
/>
)} )}
</div> </div>
@@ -338,12 +345,11 @@ export default function BucketsRoute() {
open={editor?.kind === "create"} open={editor?.kind === "create"}
configId={configId} configId={configId}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onCreated={async (msg) => { onCreated={async (name) => {
setEditor(null) setEditor(null)
if (msg) setInfo(msg)
await refresh() await refresh()
toast.success(`Created bucket ${name}`)
}} }}
onError={setError}
/> />
{/* Configure (versioning / CORS / policy) */} {/* Configure (versioning / CORS / policy) */}
@@ -352,10 +358,9 @@ export default function BucketsRoute() {
configId={configId} configId={configId}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onChanged={async (msg) => { onChanged={async (msg) => {
if (msg) setInfo(msg)
await refresh() await refresh()
toast.success(msg)
}} }}
onError={setError}
/> />
{/* Delete */} {/* Delete */}
@@ -363,12 +368,11 @@ export default function BucketsRoute() {
bucket={pendingDelete} bucket={pendingDelete}
configId={configId} configId={configId}
onClose={() => setPendingDelete(null)} onClose={() => setPendingDelete(null)}
onDeleted={async (msg) => { onDeleted={async (name) => {
setPendingDelete(null) setPendingDelete(null)
if (msg) setInfo(msg)
await refresh() await refresh()
toast.success(`Deleted bucket ${name}`)
}} }}
onError={setError}
/> />
</AppShell> </AppShell>
) )
@@ -379,6 +383,8 @@ export default function BucketsRoute() {
function BucketsTable({ function BucketsTable({
buckets, buckets,
loading, loading,
error,
onRetry,
hasConfig, hasConfig,
onOpen, onOpen,
onConfigure, onConfigure,
@@ -386,6 +392,9 @@ function BucketsTable({
}: { }: {
buckets: Bucket[] buckets: Bucket[]
loading: boolean loading: boolean
/** Raw thrown value from either the buckets load or the configs load. */
error: unknown
onRetry: () => void
hasConfig: boolean hasConfig: boolean
onOpen: (b: Bucket) => void onOpen: (b: Bucket) => void
onConfigure: (b: Bucket) => void onConfigure: (b: Bucket) => void
@@ -525,41 +534,47 @@ function BucketsTable({
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay active={loading && buckets.length === 0} label="Loading buckets…" /> <DataState
{!hasConfig ? ( loading={loading}
<EmptyState error={error}
icon={<Boxes className="size-6" />} isEmpty={table.total === 0}
title="Pick a storage configuration" onRetry={onRetry}
description="Buckets are scoped to a credential. Add one under Storage if you don't have any yet." loadingLabel="Loading buckets…"
className="py-12" 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"
/>
) : (
<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}
getRowId={(b) => b.name}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && buckets.length > 0}
stickyHeader
/> />
) : table.total === 0 && !loading ? ( <Pagination
<EmptyState page={table.page}
icon={<Boxes className="size-6" />} pageSize={table.pageSize}
title={search ? "No buckets match." : "No buckets in this account."} total={table.total}
description={search ? "Try a different search." : "Create your first bucket."} onPageChange={table.setPage}
className="py-12" onPageSizeChange={table.setPageSize}
/> />
) : ( </DataState>
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(b) => b.name}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && buckets.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -570,23 +585,25 @@ function BucketsTable({
function ObjectBrowser({ function ObjectBrowser({
storageConfigId, storageConfigId,
bucket, bucket,
onError,
}: { }: {
storageConfigId: string storageConfigId: string
bucket: Bucket bucket: Bucket
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [objects, setObjects] = useState<BucketObject[]>([]) const [objects, setObjects] = useState<BucketObject[]>([])
const [prefix, setPrefix] = useState("") const [prefix, setPrefix] = useState("")
const [loading, setLoading] = useState(true) 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 [layout, setLayout] = useState<"grid" | "list">("list")
const [previewUrl, setPreviewUrl] = useState<{ url: string; key: string } | null>(null) const [previewUrl, setPreviewUrl] = useState<{ url: string; key: string } | null>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true) setLoading(true)
onError(null) setError(null)
try { try {
const res = await listObjects(arcadia, { const res = await listObjects(arcadia, {
storage_config_id: storageConfigId, storage_config_id: storageConfigId,
@@ -596,11 +613,11 @@ function ObjectBrowser({
}) })
setObjects(res.objects ?? []) setObjects(res.objects ?? [])
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Failed to load objects.") setError(err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [arcadia, storageConfigId, bucket.name, prefix, onError]) }, [arcadia, storageConfigId, bucket.name, prefix])
useEffect(() => { useEffect(() => {
refresh() refresh()
@@ -635,10 +652,10 @@ function ObjectBrowser({
}) })
setPreviewUrl({ url: res.url, key }) setPreviewUrl({ url: res.url, key })
} catch (err) { } 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 ( return (
@@ -697,44 +714,52 @@ function ObjectBrowser({
</CardHeader> </CardHeader>
<CardContent className="relative p-4"> <CardContent className="relative p-4">
<LoadingOverlay active={loading && objects.length === 0} label="Loading objects…" /> <DataState
{fileItems.length === 0 && !loading ? ( loading={loading}
<EmptyState error={error}
icon={<FolderOpen className="size-6" />} isEmpty={fileItems.length === 0}
title={search || prefix ? "No matches." : "Empty bucket."} onRetry={refresh}
description={ loadingLabel="Loading objects…"
search || prefix empty={
? "Adjust the filter or prefix." <EmptyState
: "Upload an object via your application; this view is read-only for now." icon={<FolderOpen className="size-6" />}
} title={search || prefix ? "No matches." : "Empty bucket."}
className="py-12" description={
/> search || prefix
) : layout === "list" ? ( ? "Adjust the filter or prefix."
<FileList : "Upload an object via your application; this view is read-only for now."
files={fileItems} }
onItemClick={(f) => openPresigned(f.id)} className="py-12"
renderAction={(f) => ( />
<Button }
size="sm" >
variant="ghost" {layout === "list" ? (
onClick={(e) => { <FileList
e.stopPropagation() files={fileItems}
openPresigned(f.id) onItemClick={(f) => openPresigned(f.id)}
}} renderAction={(f) => (
data-action={`object-${f.id}-presign`} <Button
> size="sm"
<ExternalLink className="size-3.5" /> variant="ghost"
Link onClick={(e) => {
</Button> e.stopPropagation()
)} openPresigned(f.id)
/> }}
) : ( data-action={`object-${f.id}-presign`}
<FileGrid >
files={fileItems} <ExternalLink className="size-3.5" />
onItemClick={(f) => openPresigned(f.id)} Link
minItemWidth={180} </Button>
/> )}
)} />
) : (
<FileGrid
files={fileItems}
onItemClick={(f) => openPresigned(f.id)}
minItemWidth={180}
/>
)}
</DataState>
</CardContent> </CardContent>
<PresignDialog reveal={previewUrl} onClose={() => setPreviewUrl(null)} /> <PresignDialog reveal={previewUrl} onClose={() => setPreviewUrl(null)} />
@@ -812,13 +837,11 @@ function CreateBucketDialog({
configId, configId,
onClose, onClose,
onCreated, onCreated,
onError,
}: { }: {
open: boolean open: boolean
configId: string configId: string
onClose: () => void onClose: () => void
onCreated: (msg?: string) => Promise<void> onCreated: (name: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const [name, setName] = useState("") const [name, setName] = useState("")
@@ -827,6 +850,10 @@ function CreateBucketDialog({
const [versioning, setVersioning] = useState(false) const [versioning, setVersioning] = useState(false)
const [regions, setRegions] = useState<string[]>([]) const [regions, setRegions] = useState<string[]>([])
const [saving, setSaving] = useState(false) 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(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -834,8 +861,10 @@ function CreateBucketDialog({
setRegion("") setRegion("")
setAcl("private") setAcl("private")
setVersioning(false) setVersioning(false)
setError(null)
return return
} }
setError(null)
if (configId) { if (configId) {
listRegions(arcadia, configId) listRegions(arcadia, configId)
.then(setRegions) .then(setRegions)
@@ -844,7 +873,7 @@ function CreateBucketDialog({
}, [open, arcadia, configId]) }, [open, arcadia, configId])
const submit = async () => { const submit = async () => {
onError(null) setError(null)
setSaving(true) setSaving(true)
try { try {
await createBucket(arcadia, { await createBucket(arcadia, {
@@ -854,15 +883,9 @@ function CreateBucketDialog({
acl, acl,
versioning, versioning,
}) })
await onCreated(`Bucket ${name} created.`) await onCreated(name)
} catch (err) { } catch (err) {
onError( setError(err)
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Create failed.",
)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -942,6 +965,8 @@ function CreateBucketDialog({
</div> </div>
</div> </div>
{error ? <DialogError error={error} context="create the bucket" /> : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="bucket-form-cancel"> <Button variant="outline" onClick={onClose} disabled={saving} data-action="bucket-form-cancel">
Cancel Cancel
@@ -961,13 +986,11 @@ function ConfigureBucketDialog({
configId, configId,
onClose, onClose,
onChanged, onChanged,
onError,
}: { }: {
state: { kind: "configure"; bucket: Bucket } | null state: { kind: "configure"; bucket: Bucket } | null
configId: string configId: string
onClose: () => void onClose: () => void
onChanged: (msg?: string) => Promise<void> onChanged: (msg: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const [versioningOn, setVersioningOn] = useState(false) const [versioningOn, setVersioningOn] = useState(false)
@@ -976,38 +999,58 @@ function ConfigureBucketDialog({
const [corsRules, setCorsRules] = useState<CorsRule[]>([]) const [corsRules, setCorsRules] = useState<CorsRule[]>([])
const [corsSaving, setCorsSaving] = useState(false) const [corsSaving, setCorsSaving] = useState(false)
const [corsLoading, setCorsLoading] = 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 [policyText, setPolicyText] = useState("")
const [policySaving, setPolicySaving] = useState(false) 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 const open = state !== null
useEffect(() => { useEffect(() => {
if (!open || !state) return if (!open || !state) return
setCorsLoading(true) setCorsLoading(true)
setCorsError(null)
getCors(arcadia, configId, state.bucket.name) getCors(arcadia, configId, state.bucket.name)
.then((res) => { .then((res) => {
setCorsRules(res?.rules ?? []) setCorsRules(res?.rules ?? [])
}) })
.catch(() => setCorsRules([])) .catch((err) => {
setCorsRules([])
setCorsError(err)
})
.finally(() => setCorsLoading(false)) .finally(() => setCorsLoading(false))
}, [open, state, arcadia, configId]) }, [open, state, arcadia, configId, corsReloadKey])
if (!state) return null if (!state) return null
const { bucket } = state const { bucket } = state
const fail = (err: unknown, context: string) => {
setError(err)
setErrorContext(context)
}
const saveVersioning = async () => { const saveVersioning = async () => {
setVersioningSaving(true) setVersioningSaving(true)
onError(null) setError(null)
try { try {
await configureVersioning(arcadia, { await configureVersioning(arcadia, {
storage_config_id: configId, storage_config_id: configId,
bucket_name: bucket.name, bucket_name: bucket.name,
enabled: versioningOn, enabled: versioningOn,
}) })
await onChanged(`Versioning ${versioningOn ? "enabled" : "suspended"}.`) await onChanged(
`${versioningOn ? "Enabled" : "Suspended"} versioning on ${bucket.name}`,
)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.") fail(err, `save versioning on ${bucket.name}`)
} finally { } finally {
setVersioningSaving(false) setVersioningSaving(false)
} }
@@ -1015,16 +1058,16 @@ function ConfigureBucketDialog({
const saveCors = async () => { const saveCors = async () => {
setCorsSaving(true) setCorsSaving(true)
onError(null) setError(null)
try { try {
await configureCors(arcadia, { await configureCors(arcadia, {
storage_config_id: configId, storage_config_id: configId,
bucket_name: bucket.name, bucket_name: bucket.name,
rules: corsRules, rules: corsRules,
}) })
await onChanged("CORS rules saved.") await onChanged(`Saved CORS rules on ${bucket.name}`)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Save failed.") fail(err, `save the CORS rules on ${bucket.name}`)
} finally { } finally {
setCorsSaving(false) setCorsSaving(false)
} }
@@ -1032,23 +1075,19 @@ function ConfigureBucketDialog({
const savePolicy = async () => { const savePolicy = async () => {
setPolicySaving(true) setPolicySaving(true)
onError(null) setError(null)
try { 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) const policy = policyText.trim() === "" ? {} : JSON.parse(policyText)
await configurePolicy(arcadia, { await configurePolicy(arcadia, {
storage_config_id: configId, storage_config_id: configId,
bucket_name: bucket.name, bucket_name: bucket.name,
policy, policy,
}) })
await onChanged("Bucket policy saved.") await onChanged(`Saved the bucket policy on ${bucket.name}`)
} catch (err) { } catch (err) {
onError( fail(err, `save the bucket policy on ${bucket.name}`)
err instanceof ArcadiaError
? err.message
: err instanceof Error
? `Invalid JSON or save failed: ${err.message}`
: "Save failed.",
)
} finally { } finally {
setPolicySaving(false) setPolicySaving(false)
} }
@@ -1112,16 +1151,22 @@ function ConfigureBucketDialog({
</TabsContent> </TabsContent>
<TabsContent value="cors" className="pt-4"> <TabsContent value="cors" className="pt-4">
{corsLoading ? ( <DataState
<p className="py-4 text-center text-sm text-muted-foreground"> loading={corsLoading}
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading rules… error={corsError}
</p> 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} /> <CorsEditor rules={corsRules} onChange={setCorsRules} />
)} </DataState>
<div className="mt-3 flex justify-end gap-2"> <div className="mt-3 flex justify-end gap-2">
<Button <Button
variant="outline" variant="outline"
disabled={corsLoading || !!corsError}
onClick={() => onClick={() =>
setCorsRules([ setCorsRules([
...corsRules, ...corsRules,
@@ -1140,7 +1185,9 @@ function ConfigureBucketDialog({
</Button> </Button>
<Button <Button
onClick={saveCors} 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" data-action="configure-cors-save"
> >
{corsSaving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />} {corsSaving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
@@ -1182,6 +1229,8 @@ function ConfigureBucketDialog({
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{error ? <DialogError error={error} context={errorContext} /> : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} data-action="configure-close"> <Button variant="outline" onClick={onClose} data-action="configure-close">
Close Close
@@ -1303,38 +1352,38 @@ function DeleteBucketFlow({
configId, configId,
onClose, onClose,
onDeleted, onDeleted,
onError,
}: { }: {
bucket: Bucket | null bucket: Bucket | null
configId: string configId: string
onClose: () => void onClose: () => void
onDeleted: (msg?: string) => Promise<void> onDeleted: (name: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const [code, setCode] = useState("") const [code, setCode] = useState("")
const [forceEmpty, setForceEmpty] = useState(false) const [forceEmpty, setForceEmpty] = useState(false)
const [issuingCode, setIssuingCode] = useState(false) const [issuingCode, setIssuingCode] = useState(false)
const [deleting, setDeleting] = 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(() => { useEffect(() => {
if (!bucket) { if (!bucket) {
setCode("") setCode("")
setForceEmpty(false) setForceEmpty(false)
} }
setError(null)
}, [bucket]) }, [bucket])
const requestCode = async () => { const requestCode = async () => {
if (!bucket) return if (!bucket) return
setIssuingCode(true) setIssuingCode(true)
onError(null) setError(null)
try { try {
const res = await generateConfirmationCode(arcadia, configId, bucket.name) const res = await generateConfirmationCode(arcadia, configId, bucket.name)
setCode(res.code ?? "") setCode(res.code ?? "")
} catch (err) { } catch (err) {
onError( setError(err)
err instanceof ArcadiaError ? err.message : "Failed to generate confirmation code.",
)
} finally { } finally {
setIssuingCode(false) setIssuingCode(false)
} }
@@ -1343,7 +1392,7 @@ function DeleteBucketFlow({
const doDelete = async () => { const doDelete = async () => {
if (!bucket) return if (!bucket) return
setDeleting(true) setDeleting(true)
onError(null) setError(null)
try { try {
await deleteBucket(arcadia, { await deleteBucket(arcadia, {
storage_config_id: configId, storage_config_id: configId,
@@ -1352,9 +1401,9 @@ function DeleteBucketFlow({
force_empty: forceEmpty, force_empty: forceEmpty,
dry_run: false, dry_run: false,
}) })
await onDeleted(`${bucket.name} deleted.`) await onDeleted(bucket.name)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.") setError(err)
} finally { } finally {
setDeleting(false) setDeleting(false)
} }
@@ -1417,8 +1466,20 @@ function DeleteBucketFlow({
</div> </div>
</div> </div>
{error ? (
<DialogError
error={error}
context={bucket ? `delete ${bucket.name}` : "delete the bucket"}
/>
) : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={deleting}> <Button
variant="outline"
onClick={onClose}
disabled={deleting}
data-action="bucket-delete-cancel"
>
Cancel Cancel
</Button> </Button>
<Button <Button
@@ -1468,3 +1529,5 @@ function guessMime(key: string): string {
} }
return m[ext] ?? "application/octet-stream" 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) const d = Math.round(hr / 24)
return `${d}d ago` return `${d}d ago`
} }
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -630,3 +630,5 @@ function Field({
</div> </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 }) if (session) navigate(next, { replace: true })
}, [session, next, navigate]) }, [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 ( return (
<AuthShell> <AuthShell>
<LoginForm <LoginForm
brand={<AuthBrand />} brand={<AuthBrand />}
heading={`Sign in to ${brand.name}`} 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 }) => { onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
if (twoFactorRequired && twoFactorChallenge) { if (twoFactorRequired && twoFactorChallenge) {
navigate( navigate(
@@ -38,7 +46,7 @@ export default function LoginRoute() {
navigate(next, { replace: true }) navigate(next, { replace: true })
}} }}
onForgotPassword={() => navigate("/login/forgot")} onForgotPassword={() => navigate("/login/forgot")}
onSignup={() => navigate("/signup")} onSignup={isDev ? () => navigate("/signup") : undefined}
/> />
</AuthShell> </AuthShell>
) )

View File

@@ -9,7 +9,8 @@ import {
Trash2, Trash2,
} from "lucide-react" } 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 { import {
ActionsCell, ActionsCell,
BadgeCell, BadgeCell,
@@ -22,18 +23,14 @@ import {
type Column, type Column,
} from "@crema/table-ui" } from "@crema/table-ui"
import { SearchInput } from "@crema/search-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 { 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 { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import { Card, CardContent, CardHeader } from "~/components/ui/card"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -42,7 +39,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "~/components/ui/dialog" } from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label" import { Label } from "~/components/ui/label"
import { import {
Select, Select,
@@ -74,16 +70,22 @@ type Editor =
| { kind: "edit"; membership: Membership } | { kind: "edit"; membership: Membership }
| null | 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() { export default function MembershipsRoute() {
const session = useSession() const session = useSession()
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [memberships, setMemberships] = useState<Membership[]>([]) const [memberships, setMemberships] = useState<Membership[]>([])
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const [roles, setRoles] = useState<Role[]>([]) const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) // Raw thrown value — DataState turns the status code into plain language.
const [info, setInfo] = useState<string | null>(null) const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | MembershipStatus>("all") const [statusFilter, setStatusFilter] = useState<"all" | MembershipStatus>("all")
const [editor, setEditor] = useState<Editor>(null) const [editor, setEditor] = useState<Editor>(null)
@@ -93,6 +95,8 @@ export default function MembershipsRoute() {
setError(null) setError(null)
setLoading(true) setLoading(true)
try { 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([ const [m, u, r] = await Promise.all([
listMemberships(arcadia), listMemberships(arcadia),
listUsers(arcadia).catch(() => [] as User[]), listUsers(arcadia).catch(() => [] as User[]),
@@ -102,7 +106,7 @@ export default function MembershipsRoute() {
setUsers(u) setUsers(u)
setRoles(r) setRoles(r)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load memberships.") setError(err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -207,6 +211,7 @@ export default function MembershipsRoute() {
header: "", header: "",
align: "right", align: "right",
cell: (m) => { cell: (m) => {
const who = memberLabel(m)
const items: ActionItem[] = [ const items: ActionItem[] = [
{ {
id: "edit", id: "edit",
@@ -223,10 +228,10 @@ export default function MembershipsRoute() {
onSelect: async () => { onSelect: async () => {
try { try {
await suspendMembership(arcadia, m.id) await suspendMembership(arcadia, m.id)
setInfo("Membership suspended.")
await refresh() await refresh()
toast.success(`Suspended ${who}`)
} catch (err) { } 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 () => { onSelect: async () => {
try { try {
await activateMembership(arcadia, m.id) await activateMembership(arcadia, m.id)
setInfo("Membership activated.")
await refresh() await refresh()
toast.success(`Activated ${who}`)
} catch (err) { } 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( const summary = useMemo(
@@ -318,17 +323,6 @@ export default function MembershipsRoute() {
</div> </div>
</header> </header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card> <Card>
<CardHeader className="flex flex-row items-center gap-3"> <CardHeader className="flex flex-row items-center gap-3">
<SearchInput <SearchInput
@@ -358,45 +352,46 @@ export default function MembershipsRoute() {
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay <DataState
active={loading && memberships.length === 0} loading={loading}
label="Loading memberships…" error={error}
/> isEmpty={table.total === 0}
{table.total === 0 && !loading ? ( onRetry={refresh}
<EmptyState loadingLabel="Loading memberships…"
icon={<Network className="size-6" />} empty={
title={ <EmptyState
search || statusFilter !== "all" icon={<Network className="size-6" />}
? "No memberships match those filters." title={
: "No memberships yet." search || statusFilter !== "all"
} ? "No memberships match those filters."
description={ : "No memberships yet."
search || statusFilter !== "all" }
? "Loosen the filter set." description={
: "Add a user to a tenant to create the first membership." search || statusFilter !== "all"
} ? "Loosen the filter set."
className="py-12" : "Add a user to a tenant to create the first membership."
}
className="py-12"
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(m) => m.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && memberships.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={table.page}
<DataTable pageSize={table.pageSize}
columns={columns} total={table.total}
rows={table.pageRows} onPageChange={table.setPage}
getRowId={(m) => m.id} onPageSizeChange={table.setPageSize}
sort={table.sort} />
onSortToggle={table.toggleSort} </DataState>
loading={loading && memberships.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -407,21 +402,23 @@ export default function MembershipsRoute() {
title="Remove membership?" title="Remove membership?"
description={ description={
pendingDelete 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" confirmLabel="Remove"
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingDelete) return if (!pendingDelete) return
const who = memberLabel(pendingDelete)
const where = pendingDelete.tenant?.name ?? "this tenant"
try { try {
await deleteMembership(arcadia, pendingDelete.id) await deleteMembership(arcadia, pendingDelete.id)
setPendingDelete(null) setPendingDelete(null)
setInfo("Membership removed.")
await refresh() await refresh()
toast.success(`Removed ${who} from ${where}`)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Remove failed.")
setPendingDelete(null) setPendingDelete(null)
toast.error(errorMessage(err, `remove ${who} from ${where}`))
} }
}} }}
/> />
@@ -432,12 +429,11 @@ export default function MembershipsRoute() {
roles={roles} roles={roles}
existingUserIds={new Set(memberships.map((m) => m.user_id))} existingUserIds={new Set(memberships.map((m) => m.user_id))}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onSaved={async (msg) => { onSaved={async (message) => {
setEditor(null) setEditor(null)
if (msg) setInfo(msg)
await refresh() await refresh()
toast.success(message)
}} }}
onError={setError}
/> />
</AppShell> </AppShell>
) )
@@ -456,15 +452,13 @@ function MembershipEditorDialog({
existingUserIds, existingUserIds,
onClose, onClose,
onSaved, onSaved,
onError,
}: { }: {
state: Editor state: Editor
users: User[] users: User[]
roles: Role[] roles: Role[]
existingUserIds: Set<string> existingUserIds: Set<string>
onClose: () => void onClose: () => void
onSaved: (msg?: string) => Promise<void> onSaved: (message: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const open = state !== null const open = state !== null
@@ -475,9 +469,13 @@ function MembershipEditorDialog({
const [status, setStatus] = useState<MembershipStatus>("active") const [status, setStatus] = useState<MembershipStatus>("active")
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set()) const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Rendered inside the dialog: a page banner would sit behind the scrim.
const [error, setError] = useState<unknown>(null)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setError(null)
setSaving(false)
if (initial) { if (initial) {
setUserId(initial.user_id) setUserId(initial.user_id)
setStatus(initial.status) setStatus(initial.status)
@@ -495,7 +493,7 @@ function MembershipEditorDialog({
) )
const submit = async () => { const submit = async () => {
onError(null) setError(null)
setSaving(true) setSaving(true)
try { try {
const input = { const input = {
@@ -503,22 +501,18 @@ function MembershipEditorDialog({
status, status,
role_ids: Array.from(selectedRoles), role_ids: Array.from(selectedRoles),
} }
const who =
users.find((u) => u.id === userId)?.email ?? initial?.user?.email ?? "the member"
if (isEdit && initial) { if (isEdit && initial) {
await updateMembership(arcadia, initial.id, input) await updateMembership(arcadia, initial.id, input)
await onSaved("Membership updated.") await onSaved(`Saved ${who}'s membership`)
} else { } else {
await createMembership(arcadia, input) await createMembership(arcadia, input)
await onSaved("Member added.") await onSaved(`Added ${who}`)
} }
} catch (err) { } catch (err) {
onError( // Form state survives so the operator can fix and resubmit.
err instanceof ArcadiaError setError(err)
? err.message
: err instanceof Error
? err.message
: "Save failed.",
)
} finally {
setSaving(false) setSaving(false)
} }
} }
@@ -614,8 +608,20 @@ function MembershipEditorDialog({
</div> </div>
</div> </div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the membership" : "add the member"}
/>
) : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}> <Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="membership-form-cancel"
>
Cancel Cancel
</Button> </Button>
<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. export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
type Editor =
| { kind: "create" }
| { kind: "edit"; membership: Membership }
| null

View File

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

View File

@@ -10,10 +10,13 @@ import {
Wifi, Wifi,
} from "lucide-react" } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client" import { useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" import { useToast } from "@crema/notification-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell" 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 { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import {
@@ -77,30 +80,59 @@ export default function NetworkingRoute() {
const [floatingIps, setFloatingIps] = useState<FloatingIp[]>([]) const [floatingIps, setFloatingIps] = useState<FloatingIp[]>([])
const [droplets, setDroplets] = useState<Droplet[]>([]) const [droplets, setDroplets] = useState<Droplet[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) // One error per tab. These endpoints legitimately 503 when DigitalOcean isn't
const [info, setInfo] = useState<string | null>(null) // 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 () => { const refresh = useCallback(async () => {
setError(null)
setLoading(true) setLoading(true)
try { setFirewallsError(null)
const [f, v, d, fi, dr] = await Promise.all([ setVpcsError(null)
listFirewalls(arcadia), setDomainsError(null)
listVpcs(arcadia), setFloatingIpsError(null)
listDomains(arcadia),
listFloatingIps(arcadia), const [f, v, d, fi, dr] = await Promise.allSettled([
listDroplets(arcadia), listFirewalls(arcadia),
]) listVpcs(arcadia),
setFirewalls(f) listDomains(arcadia),
setVpcs(v) listFloatingIps(arcadia),
setDomains(d) listDroplets(arcadia),
setFloatingIps(fi) ])
setDroplets(dr)
} catch (err) { if (f.status === "fulfilled") setFirewalls(f.value)
setError(err instanceof ArcadiaError ? err.message : "Failed to load networking.") else {
} finally { setFirewalls([])
setLoading(false) 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]) }, [arcadia])
useEffect(() => { useEffect(() => {
@@ -137,17 +169,6 @@ export default function NetworkingRoute() {
</Button> </Button>
</header> </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"> <Tabs defaultValue="firewalls">
<TabsList> <TabsList>
<TabsTrigger value="firewalls" data-action="networking-tab-firewalls"> <TabsTrigger value="firewalls" data-action="networking-tab-firewalls">
@@ -168,22 +189,25 @@ export default function NetworkingRoute() {
<FirewallsPanel <FirewallsPanel
firewalls={firewalls} firewalls={firewalls}
loading={loading} loading={loading}
error={firewallsError}
onChanged={refresh} onChanged={refresh}
onError={setError}
onInfo={setInfo}
/> />
</TabsContent> </TabsContent>
<TabsContent value="vpcs" className="pt-4"> <TabsContent value="vpcs" className="pt-4">
<VpcsPanel vpcs={vpcs} loading={loading} /> <VpcsPanel
vpcs={vpcs}
loading={loading}
error={vpcsError}
onRetry={refresh}
/>
</TabsContent> </TabsContent>
<TabsContent value="domains" className="pt-4"> <TabsContent value="domains" className="pt-4">
<DomainsPanel <DomainsPanel
domains={domains} domains={domains}
loading={loading} loading={loading}
onError={setError} error={domainsError}
onInfo={setInfo}
onChanged={refresh} onChanged={refresh}
/> />
</TabsContent> </TabsContent>
@@ -193,9 +217,8 @@ export default function NetworkingRoute() {
ips={floatingIps} ips={floatingIps}
droplets={droplets} droplets={droplets}
loading={loading} loading={loading}
error={floatingIpsError}
onChanged={refresh} onChanged={refresh}
onError={setError}
onInfo={setInfo}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -209,48 +232,41 @@ export default function NetworkingRoute() {
function FirewallsPanel({ function FirewallsPanel({
firewalls, firewalls,
loading, loading,
error,
onChanged, onChanged,
onError,
onInfo,
}: { }: {
firewalls: Firewall[] firewalls: Firewall[]
loading: boolean loading: boolean
error: unknown
onChanged: () => Promise<void> onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [pendingDelete, setPendingDelete] = useState<Firewall | null>(null) 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 (
<Card>
<CardContent>
<EmptyState
icon={<Shield className="size-6" />}
title="No firewalls."
description="Create a firewall on your provider, or configure DigitalOcean access in arcadia's .env to see existing ones."
className="py-8"
/>
</CardContent>
</Card>
)
}
return ( return (
<> <>
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2"> <DataState
{firewalls.map((f) => ( loading={loading}
error={error}
isEmpty={firewalls.length === 0}
onRetry={onChanged}
loadingLabel="Loading firewalls…"
empty={
<Card>
<CardContent>
<EmptyState
icon={<Shield className="size-6" />}
title="No firewalls."
description="Create a firewall on your provider, or configure DigitalOcean access in arcadia's .env to see existing ones."
className="py-8"
/>
</CardContent>
</Card>
}
>
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{firewalls.map((f) => (
<Card key={String(f.id)}> <Card key={String(f.id)}>
<CardHeader className="flex flex-row items-center justify-between gap-3"> <CardHeader className="flex flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -273,8 +289,9 @@ function FirewallsPanel({
{f.droplet_ids?.length ?? 0} {f.droplet_ids?.length ?? 0}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</ul> </ul>
</DataState>
<ConfirmDialog <ConfirmDialog
open={pendingDelete !== null} open={pendingDelete !== null}
@@ -289,14 +306,15 @@ function FirewallsPanel({
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingDelete) return if (!pendingDelete) return
const name = pendingDelete.name
try { try {
await deleteFirewall(arcadia, pendingDelete.id) await deleteFirewall(arcadia, pendingDelete.id)
setPendingDelete(null) setPendingDelete(null)
onInfo("Firewall deleted.")
await onChanged() await onChanged()
toast.success(`Deleted firewall ${name}`)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null) setPendingDelete(null)
toast.error(errorMessage(err, `delete firewall ${name}`))
} }
}} }}
/> />
@@ -306,52 +324,59 @@ function FirewallsPanel({
// --- VPCs panel -------------------------------------------------------- // --- VPCs panel --------------------------------------------------------
function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) { function VpcsPanel({
if (loading && vpcs.length === 0) { vpcs,
return ( loading,
<Card> error,
<CardContent className="relative py-8"> onRetry,
<LoadingOverlay active label="Loading VPCs" /> }: {
</CardContent> vpcs: Vpc[]
</Card> loading: boolean
) error: unknown
} onRetry: () => void
if (vpcs.length === 0) { }) {
return (
<Card>
<CardContent>
<EmptyState
icon={<Network className="size-6" />}
title="No VPCs."
description="Read-only view; create VPCs on your provider directly."
className="py-8"
/>
</CardContent>
</Card>
)
}
return ( return (
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2"> <DataState
{vpcs.map((v) => ( loading={loading}
<Card key={v.id}> error={error}
<CardHeader className="flex flex-row items-center justify-between"> isEmpty={vpcs.length === 0}
<div className="flex items-center gap-2"> onRetry={onRetry}
<Network className="size-4 text-muted-foreground" /> loadingLabel="Loading VPCs"
<CardTitle className="text-base">{v.name}</CardTitle> empty={
{v.default ? <Badge>default</Badge> : null} <Card>
</div> <CardContent>
</CardHeader> <EmptyState
<CardContent className="text-xs text-muted-foreground"> icon={<Network className="size-6" />}
<div> title="No VPCs."
Region: <code className="font-mono">{v.region ?? ""}</code> description="Read-only view; create VPCs on your provider directly."
</div> className="py-8"
<div> />
IP range: <code className="font-mono">{v.ip_range ?? ""}</code>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} }
</ul> >
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{vpcs.map((v) => (
<Card key={v.id}>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Network className="size-4 text-muted-foreground" />
<CardTitle className="text-base">{v.name}</CardTitle>
{v.default ? <Badge>default</Badge> : null}
</div>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
<div>
Region: <code className="font-mono">{v.region ?? ""}</code>
</div>
<div>
IP range: <code className="font-mono">{v.ip_range ?? ""}</code>
</div>
</CardContent>
</Card>
))}
</ul>
</DataState>
) )
} }
@@ -360,20 +385,22 @@ function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
function DomainsPanel({ function DomainsPanel({
domains, domains,
loading, loading,
onError, error,
onInfo,
onChanged, onChanged,
}: { }: {
domains: Domain[] domains: Domain[]
loading: boolean loading: boolean
onError: (msg: string | null) => void error: unknown
onInfo: (msg: string | null) => void
onChanged: () => Promise<void> onChanged: () => Promise<void>
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [selectedName, setSelectedName] = useState<string>(() => domains[0]?.name ?? "") const [selectedName, setSelectedName] = useState<string>(() => domains[0]?.name ?? "")
const [records, setRecords] = useState<DnsRecord[]>([]) const [records, setRecords] = useState<DnsRecord[]>([])
const [loadingRecords, setLoadingRecords] = useState(false) 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 [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<DnsRecord | null>(null) const [pendingDelete, setPendingDelete] = useState<DnsRecord | null>(null)
@@ -387,44 +414,47 @@ function DomainsPanel({
setRecords([]) setRecords([])
return return
} }
setRecordsError(null)
setLoadingRecords(true) setLoadingRecords(true)
try { try {
setRecords(await listDnsRecords(arcadia, name)) setRecords(await listDnsRecords(arcadia, name))
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Failed to load DNS records.") setRecords([])
setRecordsError(err)
} finally { } finally {
setLoadingRecords(false) setLoadingRecords(false)
} }
}, },
[arcadia, onError], [arcadia],
) )
useEffect(() => { useEffect(() => {
loadRecords(selectedName) loadRecords(selectedName)
}, [selectedName, loadRecords]) }, [selectedName, loadRecords])
if (loading && domains.length === 0) { if (loading || error || domains.length === 0) {
return ( return (
<Card> <DataState
<CardContent className="relative py-8"> loading={loading}
<LoadingOverlay active label="Loading domains" /> error={error}
</CardContent> isEmpty={domains.length === 0}
</Card> onRetry={onChanged}
) loadingLabel="Loading domains"
} empty={
<Card>
if (domains.length === 0) { <CardContent>
return ( <EmptyState
<Card> icon={<Globe className="size-6" />}
<CardContent> title="No domains."
<EmptyState description="Add a domain on your provider; arcadia surfaces it here for record management."
icon={<Globe className="size-6" />} className="py-8"
title="No domains." />
description="Add a domain on your provider; arcadia surfaces it here for record management." </CardContent>
className="py-8" </Card>
/> }
</CardContent> >
</Card> {null}
</DataState>
) )
} }
@@ -470,14 +500,21 @@ function DomainsPanel({
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="relative p-0">
{records.length === 0 && !loadingRecords ? ( <DataState
<EmptyState loading={loadingRecords}
icon={<Globe className="size-6" />} error={recordsError}
title="No records on this domain." isEmpty={records.length === 0}
className="py-8" 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"> <ul className="divide-y border-y">
{records.map((r) => ( {records.map((r) => (
<li key={String(r.id)} className="flex items-center justify-between gap-3 px-3 py-2 text-sm"> <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> </li>
))} ))}
</ul> </ul>
)} </DataState>
</CardContent> </CardContent>
<DnsCreateDialog <DnsCreateDialog
open={createOpen} open={createOpen}
domainName={selectedName} domainName={selectedName}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onCreated={async () => { onCreated={async (label) => {
setCreateOpen(false) setCreateOpen(false)
onInfo("DNS record created.")
await loadRecords(selectedName) await loadRecords(selectedName)
await onChanged() await onChanged()
toast.success(`Created ${label}`)
}} }}
onError={onError}
/> />
<ConfirmDialog <ConfirmDialog
@@ -534,14 +570,15 @@ function DomainsPanel({
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingDelete) return if (!pendingDelete) return
const label = `${pendingDelete.type} ${pendingDelete.name}`
try { try {
await deleteDnsRecord(arcadia, selectedName, pendingDelete.id) await deleteDnsRecord(arcadia, selectedName, pendingDelete.id)
setPendingDelete(null) setPendingDelete(null)
onInfo("Record deleted.")
await loadRecords(selectedName) await loadRecords(selectedName)
toast.success(`Deleted ${label}`)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null) setPendingDelete(null)
toast.error(errorMessage(err, `delete ${label}`))
} }
}} }}
/> />
@@ -554,13 +591,11 @@ function DnsCreateDialog({
domainName, domainName,
onClose, onClose,
onCreated, onCreated,
onError,
}: { }: {
open: boolean open: boolean
domainName: string domainName: string
onClose: () => void onClose: () => void
onCreated: () => Promise<void> onCreated: (label: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const [type, setType] = useState("A") const [type, setType] = useState("A")
@@ -569,6 +604,7 @@ function DnsCreateDialog({
const [ttl, setTtl] = useState("3600") const [ttl, setTtl] = useState("3600")
const [priority, setPriority] = useState("") const [priority, setPriority] = useState("")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -577,11 +613,12 @@ function DnsCreateDialog({
setData("") setData("")
setTtl("3600") setTtl("3600")
setPriority("") setPriority("")
setError(null)
} }
}, [open]) }, [open])
const submit = async () => { const submit = async () => {
onError(null) setError(null)
setSaving(true) setSaving(true)
try { try {
await createDnsRecord(arcadia, domainName, { await createDnsRecord(arcadia, domainName, {
@@ -591,9 +628,10 @@ function DnsCreateDialog({
ttl: ttl ? Number(ttl) : undefined, ttl: ttl ? Number(ttl) : undefined,
priority: priority ? Number(priority) : undefined, priority: priority ? Number(priority) : undefined,
}) })
await onCreated() await onCreated(`${type} ${name} → ${data}`)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Create failed.") // A rejected record (bad target, duplicate name) is fixable right here.
setError(err)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -679,8 +717,15 @@ function DnsCreateDialog({
) : null} ) : null}
</div> </div>
{error ? <DialogError error={error} context="create the record" /> : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}> <Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="dns-form-cancel"
>
Cancel Cancel
</Button> </Button>
<Button onClick={submit} disabled={saving || !data} data-action="dns-form-save"> <Button onClick={submit} disabled={saving || !data} data-action="dns-form-save">
@@ -699,47 +744,37 @@ function FloatingIpsPanel({
ips, ips,
droplets, droplets,
loading, loading,
error,
onChanged, onChanged,
onError,
onInfo,
}: { }: {
ips: FloatingIp[] ips: FloatingIp[]
droplets: Droplet[] droplets: Droplet[]
loading: boolean loading: boolean
error: unknown
onChanged: () => Promise<void> onChanged: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [assigning, setAssigning] = useState<{ ip: string; dropletId: string } | null>(null) 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>
<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 ( return (
<Card> <Card>
<CardContent className="p-0"> <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"
/>
}
>
<ul className="divide-y border-y"> <ul className="divide-y border-y">
{ips.map((ip) => { {ips.map((ip) => {
const region = const region =
@@ -764,12 +799,10 @@ function FloatingIpsPanel({
onClick={async () => { onClick={async () => {
try { try {
await unassignFloatingIp(arcadia, ip.ip) await unassignFloatingIp(arcadia, ip.ip)
onInfo("Floating IP unassigned.")
await onChanged() await onChanged()
toast.success(`Unassigned ${ip.ip}`)
} catch (err) { } catch (err) {
onError( toast.error(errorMessage(err, `unassign ${ip.ip}`))
err instanceof ArcadiaError ? err.message : "Unassign failed.",
)
} }
}} }}
data-action={`fip-${ip.ip}-unassign`} data-action={`fip-${ip.ip}-unassign`}
@@ -809,14 +842,17 @@ function FloatingIpsPanel({
} }
onClick={async () => { onClick={async () => {
if (!assigning || assigning.ip !== ip.ip) return if (!assigning || assigning.ip !== ip.ip) return
const dropletName =
droplets.find((d) => String(d.id) === assigning.dropletId)
?.name ?? assigning.dropletId
try { try {
await assignFloatingIp(arcadia, ip.ip, assigning.dropletId) await assignFloatingIp(arcadia, ip.ip, assigning.dropletId)
setAssigning(null) setAssigning(null)
onInfo("Floating IP assigned.")
await onChanged() await onChanged()
toast.success(`Assigned ${ip.ip} to ${dropletName}`)
} catch (err) { } catch (err) {
onError( toast.error(
err instanceof ArcadiaError ? err.message : "Assign failed.", errorMessage(err, `assign ${ip.ip} to ${dropletName}`),
) )
} }
}} }}
@@ -831,7 +867,10 @@ function FloatingIpsPanel({
) )
})} })}
</ul> </ul>
</DataState>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,8 @@ import {
X, X,
} from "lucide-react" } 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 { import {
ActionsCell, ActionsCell,
BadgeCell, BadgeCell,
@@ -18,21 +19,16 @@ import {
Pagination, Pagination,
useTable, useTable,
type ActionItem, type ActionItem,
type BadgeTone,
type Column, type Column,
} from "@crema/table-ui" } 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 { 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 { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import { Card, CardContent } from "~/components/ui/card"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -68,40 +64,65 @@ type Editor =
| { kind: "edit"; idp: IdentityProvider } | { kind: "edit"; idp: IdentityProvider }
| null | 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() { export default function SsoRoute() {
const session = useSession() const session = useSession()
const arcadia = useArcadiaClient() 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 [idps, setIdps] = useState<IdentityProvider[]>([])
const [idpsLoading, setIdpsLoading] = useState(true)
const [idpsError, setIdpsError] = useState<unknown>(null)
const [sessions, setSessions] = useState<SamlSession[]>([]) const [sessions, setSessions] = useState<SamlSession[]>([])
const [loading, setLoading] = useState(true) const [sessionsLoading, setSessionsLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [sessionsError, setSessionsError] = useState<unknown>(null)
const [info, setInfo] = useState<string | null>(null)
const [editor, setEditor] = useState<Editor>(null) const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<IdentityProvider | null>(null) const [pendingDelete, setPendingDelete] = useState<IdentityProvider | null>(null)
const [pendingSessionDestroy, setPendingSessionDestroy] = useState<SamlSession | null>(null) const [pendingSessionDestroy, setPendingSessionDestroy] = useState<SamlSession | null>(null)
const refresh = useCallback(async () => { const refreshIdps = useCallback(async () => {
setError(null) setIdpsError(null)
setLoading(true) setIdpsLoading(true)
try { try {
const [i, s] = await Promise.all([ setIdps(await listIdentityProviders(arcadia))
listIdentityProviders(arcadia).catch(() => [] as IdentityProvider[]),
listSamlSessions(arcadia).catch(() => [] as SamlSession[]),
])
setIdps(i)
setSessions(s)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load SSO data.") setIdpsError(err)
} finally { } finally {
setLoading(false) setIdpsLoading(false)
} }
}, [arcadia]) }, [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(() => { useEffect(() => {
if (session) refresh() if (session) refresh()
}, [session, refresh]) }, [session, refresh])
const loading = idpsLoading || sessionsLoading
useRegisterContext("sso", { useRegisterContext("sso", {
identity_providers: idps.length, identity_providers: idps.length,
enabled_idps: idps.filter((i) => i.enabled).length, enabled_idps: idps.filter((i) => i.enabled).length,
@@ -176,12 +197,13 @@ export default function SsoRoute() {
label: i.enabled ? "Disable" : "Enable", label: i.enabled ? "Disable" : "Enable",
dataAction: `idp-${i.id}-toggle`, dataAction: `idp-${i.id}-toggle`,
onSelect: async () => { onSelect: async () => {
const verb = i.enabled ? "disable" : "enable"
try { try {
await updateIdentityProvider(arcadia, i.id, { enabled: !i.enabled }) await updateIdentityProvider(arcadia, i.id, { enabled: !i.enabled })
setInfo(`${i.name} ${i.enabled ? "disabled" : "enabled"}.`) await refreshIdps()
await refresh() toast.success(`${i.enabled ? "Disabled" : "Enabled"} ${i.name}`)
} catch (err) { } 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>({ const idpTable = useTable<IdentityProvider>({
@@ -231,17 +253,6 @@ export default function SsoRoute() {
</div> </div>
</header> </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"> <Tabs defaultValue="idps">
<TabsList> <TabsList>
<TabsTrigger value="idps" data-action="sso-tab-idps"> <TabsTrigger value="idps" data-action="sso-tab-idps">
@@ -255,48 +266,59 @@ export default function SsoRoute() {
<TabsContent value="idps" className="pt-4"> <TabsContent value="idps" className="pt-4">
<Card> <Card>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay active={loading && idps.length === 0} label="Loading IdPs…" /> <DataState
{idpTable.total === 0 && !loading ? ( loading={idpsLoading}
<EmptyState error={idpsError}
icon={<KeyRound className="size-6" />} isEmpty={idpTable.total === 0}
title="No identity providers." onRetry={refreshIdps}
description="Connect a SAML IdP (Okta, Azure AD, Google Workspace, etc.) to enable SSO for this tenant." loadingLabel="Loading IdPs…"
className="py-12" 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={idpsLoading && idps.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={idpTable.page}
<DataTable pageSize={idpTable.pageSize}
columns={idpColumns} total={idpTable.total}
rows={idpTable.pageRows} onPageChange={idpTable.setPage}
getRowId={(i) => i.id} onPageSizeChange={idpTable.setPageSize}
sort={idpTable.sort} />
onSortToggle={idpTable.toggleSort} </DataState>
loading={loading && idps.length > 0}
stickyHeader
/>
<Pagination
page={idpTable.page}
pageSize={idpTable.pageSize}
total={idpTable.total}
onPageChange={idpTable.setPage}
onPageSizeChange={idpTable.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="sessions" className="pt-4"> <TabsContent value="sessions" className="pt-4">
<Card> <Card>
<CardContent className="p-0"> <CardContent className="relative p-0">
{sessions.length === 0 ? ( <DataState
<EmptyState loading={sessionsLoading}
title="No active SAML sessions." error={sessionsError}
description="Sessions appear here once users authenticate via the IdP." isEmpty={sessions.length === 0}
className="py-12" 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"> <ul className="divide-y border-y">
{sessions.map((s) => ( {sessions.map((s) => (
<li <li
@@ -305,7 +327,7 @@ export default function SsoRoute() {
> >
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2"> <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() ? ( {s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? (
<Badge variant="destructive">expired</Badge> <Badge variant="destructive">expired</Badge>
) : ( ) : (
@@ -333,7 +355,7 @@ export default function SsoRoute() {
</li> </li>
))} ))}
</ul> </ul>
)} </DataState>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -346,21 +368,22 @@ export default function SsoRoute() {
title="Delete identity provider?" title="Delete identity provider?"
description={ description={
pendingDelete 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" confirmLabel="Delete"
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingDelete) return if (!pendingDelete) return
const name = pendingDelete.name
try { try {
await deleteIdentityProvider(arcadia, pendingDelete.id) await deleteIdentityProvider(arcadia, pendingDelete.id)
setPendingDelete(null) setPendingDelete(null)
setInfo("Identity provider deleted.")
await refresh() await refresh()
toast.success(`Deleted ${name}`)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null) setPendingDelete(null)
toast.error(errorMessage(err, `delete ${name}`))
} }
}} }}
/> />
@@ -371,21 +394,22 @@ export default function SsoRoute() {
title="Destroy SAML session?" title="Destroy SAML session?"
description={ description={
pendingSessionDestroy 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" confirmLabel="Destroy"
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingSessionDestroy) return if (!pendingSessionDestroy) return
const who = sessionLabel(pendingSessionDestroy)
try { try {
await destroySamlSession(arcadia, pendingSessionDestroy.id) await destroySamlSession(arcadia, pendingSessionDestroy.id)
setPendingSessionDestroy(null) setPendingSessionDestroy(null)
setInfo("Session destroyed.") await refreshSessions()
await refresh() toast.success(`Destroyed the session for ${who}`)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Destroy failed.")
setPendingSessionDestroy(null) setPendingSessionDestroy(null)
toast.error(errorMessage(err, `destroy the session for ${who}`))
} }
}} }}
/> />
@@ -393,12 +417,11 @@ export default function SsoRoute() {
<IdpEditorDialog <IdpEditorDialog
state={editor} state={editor}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onSaved={async (msg) => { onSaved={async (message) => {
setEditor(null) setEditor(null)
if (msg) setInfo(msg) await refreshIdps()
await refresh() toast.success(message)
}} }}
onError={setError}
/> />
</AppShell> </AppShell>
) )
@@ -408,12 +431,10 @@ function IdpEditorDialog({
state, state,
onClose, onClose,
onSaved, onSaved,
onError,
}: { }: {
state: Editor state: Editor
onClose: () => void onClose: () => void
onSaved: (msg?: string) => Promise<void> onSaved: (message: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const open = state !== null const open = state !== null
@@ -431,9 +452,14 @@ function IdpEditorDialog({
const [certificate, setCertificate] = useState("") const [certificate, setCertificate] = useState("")
const [attrJson, setAttrJson] = useState("{}") const [attrJson, setAttrJson] = useState("{}")
const [saving, setSaving] = useState(false) 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(() => { useEffect(() => {
if (!open) return if (!open) return
setError(null)
setSaving(false)
if (initial) { if (initial) {
setName(initial.name) setName(initial.name)
setEntityId(initial.entity_id) setEntityId(initial.entity_id)
@@ -460,7 +486,7 @@ function IdpEditorDialog({
}, [open, initial]) }, [open, initial])
const submit = async () => { const submit = async () => {
onError(null) setError(null)
setSaving(true) setSaving(true)
try { try {
let attribute_mapping: Record<string, string> = {} let attribute_mapping: Record<string, string> = {}
@@ -484,20 +510,15 @@ function IdpEditorDialog({
if (isEdit && initial) { if (isEdit && initial) {
await updateIdentityProvider(arcadia, initial.id, input) await updateIdentityProvider(arcadia, initial.id, input)
await onSaved("Identity provider updated.") await onSaved(`Saved ${name.trim()}`)
} else { } else {
await createIdentityProvider(arcadia, input) await createIdentityProvider(arcadia, input)
await onSaved("Identity provider created.") await onSaved(`Created ${name.trim()}`)
} }
} catch (err) { } catch (err) {
onError( // Keep the pasted certificate and JSON on screen — retyping them is the
err instanceof ArcadiaError // last thing an operator should have to do after a failed save.
? err.message setError(err)
: err instanceof Error
? err.message
: "Save failed.",
)
} finally {
setSaving(false) setSaving(false)
} }
} }
@@ -636,8 +657,15 @@ function IdpEditorDialog({
</div> </div>
</div> </div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the identity provider" : "create the identity provider"}
/>
) : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}> <Button variant="outline" onClick={onClose} disabled={saving} data-action="idp-form-cancel">
Cancel Cancel
</Button> </Button>
<Button <Button
@@ -653,3 +681,5 @@ function IdpEditorDialog({
</Dialog> </Dialog>
) )
} }
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

@@ -12,7 +12,8 @@ import {
Wrench, Wrench,
} from "lucide-react" } 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 { import {
ActionsCell, ActionsCell,
BadgeCell, BadgeCell,
@@ -25,9 +26,11 @@ import {
type Column, type Column,
} from "@crema/table-ui" } from "@crema/table-ui"
import { SearchInput } from "@crema/search-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 { 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 { Button } from "~/components/ui/button"
import { import {
Card, Card,
@@ -90,14 +93,27 @@ type EditorState =
| { mode: "edit"; config: StorageConfig } | { mode: "edit"; config: StorageConfig }
| null | 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() { export default function StorageRoute() {
const session = useSession() const session = useSession()
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [configs, setConfigs] = useState<StorageConfig[]>([]) const [configs, setConfigs] = useState<StorageConfig[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) // The raw thrown value — `DataState` normalises it into plain language. A
const [info, setInfo] = useState<string | null>(null) // 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 [pending, setPending] = useState<PendingAction>(null)
const [editor, setEditor] = useState<EditorState>(null) const [editor, setEditor] = useState<EditorState>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
@@ -109,7 +125,7 @@ export default function StorageRoute() {
const list = await listStorageConfigs(arcadia) const list = await listStorageConfigs(arcadia)
setConfigs(list) setConfigs(list)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load storage configs.") setError(err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -131,30 +147,35 @@ export default function StorageRoute() {
else if (action.kind === "delete") await deleteStorageConfig(arcadia, action.config.id) else if (action.kind === "delete") await deleteStorageConfig(arcadia, action.config.id)
setPending(null) setPending(null)
await refresh() await refresh()
toast.success(`${ACTION_PAST_TENSE[action.kind]} ${action.config.name}`)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Action failed.")
setPending(null) setPending(null)
toast.error(errorMessage(err, `${action.kind} ${action.config.name}`))
} }
}, },
[arcadia, refresh], [arcadia, refresh, toast],
) )
const validate = useCallback( const validate = useCallback(
async (config: StorageConfig) => { async (config: StorageConfig) => {
setError(null)
setInfo(null)
try { try {
const result = await validateStorageConfig(arcadia, config.id) const result = await validateStorageConfig(arcadia, config.id)
if (result?.ok) { if (result?.ok) {
setInfo(`${config.name}: validation passed.`) toast.success(`${config.name} validated`, {
description: "The backend answered with the credentials on file.",
})
} else { } 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) { } 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>[]>( const columns = useMemo<Column<StorageConfig>[]>(
@@ -216,7 +237,7 @@ export default function StorageRoute() {
refresh, refresh,
setPending, setPending,
setEditor, setEditor,
setError, toast,
validate, validate,
})} })}
triggerDataAction={`storage-${slugify(c.name)}-actions`} triggerDataAction={`storage-${slugify(c.name)}-actions`}
@@ -224,7 +245,7 @@ export default function StorageRoute() {
), ),
}, },
], ],
[arcadia, refresh, validate], [arcadia, refresh, toast, validate],
) )
const summary = useMemo( const summary = useMemo(
@@ -295,17 +316,6 @@ export default function StorageRoute() {
</div> </div>
</header> </header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between gap-4"> <CardHeader className="flex flex-row items-center justify-between gap-4">
<SearchInput <SearchInput
@@ -321,37 +331,41 @@ export default function StorageRoute() {
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay active={loading && configs.length === 0} label="Loading storage configs…" /> <DataState
{table.total === 0 && !loading ? ( loading={loading}
<EmptyState error={error}
title={search ? "No configs match that search." : "No storage configs yet."} isEmpty={table.total === 0}
description={ onRetry={refresh}
search loadingLabel="Loading storage configs…"
? "Try a different name, backend, or status." empty={
: "Create your first storage config to start uploading objects." <EmptyState
} title={search ? "No configs match that search." : "No storage configs yet."}
className="py-12" description={
search
? "Try a different name, backend, or status."
: "Create your first storage config to start uploading objects."
}
className="py-12"
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(c) => c.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && configs.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={table.page}
<DataTable pageSize={table.pageSize}
columns={columns} total={table.total}
rows={table.pageRows} onPageChange={table.setPage}
getRowId={(c) => c.id} onPageSizeChange={table.setPageSize}
sort={table.sort} />
onSortToggle={table.toggleSort} </DataState>
loading={loading && configs.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -412,11 +426,11 @@ export default function StorageRoute() {
<StorageEditorDialog <StorageEditorDialog
state={editor} state={editor}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onSaved={async () => { onSaved={async (name, wasEdit) => {
setEditor(null) setEditor(null)
await refresh() await refresh()
toast.success(wasEdit ? `Saved ${name}` : `Created ${name}`)
}} }}
onError={setError}
/> />
</AppShell> </AppShell>
) )
@@ -437,11 +451,11 @@ function rowActions(
refresh: () => Promise<void> refresh: () => Promise<void>
setPending: (p: PendingAction) => void setPending: (p: PendingAction) => void
setEditor: (s: EditorState) => void setEditor: (s: EditorState) => void
setError: (msg: string | null) => void toast: ReturnType<typeof useToast>
validate: (c: StorageConfig) => Promise<void> validate: (c: StorageConfig) => Promise<void>
}, },
): ActionItem[] { ): ActionItem[] {
const { arcadia, refresh, setPending, setEditor, setError, validate } = ctx const { arcadia, refresh, setPending, setEditor, toast, validate } = ctx
const slug = slugify(c.name) const slug = slugify(c.name)
const items: ActionItem[] = [] const items: ActionItem[] = []
@@ -478,8 +492,9 @@ function rowActions(
try { try {
await activateStorageConfig(arcadia, c.id) await activateStorageConfig(arcadia, c.id)
await refresh() await refresh()
toast.success(`Activated ${c.name}`)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Activate failed.") toast.error(errorMessage(err, `activate ${c.name}`))
} }
}, },
}) })
@@ -495,8 +510,9 @@ function rowActions(
try { try {
await setDefaultStorageConfig(arcadia, c.id) await setDefaultStorageConfig(arcadia, c.id)
await refresh() await refresh()
toast.success(`${c.name} is now the default backend`)
} catch (err) { } 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, state,
onClose, onClose,
onSaved, onSaved,
onError,
}: { }: {
state: EditorState state: EditorState
onClose: () => void onClose: () => void
onSaved: () => Promise<void> onSaved: (name: string, wasEdit: boolean) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const open = state !== null const open = state !== null
const isEdit = state?.mode === "edit" const isEdit = state?.mode === "edit"
const initial = isEdit ? state.config : null 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 [name, setName] = useState("")
const [backend, setBackend] = useState<StorageBackend>("s3") const [backend, setBackend] = useState<StorageBackend>("s3")
@@ -556,7 +573,11 @@ function StorageEditorDialog({
// Reset form whenever the dialog opens / target changes. // Reset form whenever the dialog opens / target changes.
useEffect(() => { useEffect(() => {
if (!open) return if (!open) {
setError(null)
return
}
setError(null)
if (initial) { if (initial) {
setName(initial.name) setName(initial.name)
setBackend(initial.backend_type) setBackend(initial.backend_type)
@@ -595,7 +616,7 @@ function StorageEditorDialog({
} }
const submit = async () => { const submit = async () => {
onError(null) setError(null)
setSaving(true) setSaving(true)
try { try {
const config: Record<string, unknown> = {} const config: Record<string, unknown> = {}
@@ -633,9 +654,11 @@ function StorageEditorDialog({
} else { } else {
await createStorageConfig(arcadia, input) await createStorageConfig(arcadia, input)
} }
await onSaved() await onSaved(name, isEdit)
} catch (err) { } 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 { } finally {
setSaving(false) setSaving(false)
} }
@@ -749,6 +772,13 @@ function StorageEditorDialog({
</div> </div>
</div> </div>
{error ? (
<DialogError
error={error}
context={isEdit ? "save the storage config" : "create the storage config"}
/>
) : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="storage-form-cancel"> <Button variant="outline" onClick={onClose} disabled={saving} data-action="storage-form-cancel">
Cancel Cancel
@@ -841,3 +871,5 @@ function formatBytes(n: number | null): string {
} }
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}` 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 { 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 { import {
ActionsCell, ActionsCell,
BadgeCell, BadgeCell,
@@ -14,10 +16,12 @@ import {
type Column, type Column,
} from "@crema/table-ui" } from "@crema/table-ui"
import { SearchInput } from "@crema/search-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 { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { PageHeader } from "~/components/layout/page-header" import { PageHeader } from "~/components/layout/page-header"
import { errorMessage } from "~/lib/errors"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import {
Card, Card,
@@ -59,10 +63,14 @@ type PendingAction = {
export default function TenantsRoute() { export default function TenantsRoute() {
const session = useSession() const session = useSession()
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const navigate = useNavigate()
const [tenants, setTenants] = useState<Tenant[]>([]) const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true) 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 [pending, setPending] = useState<PendingAction>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
@@ -74,7 +82,7 @@ export default function TenantsRoute() {
const list = await listTenants(arcadia) const list = await listTenants(arcadia)
setTenants(list) setTenants(list)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load tenants.") setError(err)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -87,17 +95,19 @@ export default function TenantsRoute() {
const runAction = useCallback( const runAction = useCallback(
async (action: PendingAction) => { async (action: PendingAction) => {
if (!action) return if (!action) return
const verb = action.kind === "suspend" ? "Suspended" : "Deactivated"
try { try {
if (action.kind === "suspend") await suspendTenant(arcadia, action.tenant.id) if (action.kind === "suspend") await suspendTenant(arcadia, action.tenant.id)
else await deactivateTenant(arcadia, action.tenant.id) else await deactivateTenant(arcadia, action.tenant.id)
setPending(null) setPending(null)
await refresh() await refresh()
toast.success(`${verb} ${action.tenant.name}`)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Action failed.")
setPending(null) setPending(null)
toast.error(errorMessage(err, `${action.kind} ${action.tenant.name}`))
} }
}, },
[arcadia, refresh], [arcadia, refresh, toast],
) )
const columns = useMemo<Column<Tenant>[]>( const columns = useMemo<Column<Tenant>[]>(
@@ -107,7 +117,15 @@ export default function TenantsRoute() {
header: "Name", header: "Name",
accessor: "name", accessor: "name",
sortable: true, 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", id: "slug",
@@ -145,13 +163,13 @@ export default function TenantsRoute() {
align: "right", align: "right",
cell: (t) => ( cell: (t) => (
<ActionsCell <ActionsCell
items={rowActions(t, arcadia, refresh, setPending, setError)} items={rowActions(t, arcadia, refresh, setPending, toast, navigate)}
triggerDataAction={`tenant-${t.slug}-actions`} triggerDataAction={`tenant-${t.slug}-actions`}
/> />
), ),
}, },
], ],
[arcadia, refresh], [arcadia, refresh, toast, navigate],
) )
const tenantSummary = useMemo( const tenantSummary = useMemo(
@@ -215,12 +233,6 @@ export default function TenantsRoute() {
} }
/> />
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between gap-4"> <CardHeader className="flex flex-row items-center justify-between gap-4">
<SearchInput <SearchInput
@@ -236,46 +248,56 @@ export default function TenantsRoute() {
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay active={loading && tenants.length === 0} label="Loading tenants…" /> <DataState
{table.total === 0 && !loading ? ( loading={loading}
<EmptyState error={error}
title={search ? "No tenants match that search." : "No tenants yet."} isEmpty={table.total === 0}
description={ onRetry={refresh}
search ? "Try a different name, slug, or status." : "Create your first tenant to get started." loadingLabel="Loading tenants…"
} empty={
className="py-12" <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."
}
className="py-12"
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(t) => t.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && tenants.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={table.page}
<DataTable pageSize={table.pageSize}
columns={columns} total={table.total}
rows={table.pageRows} onPageChange={table.setPage}
getRowId={(t) => t.id} onPageSizeChange={table.setPageSize}
sort={table.sort} />
onSortToggle={table.toggleSort} </DataState>
loading={loading && tenants.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
<TenantCreateDialog <TenantCreateDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onCreated={async () => { onCreated={async (tenant, adminEmail) => {
setCreateOpen(false) setCreateOpen(false)
await refresh() 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 <ConfirmDialog
open={pending?.kind === "suspend"} open={pending?.kind === "suspend"}
@@ -296,7 +318,7 @@ export default function TenantsRoute() {
title="Deactivate tenant?" title="Deactivate tenant?"
description={ description={
pending 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" confirmLabel="Deactivate"
@@ -319,9 +341,18 @@ function rowActions(
arcadia: ReturnType<typeof useArcadiaClient>, arcadia: ReturnType<typeof useArcadiaClient>,
refresh: () => Promise<void>, refresh: () => Promise<void>,
setPending: (p: PendingAction) => void, setPending: (p: PendingAction) => void,
setError: (msg: string | null) => void, toast: ReturnType<typeof useToast>,
navigate: (to: string) => void,
): ActionItem[] { ): 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") { if (t.status === "active") {
items.push({ items.push({
id: "suspend", id: "suspend",
@@ -340,8 +371,9 @@ function rowActions(
try { try {
await activateTenant(arcadia, t.id) await activateTenant(arcadia, t.id)
await refresh() await refresh()
toast.success(`Activated ${t.name}`)
} catch (err) { } 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 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 { function slugify(name: string): string {
return name return name
.toLowerCase() .toLowerCase()
@@ -391,12 +400,10 @@ function TenantCreateDialog({
open, open,
onClose, onClose,
onCreated, onCreated,
onError,
}: { }: {
open: boolean open: boolean
onClose: () => void onClose: () => void
onCreated: () => Promise<void> | void onCreated: (tenant: Tenant, adminEmail: string) => Promise<void> | void
onError: (msg: string) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const [name, setName] = useState("") const [name, setName] = useState("")
@@ -407,6 +414,11 @@ function TenantCreateDialog({
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false) 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(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -418,6 +430,7 @@ function TenantCreateDialog({
setEmail("") setEmail("")
setPassword("") setPassword("")
setSubmitting(false) setSubmitting(false)
setError(null)
} }
}, [open]) }, [open])
@@ -436,8 +449,9 @@ function TenantCreateDialog({
e.preventDefault() e.preventDefault()
if (!canSubmit) return if (!canSubmit) return
setSubmitting(true) setSubmitting(true)
setError(null)
try { try {
await provisionTenant(arcadia, { const tenant = await provisionTenant(arcadia, {
tenant: { name: name.trim(), slug }, tenant: { name: name.trim(), slug },
admin_user: { admin_user: {
email: email.trim(), email: email.trim(),
@@ -446,9 +460,11 @@ function TenantCreateDialog({
last_name: lastName.trim(), last_name: lastName.trim(),
}, },
}) })
await onCreated() await onCreated(tenant, email.trim())
} catch (err) { } 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) setSubmitting(false)
} }
} }
@@ -460,7 +476,8 @@ function TenantCreateDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>New tenant</DialogTitle> <DialogTitle>New tenant</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -547,6 +564,8 @@ function TenantCreateDialog({
</div> </div>
</div> </div>
{error ? <DialogError error={error} context="create the tenant" /> : null}
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -570,3 +589,5 @@ function TenantCreateDialog({
</Dialog> </Dialog>
) )
} }
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import { import {
CheckCircle2, CheckCircle2,
Eye, Eye,
@@ -14,7 +13,8 @@ import {
X, X,
} from "lucide-react" } 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 { import {
ActionsCell, ActionsCell,
BadgeCell, BadgeCell,
@@ -27,17 +27,13 @@ import {
type Column, type Column,
} from "@crema/table-ui" } from "@crema/table-ui"
import { SearchInput } from "@crema/search-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 { 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 { Button } from "~/components/ui/button"
import { import { Card, CardContent, CardHeader } from "~/components/ui/card"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -99,44 +95,53 @@ export default function UsersRoute() {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const [tab, setTab] = useState<Tab>("users") 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 [users, setUsers] = useState<User[]>([])
const [usersLoading, setUsersLoading] = useState(true) const [usersLoading, setUsersLoading] = useState(true)
const [usersError, setUsersError] = useState<unknown>(null)
const [invitations, setInvitations] = useState<Invitation[]>([]) const [invitations, setInvitations] = useState<Invitation[]>([])
const [invitationsLoading, setInvitationsLoading] = useState(true) const [invitationsLoading, setInvitationsLoading] = useState(true)
const [invitationsError, setInvitationsError] = useState<unknown>(null)
const [roles, setRoles] = useState<Role[]>([]) const [roles, setRoles] = useState<Role[]>([])
const [rolesLoading, setRolesLoading] = useState(true) const [rolesLoading, setRolesLoading] = useState(true)
const [rolesError, setRolesError] = useState<unknown>(null)
const refreshUsers = useCallback(async () => { const refreshUsers = useCallback(async () => {
setUsersError(null)
setUsersLoading(true) setUsersLoading(true)
try { try {
setUsers(await listUsers(arcadia)) setUsers(await listUsers(arcadia))
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load users.") // Raw throw: describeError() reads the status off it.
setUsersError(err)
} finally { } finally {
setUsersLoading(false) setUsersLoading(false)
} }
}, [arcadia]) }, [arcadia])
const refreshInvitations = useCallback(async () => { const refreshInvitations = useCallback(async () => {
setInvitationsError(null)
setInvitationsLoading(true) setInvitationsLoading(true)
try { try {
setInvitations(await listInvitations(arcadia)) setInvitations(await listInvitations(arcadia))
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load invitations.") setInvitationsError(err)
} finally { } finally {
setInvitationsLoading(false) setInvitationsLoading(false)
} }
}, [arcadia]) }, [arcadia])
const refreshRoles = useCallback(async () => { const refreshRoles = useCallback(async () => {
setRolesError(null)
setRolesLoading(true) setRolesLoading(true)
try { try {
setRoles(await listRoles(arcadia)) setRoles(await listRoles(arcadia))
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load roles.") setRolesError(err)
} finally { } finally {
setRolesLoading(false) setRolesLoading(false)
} }
@@ -179,17 +184,6 @@ export default function UsersRoute() {
</p> </p>
</header> </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)}> <Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
<TabsList> <TabsList>
<TabsTrigger value="users" data-action="users-tab-users"> <TabsTrigger value="users" data-action="users-tab-users">
@@ -208,9 +202,8 @@ export default function UsersRoute() {
users={users} users={users}
roles={roles} roles={roles}
loading={usersLoading} loading={usersLoading}
error={usersError}
onRefresh={refreshUsers} onRefresh={refreshUsers}
onError={setError}
onInfo={setInfo}
/> />
</TabsContent> </TabsContent>
<TabsContent value="invitations"> <TabsContent value="invitations">
@@ -218,18 +211,16 @@ export default function UsersRoute() {
invitations={invitations} invitations={invitations}
roles={roles} roles={roles}
loading={invitationsLoading} loading={invitationsLoading}
error={invitationsError}
onRefresh={refreshInvitations} onRefresh={refreshInvitations}
onError={setError}
onInfo={setInfo}
/> />
</TabsContent> </TabsContent>
<TabsContent value="roles"> <TabsContent value="roles">
<RolesPanel <RolesPanel
roles={roles} roles={roles}
loading={rolesLoading} loading={rolesLoading}
error={rolesError}
onRefresh={refreshRoles} onRefresh={refreshRoles}
onError={setError}
onInfo={setInfo}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -244,18 +235,17 @@ function UsersPanel({
users, users,
roles, roles,
loading, loading,
error,
onRefresh, onRefresh,
onError,
onInfo,
}: { }: {
users: User[] users: User[]
roles: Role[] roles: Role[]
loading: boolean loading: boolean
error: unknown
onRefresh: () => Promise<void> onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all") const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all")
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null) const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null)
@@ -344,15 +334,14 @@ function UsersPanel({
setEditor, setEditor,
setPendingDelete, setPendingDelete,
setDetailUser, setDetailUser,
setError: onError, toast,
setInfo: onInfo,
})} })}
triggerDataAction={`user-${u.id}-actions`} triggerDataAction={`user-${u.id}-actions`}
/> />
), ),
}, },
], ],
[arcadia, onError, onInfo, onRefresh], [arcadia, onRefresh, toast],
) )
const table = useTable<User>({ const table = useTable<User>({
@@ -406,37 +395,43 @@ function UsersPanel({
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay active={loading && users.length === 0} label="Loading users…" /> <DataState
{table.total === 0 && !loading ? ( loading={loading}
<EmptyState error={error}
title={search || statusFilter !== "all" ? "No users match those filters." : "No users yet."} isEmpty={table.total === 0}
description={ onRetry={onRefresh}
search || statusFilter !== "all" loadingLabel="Loading users…"
? "Try a different search or status filter." empty={
: "Invite your first user from the Invitations tab." <EmptyState
} title={
className="py-12" search || statusFilter !== "all" ? "No users match those filters." : "No users yet."
}
description={
search || statusFilter !== "all"
? "Try a different search or status filter."
: "Invite your first user from the Invitations tab."
}
className="py-12"
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(u) => u.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && users.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={table.page}
<DataTable pageSize={table.pageSize}
columns={columns} total={table.total}
rows={table.pageRows} onPageChange={table.setPage}
getRowId={(u) => u.id} onPageSizeChange={table.setPageSize}
sort={table.sort} />
onSortToggle={table.toggleSort} </DataState>
loading={loading && users.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
<ConfirmDialog <ConfirmDialog
@@ -445,20 +440,22 @@ function UsersPanel({
title="Delete user?" title="Delete user?"
description={ description={
pendingDelete 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" confirmLabel="Delete"
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingDelete) return if (!pendingDelete) return
const email = pendingDelete.email
try { try {
await deleteUser(arcadia, pendingDelete.id) await deleteUser(arcadia, pendingDelete.id)
setPendingDelete(null) setPendingDelete(null)
await onRefresh() await onRefresh()
toast.success(`Deleted ${email}`)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null) setPendingDelete(null)
toast.error(errorMessage(err, `delete ${email}`))
} }
}} }}
/> />
@@ -467,11 +464,11 @@ function UsersPanel({
state={editor} state={editor}
roles={roles} roles={roles}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onSaved={async () => { onSaved={async (message) => {
setEditor(null) setEditor(null)
await onRefresh() await onRefresh()
toast.success(message)
}} }}
onError={onError}
/> />
<UserDetailSheet <UserDetailSheet
@@ -504,11 +501,10 @@ function userRowActions(
setEditor: (s: { mode: "edit"; user: User } | null) => void setEditor: (s: { mode: "edit"; user: User } | null) => void
setPendingDelete: (u: User | null) => void setPendingDelete: (u: User | null) => void
setDetailUser: (u: User | null) => void setDetailUser: (u: User | null) => void
setError: (msg: string | null) => void toast: ReturnType<typeof useToast>
setInfo: (msg: string | null) => void
}, },
): ActionItem[] { ): ActionItem[] {
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, setError, setInfo } = ctx const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, toast } = ctx
const items: ActionItem[] = [] const items: ActionItem[] = []
items.push({ items.push({
@@ -535,10 +531,10 @@ function userRowActions(
onSelect: async () => { onSelect: async () => {
try { try {
await setUserStatus(arcadia, u.id, "suspended") await setUserStatus(arcadia, u.id, "suspended")
setInfo(`${u.email} suspended.`)
await refresh() await refresh()
toast.success(`Suspended ${u.email}`)
} catch (err) { } 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 () => { onSelect: async () => {
try { try {
await setUserStatus(arcadia, u.id, "active") await setUserStatus(arcadia, u.id, "active")
setInfo(`${u.email} activated.`)
await refresh() await refresh()
toast.success(`Activated ${u.email}`)
} catch (err) { } catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Activate failed.") toast.error(errorMessage(err, `activate ${u.email}`))
} }
}, },
}) })
@@ -577,13 +573,11 @@ function UserEditorDialog({
roles, roles,
onClose, onClose,
onSaved, onSaved,
onError,
}: { }: {
state: { mode: "create" } | { mode: "edit"; user: User } | null state: { mode: "create" } | { mode: "edit"; user: User } | null
roles: Role[] roles: Role[]
onClose: () => void onClose: () => void
onSaved: () => Promise<void> onSaved: (message: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const open = state !== null const open = state !== null
@@ -597,9 +591,14 @@ function UserEditorDialog({
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<string>>(new Set()) const [selectedRoleIds, setSelectedRoleIds] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false) 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(() => { useEffect(() => {
if (!open) return if (!open) return
setError(null)
setSaving(false)
if (initial) { if (initial) {
setEmail(initial.email) setEmail(initial.email)
setFirstName(initial.first_name ?? "") setFirstName(initial.first_name ?? "")
@@ -627,7 +626,7 @@ function UserEditorDialog({
} }
const submit = async () => { const submit = async () => {
onError(null) setError(null)
setSaving(true) setSaving(true)
try { try {
const input: UserInput = { const input: UserInput = {
@@ -637,18 +636,18 @@ function UserEditorDialog({
status, status,
role_ids: Array.from(selectedRoleIds), role_ids: Array.from(selectedRoleIds),
} }
if (!isEdit && password) input.password = password if (password) input.password = password
else if (isEdit && password) input.password = password
if (isEdit && initial) { if (isEdit && initial) {
await updateUser(arcadia, initial.id, input) await updateUser(arcadia, initial.id, input)
await onSaved(`Saved ${initial.email}`)
} else { } else {
await createUser(arcadia, input) await createUser(arcadia, input)
await onSaved(`Created ${email.trim()}`)
} }
await onSaved()
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.") // Keep the dialog open and the form intact so it can be fixed and resubmitted.
} finally { setError(err)
setSaving(false) setSaving(false)
} }
} }
@@ -759,6 +758,10 @@ function UserEditorDialog({
</div> </div>
</div> </div>
{error ? (
<DialogError error={error} context={isEdit ? "save the user" : "create the user"} />
) : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="user-form-cancel"> <Button variant="outline" onClick={onClose} disabled={saving} data-action="user-form-cancel">
Cancel Cancel
@@ -783,18 +786,17 @@ function InvitationsPanel({
invitations, invitations,
roles, roles,
loading, loading,
error,
onRefresh, onRefresh,
onError,
onInfo,
}: { }: {
invitations: Invitation[] invitations: Invitation[]
roles: Role[] roles: Role[]
loading: boolean loading: boolean
error: unknown
onRefresh: () => Promise<void> onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [inviteOpen, setInviteOpen] = useState(false) const [inviteOpen, setInviteOpen] = useState(false)
const [pendingRevoke, setPendingRevoke] = useState<Invitation | null>(null) const [pendingRevoke, setPendingRevoke] = useState<Invitation | null>(null)
@@ -850,15 +852,14 @@ function InvitationsPanel({
arcadia, arcadia,
refresh: onRefresh, refresh: onRefresh,
setPendingRevoke, setPendingRevoke,
setError: onError, toast,
setInfo: onInfo,
})} })}
triggerDataAction={`invitation-${i.id}-actions`} triggerDataAction={`invitation-${i.id}-actions`}
/> />
), ),
}, },
], ],
[arcadia, onError, onInfo, onRefresh], [arcadia, onRefresh, toast],
) )
const table = useTable<Invitation>({ const table = useTable<Invitation>({
@@ -906,39 +907,43 @@ function InvitationsPanel({
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay active={loading && invitations.length === 0} label="Loading invitations…" /> <DataState
{table.total === 0 && !loading ? ( loading={loading}
<EmptyState error={error}
title={search ? "No invitations match." : "No invitations yet."} isEmpty={table.total === 0}
description={ onRetry={onRefresh}
search loadingLabel="Loading invitations…"
? "Try a different search." empty={
: roles.length === 0 <EmptyState
? "Create a role first, then invite users." title={search ? "No invitations match." : "No invitations yet."}
: "Invite your first user." description={
} search
className="py-12" ? "Try a different search."
: roles.length === 0
? "Create a role first, then invite users."
: "Invite your first user."
}
className="py-12"
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(i) => i.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && invitations.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={table.page}
<DataTable pageSize={table.pageSize}
columns={columns} total={table.total}
rows={table.pageRows} onPageChange={table.setPage}
getRowId={(i) => i.id} onPageSizeChange={table.setPageSize}
sort={table.sort} />
onSortToggle={table.toggleSort} </DataState>
loading={loading && invitations.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
<ConfirmDialog <ConfirmDialog
@@ -947,21 +952,22 @@ function InvitationsPanel({
title="Revoke invitation?" title="Revoke invitation?"
description={ description={
pendingRevoke 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" confirmLabel="Revoke"
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingRevoke) return if (!pendingRevoke) return
const email = pendingRevoke.email
try { try {
await revokeInvitation(arcadia, pendingRevoke.id) await revokeInvitation(arcadia, pendingRevoke.id)
setPendingRevoke(null) setPendingRevoke(null)
onInfo("Invitation revoked.")
await onRefresh() await onRefresh()
toast.success(`Revoked the invitation to ${email}`)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Revoke failed.")
setPendingRevoke(null) setPendingRevoke(null)
toast.error(errorMessage(err, `revoke the invitation to ${email}`))
} }
}} }}
/> />
@@ -970,12 +976,13 @@ function InvitationsPanel({
open={inviteOpen} open={inviteOpen}
roles={roles} roles={roles}
onClose={() => setInviteOpen(false)} onClose={() => setInviteOpen(false)}
onSent={async () => { onSent={async (email) => {
setInviteOpen(false) setInviteOpen(false)
onInfo("Invitation sent.")
await onRefresh() await onRefresh()
toast.success(`Invitation sent to ${email}`, {
description: "They'll pick their own password when they accept.",
})
}} }}
onError={onError}
/> />
</Card> </Card>
) )
@@ -994,11 +1001,10 @@ function invitationRowActions(
arcadia: ReturnType<typeof useArcadiaClient> arcadia: ReturnType<typeof useArcadiaClient>
refresh: () => Promise<void> refresh: () => Promise<void>
setPendingRevoke: (i: Invitation | null) => void setPendingRevoke: (i: Invitation | null) => void
setError: (msg: string | null) => void toast: ReturnType<typeof useToast>
setInfo: (msg: string | null) => void
}, },
): ActionItem[] { ): ActionItem[] {
const { arcadia, refresh, setPendingRevoke, setError, setInfo } = ctx const { arcadia, refresh, setPendingRevoke, toast } = ctx
const status = invitationStatus(inv) const status = invitationStatus(inv)
const items: ActionItem[] = [] const items: ActionItem[] = []
@@ -1011,10 +1017,10 @@ function invitationRowActions(
onSelect: async () => { onSelect: async () => {
try { try {
await resendInvitation(arcadia, inv.id) await resendInvitation(arcadia, inv.id)
setInfo(`Resent invitation to ${inv.email}.`)
await refresh() await refresh()
toast.success(`Resent the invitation to ${inv.email}`)
} catch (err) { } 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, roles,
onClose, onClose,
onSent, onSent,
onError,
}: { }: {
open: boolean open: boolean
roles: Role[] roles: Role[]
onClose: () => void onClose: () => void
onSent: () => Promise<void> onSent: (email: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [roleId, setRoleId] = useState<string>("") const [roleId, setRoleId] = useState<string>("")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setEmail("") setEmail("")
setRoleId(roles[0]?.id ?? "") setRoleId(roles[0]?.id ?? "")
setError(null)
setSaving(false)
} else { } else {
setRoleId((prev) => prev || roles[0]?.id || "") setRoleId((prev) => prev || roles[0]?.id || "")
} }
}, [open, roles]) }, [open, roles])
const submit = async () => { const submit = async () => {
onError(null) setError(null)
setSaving(true) setSaving(true)
try { try {
await createInvitation(arcadia, { email, role_id: roleId }) await createInvitation(arcadia, { email, role_id: roleId })
await onSent() await onSent(email.trim())
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Invite failed.") setError(err)
} finally {
setSaving(false) setSaving(false)
} }
} }
@@ -1113,6 +1119,8 @@ function InviteDialog({
</div> </div>
</div> </div>
{error ? <DialogError error={error} context="send the invitation" /> : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="invite-form-cancel"> <Button variant="outline" onClick={onClose} disabled={saving} data-action="invite-form-cancel">
Cancel Cancel
@@ -1136,17 +1144,16 @@ function InviteDialog({
function RolesPanel({ function RolesPanel({
roles, roles,
loading, loading,
error,
onRefresh, onRefresh,
onError,
onInfo,
}: { }: {
roles: Role[] roles: Role[]
loading: boolean loading: boolean
error: unknown
onRefresh: () => Promise<void> onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const toast = useToast()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; role: Role } | null>(null) const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; role: Role } | null>(null)
const [pendingDelete, setPendingDelete] = useState<Role | null>(null) const [pendingDelete, setPendingDelete] = useState<Role | null>(null)
@@ -1253,33 +1260,37 @@ function RolesPanel({
</CardHeader> </CardHeader>
<CardContent className="relative p-0"> <CardContent className="relative p-0">
<LoadingOverlay active={loading && roles.length === 0} label="Loading roles…" /> <DataState
{table.total === 0 && !loading ? ( loading={loading}
<EmptyState error={error}
title={search ? "No roles match." : "No roles yet."} isEmpty={table.total === 0}
description={search ? "Try a different search." : "Create your first role."} onRetry={onRefresh}
className="py-12" 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}
getRowId={(r) => r.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && roles.length > 0}
stickyHeader
/> />
) : ( <Pagination
<> page={table.page}
<DataTable pageSize={table.pageSize}
columns={columns} total={table.total}
rows={table.pageRows} onPageChange={table.setPage}
getRowId={(r) => r.id} onPageSizeChange={table.setPageSize}
sort={table.sort} />
onSortToggle={table.toggleSort} </DataState>
loading={loading && roles.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent> </CardContent>
<ConfirmDialog <ConfirmDialog
@@ -1288,21 +1299,22 @@ function RolesPanel({
title="Delete role?" title="Delete role?"
description={ description={
pendingDelete 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" confirmLabel="Delete"
variant="danger" variant="danger"
onConfirm={async () => { onConfirm={async () => {
if (!pendingDelete) return if (!pendingDelete) return
const name = pendingDelete.name
try { try {
await deleteRole(arcadia, pendingDelete.id) await deleteRole(arcadia, pendingDelete.id)
setPendingDelete(null) setPendingDelete(null)
onInfo("Role deleted.")
await onRefresh() await onRefresh()
toast.success(`Deleted ${name}`)
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null) setPendingDelete(null)
toast.error(errorMessage(err, `delete ${name}`))
} }
}} }}
/> />
@@ -1310,11 +1322,11 @@ function RolesPanel({
<RoleEditorDialog <RoleEditorDialog
state={editor} state={editor}
onClose={() => setEditor(null)} onClose={() => setEditor(null)}
onSaved={async () => { onSaved={async (message) => {
setEditor(null) setEditor(null)
await onRefresh() await onRefresh()
toast.success(message)
}} }}
onError={onError}
/> />
</Card> </Card>
) )
@@ -1351,12 +1363,10 @@ function RoleEditorDialog({
state, state,
onClose, onClose,
onSaved, onSaved,
onError,
}: { }: {
state: { mode: "create" } | { mode: "edit"; role: Role } | null state: { mode: "create" } | { mode: "edit"; role: Role } | null
onClose: () => void onClose: () => void
onSaved: () => Promise<void> onSaved: (message: string) => Promise<void>
onError: (msg: string | null) => void
}) { }) {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const open = state !== null const open = state !== null
@@ -1369,9 +1379,12 @@ function RoleEditorDialog({
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [permissionsText, setPermissionsText] = useState("") const [permissionsText, setPermissionsText] = useState("")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setError(null)
setSaving(false)
if (initial) { if (initial) {
setName(initial.name) setName(initial.name)
setSlug(initial.slug) setSlug(initial.slug)
@@ -1386,7 +1399,7 @@ function RoleEditorDialog({
}, [open, initial]) }, [open, initial])
const submit = async () => { const submit = async () => {
onError(null) setError(null)
setSaving(true) setSaving(true)
try { try {
const permissions = permissionsText const permissions = permissionsText
@@ -1394,12 +1407,15 @@ function RoleEditorDialog({
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
const input: RoleInput = { name, slug, description: description || null, permissions } const input: RoleInput = { name, slug, description: description || null, permissions }
if (isEdit && initial) await updateRole(arcadia, initial.id, input) if (isEdit && initial) {
else await createRole(arcadia, input) await updateRole(arcadia, initial.id, input)
await onSaved() await onSaved(`Saved ${name.trim()}`)
} else {
await createRole(arcadia, input)
await onSaved(`Created ${name.trim()}`)
}
} catch (err) { } catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.") setError(err)
} finally {
setSaving(false) setSaving(false)
} }
} }
@@ -1468,6 +1484,10 @@ function RoleEditorDialog({
</div> </div>
</div> </div>
{error ? (
<DialogError error={error} context={isEdit ? "save the role" : "create the role"} />
) : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} data-action="role-form-cancel"> <Button variant="outline" onClick={onClose} data-action="role-form-cancel">
{readOnly ? "Close" : "Cancel"} {readOnly ? "Close" : "Cancel"}
@@ -1497,3 +1517,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return acc return acc
}, {}) }, {})
} }
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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