ui: confirming an alert dialog should close it #6

Open
jules wants to merge 7 commits from fix/alert-dialog-action-closes into main
97 changed files with 7181 additions and 4708 deletions

3
.gitignore vendored
View File

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

View File

@@ -7,9 +7,9 @@ This file is a quick map, not a duplication of upstream docs.
## What Arcadia Admin is
- **Arcadia Admin** is the operator/admin UI for [arcadia-core](../reference/arcadia-app), a multi-tenant Phoenix backend. Surfaces tenant management, user/role admin, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
- **Arcadia Admin** is the operator/admin UI for [arcadia-core](../reference/arcadia-core), a multi-tenant Phoenix backend. Surfaces tenant management, user/role admin, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
- **Cloned from** [Vibespace](../vibespace) — the starter for webapps in this style. Vibespace and Skyrise are the upstream sources of truth for the shell and the theme; don't backport arcadia-admin-specific changes into Vibespace unless they're broadly applicable.
- **Backend reference** lives at `../reference/arcadia-app/`. Treat it as read-only documentation — it's the Phoenix umbrella app that owns the OpenAPI spec, controllers, schemas, and seed data. Spec is regenerated from a running arcadia at `http://localhost:4000/api/openapi` via `node ../lib-arcadia-client/scripts/sync-spec.mjs` (run from this directory).
- **Backend reference** lives at `../reference/arcadia-core/`. Treat it as read-only documentation — it's the Phoenix umbrella app that owns the OpenAPI spec, controllers, schemas, and seed data. Spec is regenerated from a running arcadia at `http://localhost:4000/api/openapi` via `node ../lib-arcadia-core-client/scripts/sync-spec.mjs` (run from this directory).
- **Skyrise** (`lib-theme-skyrise`) is the canonical theme — premium AI-first glass, iridescent body, vivid text, Apple-spring motion. Theme tweaks belong upstream in Vibespace + Skyrise, not here.
- The brand string lives in **one place**: `app/lib/identity.ts` (`useBrand()` / `getBrand()`). Don't hardcode "Arcadia Admin" in components, page titles, or copy.
@@ -26,7 +26,7 @@ This file is a quick map, not a duplication of upstream docs.
- `npm run dev` — Vite dev server (React Router 7).
- `npm run build` — production build (`react-router build`).
- `npm run start` — serve the built app (`react-router-serve ./build/server/index.js`).
- `npm run typecheck``react-router typegen && tsc`. See gotcha below; may crash.
- `npm run typecheck``react-router typegen && tsc`. **Works** (2026-07-14). Run it before every commit.
- `start.sh` / `stop.sh` — repo's preferred way to run/stop the dev server in the background.
- `npm run test` — Vitest run (vibespace-inherited setup; jsdom + @testing-library/react).
@@ -141,6 +141,7 @@ This repo was scaffolded from `create-crema-app`, which patches marker comments.
## Known gotchas
- `npm run typecheck` may crash with a TypeScript internal error — pre-existing in the Crema toolchain. There's no test runner here, so rely on careful reads + dev server.
- `npm run typecheck` **does not crash** — verified 2026-07-14. The old "it crashes, rely on careful reads" note was stale, and it cost us: a route shipped using `Input`/`Textarea` without importing them, which `tsc` reports instantly as TS2304 but nobody was running it. It currently reports ~39 pre-existing errors in `app/` (mostly `TS2322` prop mismatches) and more in sibling libs; treat *new* errors as blocking even while that backlog stands.
- Every route that renders `<AppShell>` re-exports a shared route-level error boundary (`app/components/route-error.tsx`). Keep that export when adding routes — without it, one crashing panel replaces the entire console with an unstyled stack trace and strands the operator with no nav.
- Vite "Outdated Optimize Dep" 504s after editing `vite.config.ts` or `tsconfig.json`: stop dev, `rm -rf node_modules/.vite`, restart, hard-reload.
- After editing a sibling lib's exports, the dev server sometimes needs a manual restart to pick up the new types.

40
PRODUCT.md Normal file
View File

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

View File

@@ -1,6 +1,6 @@
# Arcadia Admin
Admin webapp for [arcadia-core](../reference/arcadia-app) — the multi-tenant Phoenix backend. Built on the [Crema design system](https://git.sky-ai.com/CremaUIStudio) with the **Skyrise** theme and started from the [Vibespace](../vibespace) starter.
Admin webapp for [arcadia-core](../reference/arcadia-core) — the multi-tenant Phoenix backend. Built on the [Crema design system](https://git.sky-ai.com/CremaUIStudio) with the **Skyrise** theme and started from the [Vibespace](../vibespace) starter.
Surfaces tenant management, user/role administration, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
@@ -15,7 +15,7 @@ Open [http://localhost:5173](http://localhost:5173). The app talks to arcadia at
To use it for real:
1. Have arcadia running locally (see `../reference/arcadia-app/DEV_SETUP.md`).
1. Have arcadia running locally (see `../reference/arcadia-core/DEV_SETUP.md`).
2. Visit `/login` and sign in with admin credentials. In dev seeds: `admin@example.com` / `AdminP@ssw0rd` (tenant `default`).
## Configuration
@@ -33,7 +33,7 @@ To use it for real:
`app/components/layout/app-shell.tsx` — left rail + appbar + avatar dropdown. Brand identity in `app/lib/identity.ts` (`name: "Arcadia Admin"`, icon: `Shield`). The shell is **template code, not a lib** — fork it freely as admin features are added.
### Arcadia client + auth UI
- [`@crema/arcadia-client`](../lib-arcadia-client) — typed HTTP client (generic + openapi-fetch-backed `client.typed`), Phoenix Channels realtime, error normalization. Mounted at the root via `<ArcadiaProvider>`.
- [`@crema/arcadia-core-client`](../lib-arcadia-core-client) — typed HTTP client (generic + openapi-fetch-backed `client.typed`), Phoenix Channels realtime, error normalization. Mounted at the root via `<ArcadiaProvider>`.
- [`@crema/arcadia-auth-ui`](../lib-arcadia-auth-ui) — login / signup / password reset / 2FA forms, themed via Skyrise tokens. The `/login` route renders `<LoginForm>`.
### Skyrise theme
@@ -50,8 +50,8 @@ Surface tints (`body[data-surface="snow|stone|sage|slate"]`) and dark mode (`htm
your-workspace/
arcadia-admin/ ← this repo
vibespace/ ← starter that this was cloned from
reference/arcadia-app/ ← Phoenix backend (read-only reference)
lib-arcadia-client/
reference/arcadia-core/ ← Phoenix backend (read-only reference)
lib-arcadia-core-client/
lib-arcadia-auth-ui/
lib-action-bus/
lib-aifirst-ui/
@@ -84,4 +84,4 @@ your-workspace/
- [`docs/AI_FIRST.md`](docs/AI_FIRST.md) — command-bus / DSL system tour
- [`app/components/layout/THEME_CONTRACT.md`](app/components/layout/THEME_CONTRACT.md) — token contract every theme must satisfy
- `CLAUDE.md` — orientation for an LLM working in this repo
- `../reference/arcadia-app/` — backend (DEV_SETUP, controllers, OpenAPI source-of-truth)
- `../reference/arcadia-core/` — backend (DEV_SETUP, controllers, OpenAPI source-of-truth)

View File

@@ -15,7 +15,7 @@
@source "../../lib-aifirst-ui/src";
@source "../../lib-llm-ui/src";
@source "../../lib-action-bus/src";
@source "../../lib-arcadia-client/src";
@source "../../lib-arcadia-core-client/src";
@source "../../lib-arcadia-auth-ui/src";
@source "../../lib-table-ui/src";
@source "../../lib-search-ui/src";
@@ -156,3 +156,21 @@
}
}
/* Accessibility: honour a reduced-motion request. Skyrise leans on ambient
* drift (the aurora field), spring transitions, and looping keyframes; for
* anyone who asks for less motion, near-instant everything and freeze the
* decorative loops. This is the global guard the app previously lacked. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
[data-slot="aurora-field"] {
animation: none !important;
}
}

View File

@@ -0,0 +1,51 @@
import { useState } from "react"
import { Check, Copy } from "lucide-react"
/**
* A monospace id (UUID, slug, token name) that copies to the clipboard on
* click, with a brief checkmark. Raw ids are common in an admin console and
* useless if you can't get them into a support ticket or a CLI.
*/
export function CopyId({
value,
label,
className = "",
dataAction,
}: {
value: string
/** What's being copied, for the aria-label. Defaults to "id". */
label?: string
className?: string
dataAction?: string
}) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1200)
} catch {
// Clipboard blocked (insecure context / permissions) — no-op; the value
// is still selectable by hand.
}
}
return (
<button
type="button"
onClick={copy}
data-action={dataAction}
aria-label={`Copy ${label ?? "id"}`}
title="Copy"
className={`group inline-flex max-w-full items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-xs transition-colors hover:bg-accent ${className}`}
>
<span className="truncate">{value}</span>
{copied ? (
<Check className="size-3 shrink-0 text-emerald-500" />
) : (
<Copy className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
)}
</button>
)
}

View File

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

View File

@@ -18,7 +18,6 @@ import {
PanelLeftOpen,
User as UserIcon,
LogOut,
HelpCircle,
Menu,
Play,
HardDrive,
@@ -38,9 +37,9 @@ import {
Database,
Plug,
MessageSquare,
Eye,
LayoutGrid,
CreditCard,
Flag,
Eye,
// CREMA:NAV-ICONS
} from "lucide-react"
@@ -68,7 +67,7 @@ import {
PopoverTrigger,
} from "~/components/ui/popover"
import { profileInitials, useProfile } from "~/lib/profile"
import { signOut, useSession } from "~/lib/session"
import { endImpersonation, signOut, useSession } from "~/lib/session"
import { capabilityForPath, useCapabilities } from "~/lib/capabilities"
import {
addNotification,
@@ -76,7 +75,6 @@ import {
dismissAll,
markAllRead,
markRead,
seedIfEmpty,
unreadCount,
useNotifications,
} from "~/lib/notifications"
@@ -121,7 +119,10 @@ const pinnedTop: NavItem[] = [
]
// Pinned items render flat at the bottom of the rail, below all groups.
// Audit log is cross-cutting rather than owned by any one group, so it sits
// here next to Settings.
const pinnedBottom: NavItem[] = [
{ to: "/audit-log", icon: Activity, label: "Audit log" },
{ to: "/settings", icon: Settings, label: "Settings" },
]
@@ -132,9 +133,9 @@ const navGroups: NavGroup[] = [
icon: Building2,
items: [
{ to: "/tenants", icon: Building2, label: "Tenants" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/organizations", icon: Building, label: "Organizations" },
{ to: "/users", icon: UsersIcon, label: "Users" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
],
},
@@ -142,11 +143,9 @@ const navGroups: NavGroup[] = [
key: "billing",
label: "Billing",
icon: CreditCard,
items: [
{ to: "/apps", icon: LayoutGrid, label: "Apps" },
{ to: "/plan", icon: CreditCard, label: "Plan" },
{ to: "/entitlements", icon: Gauge, label: "Entitlements" },
],
// One item today (Plan/Entitlements/Apps collapsed here — none has a live
// endpoint yet). They split back into siblings under this group once wired.
items: [{ to: "/billing", icon: CreditCard, label: "Plan & usage" }],
},
{
key: "data",
@@ -156,17 +155,26 @@ const navGroups: NavGroup[] = [
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
],
},
{
key: "automation",
label: "Automation",
icon: Plug,
items: [
{ to: "/feature-flags", icon: Flag, label: "Feature flags" },
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/integrations", icon: Plug, label: "Integrations" },
],
},
{
key: "integrations",
label: "Integrations",
icon: Plug,
key: "infrastructure",
label: "Infrastructure",
icon: Network,
items: [
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/networking", icon: Network, label: "Networking" },
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
],
},
{
@@ -178,15 +186,6 @@ const navGroups: NavGroup[] = [
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
],
},
{
key: "observability",
label: "Observability",
icon: Eye,
items: [
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
{ to: "/activity", icon: Activity, label: "Audit log" },
],
},
{
key: "ai",
label: "AI & Search",
@@ -260,8 +259,12 @@ export function AppShell({
// short-circuit so a sign-out doesn't reduce the hook count and trip
// React's "rendered fewer hooks than expected" check.
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1"
if (typeof window === "undefined") return true
// Default to expanded on first run — an icon-only rail of ~18 pictograms is
// unreadable to anyone who hasn't memorised it. Collapse stays available and
// is remembered once chosen.
const stored = localStorage.getItem(SIDEBAR_KEY)
return stored === null ? true : stored === "1"
})
useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
@@ -357,7 +360,7 @@ export function AppShell({
data-slot="sidebar"
data-expanded={expanded ? "true" : "false"}
className={[
"sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard md:flex",
"sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard motion-reduce:transition-none md:flex",
expanded ? "w-60" : "w-16",
].join(" ")}
>
@@ -468,6 +471,7 @@ export function AppShell({
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<ImpersonationBanner />
{/* Mobile-only menu trigger, floating top-left of main */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
@@ -627,9 +631,6 @@ export function AppShell({
>
<Settings /> Settings
</DropdownMenuItem>
<DropdownMenuItem data-action="avatar-help">
<HelpCircle /> Help
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-signout"
@@ -655,7 +656,10 @@ export function AppShell({
one floating card in a sea of black. The floating actions
pill is fixed to the viewport edge and lives outside this
column, so it stays clear regardless of cap width. */}
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 p-6 [&>*:first-child]:lg:pr-72">
{/* pt-16 on mobile reserves a row for the floating hamburger (left)
and actions pill (right) so they never overlap the page H1; the
desktop rail/pill sit outside this column, so pt drops to p-6. */}
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 px-4 pb-6 pt-16 md:p-6 [&>*:first-child]:lg:pr-72">
<RouteGuard>{children}</RouteGuard>
</div>
</div>
@@ -692,6 +696,9 @@ function NavRow({
to={item.to}
end={item.end}
title={expanded ? undefined : item.label}
// When collapsed there's no visible label text, only an icon — give
// screen readers the name explicitly (title alone isn't reliably read).
aria-label={expanded ? undefined : item.label}
onClick={onNavigate}
data-action={`${prefix}${item.label.toLowerCase()}`}
className={({ isActive }) =>
@@ -790,15 +797,42 @@ function NotificationDispatcher() {
)
}
function ImpersonationBanner() {
const session = useSession()
const navigate = useNavigate()
if (!session?.impersonatedBy) return null
return (
<div
role="alert"
data-slot="impersonation-banner"
className="sticky top-0 z-40 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-amber-500/40 bg-amber-500/15 px-4 py-2 text-sm text-amber-900 backdrop-blur-sm dark:text-amber-200"
>
<span className="inline-flex items-center gap-1.5">
<Eye className="size-4" />
Viewing as <span className="font-semibold">{session.email}</span> actions you take
happen as this user.
</span>
<button
type="button"
data-action="impersonation-stop"
onClick={() => {
endImpersonation()
navigate("/users")
}}
className="rounded-md border border-amber-600/40 px-2 py-0.5 font-medium transition-colors hover:bg-amber-500/25"
>
Stop impersonating
</button>
</div>
)
}
function NotificationsBell() {
const items = useNotifications()
const unread = unreadCount(items)
const navigate = useNavigate()
useEffect(() => {
seedIfEmpty()
}, [])
return (
<Popover>
<PopoverTrigger

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import {
loadSettings as loadActiveSettings,
saveSettings as saveActiveSettings,

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

@@ -0,0 +1,67 @@
// Confirming an AlertDialog must close it AND run the action.
//
// AlertDialogAction used to be a plain <Button>, so the dialog just sat there
// after you confirmed ("Clear conversation" cleared the chat and left the modal
// on screen). Other call sites only looked right because the row they acted on
// unmounted underneath the open dialog. Both halves are asserted here so a
// future refactor can't quietly drop one.
import { act } from "react"
import { render, screen } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./alert-dialog"
function Harness({ onConfirm }: { onConfirm: () => void }) {
return (
<AlertDialog defaultOpen>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear this conversation?</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep it</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Clear</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
afterEach(() => vi.restoreAllMocks())
describe("AlertDialogAction", () => {
it("runs the action and closes the dialog", async () => {
const onConfirm = vi.fn()
render(<Harness onConfirm={onConfirm} />)
expect(screen.getByText("Clear this conversation?")).toBeTruthy()
await act(async () => {
screen.getByRole("button", { name: "Clear" }).click()
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(screen.queryByText("Clear this conversation?")).toBeNull()
})
it("cancelling closes without running the action", async () => {
const onConfirm = vi.fn()
render(<Harness onConfirm={onConfirm} />)
await act(async () => {
screen.getByRole("button", { name: "Keep it" }).click()
})
expect(onConfirm).not.toHaveBeenCalled()
expect(screen.queryByText("Clear this conversation?")).toBeNull()
})
})

View File

@@ -141,14 +141,24 @@ function AlertDialogDescription({
)
}
// Confirming closes the dialog, exactly as cancelling does. This was a plain
// <Button>, so the dialog stayed open after you confirmed — every call site
// already assumed otherwise, and the ones that looked fine only did because
// the thing they acted on (a row, a card) unmounted underneath them. The
// handler still runs: Base UI merges its close handler with the caller's
// onClick, so async work carries on behind the closed dialog.
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof Button>) {
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button
<AlertDialogPrimitive.Close
data-slot="alert-dialog-action"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)

View File

@@ -15,7 +15,7 @@ import {
X,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner, ConfirmDialog } from "@crema/feedback-ui"
import { Badge } from "~/components/ui/badge"

View File

@@ -5,7 +5,7 @@
// Each tool is a named function with documented args. The LLM never sees
// raw HTTP — only the menu below.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
import {
createToolRuntime,
type ToolDef,
@@ -53,7 +53,7 @@ const docsClient = createRAGClient("/docs-index.json")
// tooling and wire it through `.env.local`.
// 3. operator session JWT — works only when arcadia-search shares the
// JWT signing secret with the arcadia issuing the operator's session
// (i.e. local arcadia-app + local arcadia-search with matching keys).
// (i.e. local arcadia-core + local arcadia-search with matching keys).
// 4. "dev" literal — only accepted by AUTH_MODE=dev backends.
function readEnv(key: string): string | undefined {
if (typeof import.meta === "undefined") return undefined
@@ -667,7 +667,7 @@ const TOOLS: ToolDef<ToolCtx>[] = [
{
name: "search_docs",
description:
"Search the arcadia-app documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
"Search the arcadia-core documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
parameters: {
type: "object",
properties: {
@@ -715,7 +715,7 @@ const TOOLS: ToolDef<ToolCtx>[] = [
{
name: "search_kb",
description:
"Lexical (BM25) search over the arcadia-search Tantivy backend. Returns chunks with snippets + chunk_ids that can be passed to `read_chunk` to expand. Prefer this over `search_docs` (browser) when you need richer hits or when the content wouldn't be in the bundled docs.\n\nKnown corpora on the platform-admin tenant:\n- `docs` — arcadia-app architecture/ops docs (same as the browser RAG, server-hosted for parity).\n- `operator-tools` — arcadia-search + arcadia-admin documentation (admin sidecar, deploy script, search admin UI, MULTI_TENANT, RAG, AI_FIRST, LIBS, LLM_PROXY_CONTRACT).\n- `files` — markdown/text files uploaded by tenant users via arcadia-app.\n\nIf you're not sure what's available, call `list_search_corpora` first. Operators can add new corpora via the `/search` route.",
"Lexical (BM25) search over the arcadia-search Tantivy backend. Returns chunks with snippets + chunk_ids that can be passed to `read_chunk` to expand. Prefer this over `search_docs` (browser) when you need richer hits or when the content wouldn't be in the bundled docs.\n\nKnown corpora on the platform-admin tenant:\n- `docs` — arcadia-core architecture/ops docs (same as the browser RAG, server-hosted for parity).\n- `operator-tools` — arcadia-search + arcadia-admin documentation (admin sidecar, deploy script, search admin UI, MULTI_TENANT, RAG, AI_FIRST, LIBS, LLM_PROXY_CONTRACT).\n- `files` — markdown/text files uploaded by tenant users via arcadia-core.\n\nIf you're not sure what's available, call `list_search_corpora` first. Operators can add new corpora via the `/search` route.",
parameters: {
type: "object",
properties: {

View File

@@ -1,5 +1,5 @@
// Domain primer baked into the assistant's system prompt so it understands
// what arcadia-app is, what platform admins do, and how the data model fits
// what arcadia-core is, what platform admins do, and how the data model fits
// together. Keep this tight — it costs context tokens on every turn.
export const ARCADIA_KNOWLEDGE = `Arcadia (the backend you administer):
@@ -31,7 +31,7 @@ Things to keep in mind when assisting:
- Writes are auditable. Suggest the user double-check tenant slug and impact before suspend/deactivate. Deactivate is harsher than suspend — only use when clearly intended.
- The operator can impersonate tenant users for debugging (POST /api/v1/admin/impersonate/:user_id) — surface this when they ask "why can't user X log in".
- Quotas / rate cards / billing config errors usually surface as 402/403 from /api/v1 endpoints — diagnose by checking the tenant's billing-config and api-metering quotas.
- The reference Phoenix app lives at \`reference/arcadia-app/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-client/scripts/sync-spec.mjs\`).
- The reference Phoenix app lives at \`reference/arcadia-core/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-core-client/scripts/sync-spec.mjs\`).
- Search admin (arcadia-search) is a separate service. Manage tenants/corpora at \`/search\`. Use \`list_search_corpora\` if you don't know what's indexed; \`rebuild_search_corpus\` after uploads or when results look stale; \`search_kb\` / \`read_chunk\` to query.
When the user asks something that maps to a tool, call it. When they ask about a concept, explain it from this primer in plain language. Write tools (suspend_tenant, activate_tenant) prompt the operator with an inline confirm card before they actually run — you do not need to ask in prose first; just call the tool and the user will see the confirmation UI. If the user denies a write, do not retry it; ask what they'd like to do differently.

View File

@@ -1,7 +1,7 @@
// Platform announcements helpers.
// Backend: /api/v1/admin/announcements (admin CRUD).
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type AnnouncementType =
| "info"

View File

@@ -4,7 +4,7 @@
// once — list/show endpoints only return the prefix. Callers must surface
// the value to the user immediately on create.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface ApiKey {
id: string

View File

@@ -1,7 +1,7 @@
// Audit log + observability helpers.
// All endpoints are read-only; the backend writes audit events itself.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type AuditSeverity = "info" | "warning" | "error" | "critical" | string

View File

@@ -0,0 +1,26 @@
// Billing — the plan catalogue (what plans tenants can be on). Per-tenant plan
// assignment lives on the tenant detail page; this is the platform-level view
// of what's on offer. Backend: /api/v1/billing/plans.
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface PlanMeter {
meter_key: string
included_units: number | null
overage_price_cents: number | null
}
export interface Plan {
name: string
slug: string
description: string | null
meters: PlanMeter[]
pricing: unknown[]
billing_track: string
trial_days: number
}
export async function listPlans(arcadia: ArcadiaClient): Promise<Plan[]> {
const res = await arcadia.GET<{ data: Plan[] }>("/api/v1/billing/plans")
return res.data
}

View File

@@ -2,7 +2,7 @@
// Backend: /api/v1/platform/buckets/*. All operations require a
// storage_config_id pointing at a credential row in /api/v1/storage_configs.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface Bucket {
name: string

View File

@@ -2,7 +2,7 @@
// used by the avatar uploader. The full digital-objects API is much
// larger; add endpoints here as we wire more features.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface DigitalObject {
id: string

View File

@@ -0,0 +1,51 @@
// Platform-wide feature flags — the registry of flags that exist across the
// whole deployment. A tenant then overrides any of these under its detail page
// (see tenant-detail/feature-flags-tab). Backend: /api/v1/admin/feature-flags
// (platform_admin only).
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface PlatformFlag {
id: string
key: string
description: string | null
enabled_by_default: boolean
inserted_at?: string
updated_at?: string
}
export type PlatformFlagInput = {
key?: string
description?: string | null
enabled_by_default?: boolean
}
export async function listPlatformFlags(arcadia: ArcadiaClient): Promise<PlatformFlag[]> {
const res = await arcadia.GET<{ data: PlatformFlag[] }>("/api/v1/admin/feature-flags")
return res.data
}
export async function createPlatformFlag(
arcadia: ArcadiaClient,
input: PlatformFlagInput,
): Promise<PlatformFlag> {
const res = await arcadia.POST<{ data: PlatformFlag }>("/api/v1/admin/feature-flags", {
body: { flag: input },
})
return res.data
}
export async function updatePlatformFlag(
arcadia: ArcadiaClient,
id: string,
input: PlatformFlagInput,
): Promise<PlatformFlag> {
const res = await arcadia.PUT<{ data: PlatformFlag }>(`/api/v1/admin/feature-flags/${id}`, {
body: { flag: input },
})
return res.data
}
export async function deletePlatformFlag(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/feature-flags/${id}`)
}

View File

@@ -2,9 +2,9 @@
//
// Backed by /api/v1/health* (public — no auth). Each subsystem is probed
// independently; the overall endpoint aggregates and returns 503 if any
// subsystem is not "ok". See arcadia-app commit f427892.
// subsystem is not "ok". See arcadia-core commit f427892.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type HealthSubsystem = "api" | "db" | "workers" | "storage"

View File

@@ -4,7 +4,7 @@
// arcadia-console's tenant surface); this file just exposes operator-idiomatic
// names so the page reads naturally.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
import {
createIntegrationsApi,
type CredentialInput,

View File

@@ -1,7 +1,7 @@
// Arcadia invitations API helpers.
// Backed by /api/v1/invitations.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface InvitationRole {
id: string

View File

@@ -8,7 +8,7 @@
// `tenant_id: null` configurations are platform-defaults visible to
// every tenant. Names are unique within (tenant, name).
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type LlmProvider = "openai" | "anthropic" | "deepseek" | "qwen" | "lmstudio"
@@ -124,7 +124,7 @@ export async function getCatalog(arcadia: ArcadiaClient): Promise<CatalogEntry[]
/**
* Compute cost in cents for a given input/output token count using a
* configuration's published rates. Mirrors `LlmConfiguration.compute_cost_cents/3`
* in arcadia-app — keep in sync.
* in arcadia-core — keep in sync.
*/
export function computeCostCents(
config: Pick<LlmConfiguration, "input_cost_per_million" | "output_cost_per_million">,

View File

@@ -1,13 +1,13 @@
// Arcadia LLM proxy client.
//
// Implements the spec in docs/LLM_PROXY_CONTRACT.md against arcadia-app's
// Implements the spec in docs/LLM_PROXY_CONTRACT.md against arcadia-core's
// POST /api/v1/ai/llm/chat. The lib (@crema/llm-providers-ui buildAdapter)
// owns the streaming chat path itself; this module exposes a lightweight
// non-streaming probe so the Settings "Test connection" button can verify
// the proxy round-trips end-to-end (auth → secret resolution → upstream
// dispatch → response shape).
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type LLMProxyProvider =
| "openai"

View File

@@ -1,7 +1,7 @@
// Tenant memberships — the M:N glue between users and tenants.
// Backend: /api/v1/admin/memberships (admin) + /api/v1/me/tenants (self).
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type MembershipStatus = "active" | "suspended" | "deactivated" | string

View File

@@ -2,7 +2,7 @@
// Wraps /api/v1/admin/monitoring/* + /api/v1/platform/* + a few observability
// endpoints used by the monitoring dashboard.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
// --- Rate limits ---------------------------------------------------------

View File

@@ -1,7 +1,7 @@
// Networking helpers: firewalls, VPCs, domains + DNS records, floating IPs.
// Backend: /api/v1/platform/{firewalls,vpcs,domains,floating_ips,...}.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
const BASE = "/api/v1/platform"

View File

@@ -5,7 +5,7 @@
// `OrganizationContext` plug, so the same per-org routes used by end-users
// are used here to mutate any org in the tenant.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type OrgStatus = "active" | "frozen" | "pending_deletion" | string
export type OnOwnerRemoval =

View File

@@ -3,7 +3,7 @@
// profile fields. The "profile" here is the per-tenant profile row, not
// the auth account.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface Profile {
id: string

View File

@@ -1,7 +1,7 @@
// Arcadia roles API helpers.
// Backed by /api/v1/roles (resources route, except :new and :edit).
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface Role {
id: string

View File

@@ -1,7 +1,7 @@
// Scheduled tasks (cron) helpers.
// Backend: /api/v1/admin/scheduled-tasks (CRUD + runs/enable/disable/trigger).
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type ScheduledTaskAction = "webhook" | "event"

View File

@@ -5,7 +5,7 @@
// exposed by these endpoints. Tenant-side resolution (returning the value)
// goes through a separate runtime endpoint that's not used by the admin UI.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type SecretCategory =
| "api_key"

View File

@@ -2,7 +2,7 @@
// Backend: /api/v1/sso/identity-providers (tenant CRUD) + /sessions.
// Note: certificates are large and write-only.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface IdentityProvider {
id: string

View File

@@ -1,7 +1,7 @@
// Status page helpers — components, incidents, subscribers.
// Backend: /api/v1/admin/status-page/* (admin CRUD).
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type ComponentStatus =
| "operational"

View File

@@ -8,7 +8,7 @@
// generic `arcadia.GET<T>` / `arcadia.POST<T>` / etc. — same pattern as
// `tenants.ts`. Switch to `arcadia.typed.*` when the spec gains coverage.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type StorageBackend = "s3" | "local" | "gcs"
export type StorageStatus = "active" | "inactive" | "degraded" | "maintenance"

View File

@@ -6,7 +6,7 @@
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
// and drop these manual types.
import type { ArcadiaClient } from "@crema/arcadia-client"
import { ArcadiaError, type ArcadiaClient } from "@crema/arcadia-core-client"
export type TenantStatus = "active" | "suspended" | "deactivated" | string
@@ -111,3 +111,427 @@ export async function provisionTenant(
})
return res.data
}
// ---------------------------------------------------------------------------
// Tenant detail — one screen per tab, all against /admin/tenants/:id/*.
// Enum values mirror arcadia-core's Tenant schema (tenant.ex) so the pickers
// only offer values the server will accept.
// ---------------------------------------------------------------------------
export const TENANT_PLANS = ["free", "starter", "professional", "enterprise", "custom"]
export const TENANT_LOCALES = ["en", "es", "fr", "de", "pt", "ja", "zh"]
export const TENANT_CURRENCIES = ["USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CNY"]
export const EMAIL_PROVIDERS = ["smtp", "sendgrid", "mailgun", "ses"] as const
export const SMS_PROVIDERS = ["twilio", "vonage", "messagebird"] as const
// A curated subset — the server accepts any IANA name, but a full 400-entry
// list is worse UX than the ones operators actually pick.
export const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Singapore",
"Australia/Sydney",
"Pacific/Auckland",
]
/** Rename the tenant. */
export async function updateTenant(
arcadia: ArcadiaClient,
id: string,
tenant: { name?: string },
): Promise<Tenant> {
const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}`, {
body: { tenant },
})
return res.data
}
export async function updateBranding(
arcadia: ArcadiaClient,
id: string,
branding: Partial<Omit<TenantBranding, "settings">>,
): Promise<Tenant> {
const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/branding`, {
body: { branding },
})
return res.data
}
export async function updateLocalization(
arcadia: ArcadiaClient,
id: string,
localization: Partial<Omit<TenantLocalization, "settings">>,
): Promise<Tenant> {
const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/localization`, {
body: { localization },
})
return res.data
}
/** The plan endpoint nests its params under `plan` and validates the slug
* against the server's plan list — pass one of `TENANT_PLANS`. */
export async function updatePlan(
arcadia: ArcadiaClient,
id: string,
plan: { plan: string; plan_limits?: Record<string, unknown> },
): Promise<Tenant> {
const res = await arcadia.PUT<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/plan`, {
body: { plan },
})
return res.data
}
// --- Quotas & usage ---
export interface QuotaConfig {
category: string
calls_per_minute: number | null
calls_per_day: number | null
calls_per_month: number | null
enabled: boolean
}
export interface UsageRow {
category: string
usage: { minute: number; day: number; month: number }
quota: {
enabled: boolean
calls_per_minute: number | null
calls_per_day: number | null
calls_per_month: number | null
}
}
export async function listUsage(arcadia: ArcadiaClient, id: string): Promise<UsageRow[]> {
const res = await arcadia.GET<{ data: UsageRow[] }>(
`/api/v1/admin/tenants/${id}/api-metering/usage`,
)
return res.data
}
export async function upsertQuota(
arcadia: ArcadiaClient,
id: string,
category: string,
limits: Partial<Omit<QuotaConfig, "category">>,
): Promise<QuotaConfig> {
const res = await arcadia.PUT<{ data: QuotaConfig }>(
`/api/v1/admin/tenants/${id}/api-metering/quotas/${encodeURIComponent(category)}`,
{ body: limits },
)
return res.data
}
export async function deleteQuota(
arcadia: ArcadiaClient,
id: string,
category: string,
): Promise<void> {
await arcadia.DELETE(
`/api/v1/admin/tenants/${id}/api-metering/quotas/${encodeURIComponent(category)}`,
)
}
// --- Feature flags ---
//
// The list is one row per *platform-defined* flag, overlaid with this tenant's
// override: `source` is "override" when the tenant pins a value, "default" when
// it inherits `enabled_by_default`. You can only override a flag that exists at
// the platform level — an override for an unknown key is stored but never shown
// (define platform flags under the platform Feature-flags screen). So an empty
// list means "no platform flags defined", not "no overrides".
export interface TenantFeatureFlag {
key: string
description: string | null
enabled: boolean
source: "override" | "default" | string
}
export async function listFeatureFlags(
arcadia: ArcadiaClient,
id: string,
): Promise<TenantFeatureFlag[]> {
const res = await arcadia.GET<{ data: TenantFeatureFlag[] }>(
`/api/v1/admin/tenants/${id}/feature-flags`,
)
return res.data
}
/** Pin `key` on/off for this tenant, overriding the platform default. */
export async function setFeatureFlag(
arcadia: ArcadiaClient,
id: string,
key: string,
enabled: boolean,
): Promise<void> {
await arcadia.PUT(
`/api/v1/admin/tenants/${id}/feature-flags/${encodeURIComponent(key)}`,
{ body: { enabled } },
)
}
/** Drop the tenant's override for `key`, reverting it to the platform default. */
export async function clearFeatureFlag(
arcadia: ArcadiaClient,
id: string,
key: string,
): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/feature-flags/${encodeURIComponent(key)}`)
}
// --- IP rules ---
export interface IpRule {
id: string
cidr: string
rule_type: "allow" | "deny" | string
description: string | null
enabled: boolean
}
export type IpRuleInput = {
cidr: string
rule_type: "allow" | "deny"
description?: string
enabled?: boolean
}
export async function listIpRules(arcadia: ArcadiaClient, id: string): Promise<IpRule[]> {
const res = await arcadia.GET<{ data: IpRule[] }>(`/api/v1/admin/tenants/${id}/ip-rules`)
return res.data
}
export async function createIpRule(
arcadia: ArcadiaClient,
id: string,
input: IpRuleInput,
): Promise<IpRule> {
const res = await arcadia.POST<{ data: IpRule }>(`/api/v1/admin/tenants/${id}/ip-rules`, {
body: input,
})
return res.data
}
export async function updateIpRule(
arcadia: ArcadiaClient,
id: string,
ruleId: string,
input: Partial<IpRuleInput>,
): Promise<IpRule> {
const res = await arcadia.PUT<{ data: IpRule }>(
`/api/v1/admin/tenants/${id}/ip-rules/${ruleId}`,
{ body: input },
)
return res.data
}
export async function deleteIpRule(
arcadia: ArcadiaClient,
id: string,
ruleId: string,
): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/ip-rules/${ruleId}`)
}
// --- Inbound webhooks ---
export interface InboundWebhookSource {
id: string
name: string
provider: string | null
signature_header: string | null
signature_algorithm: string
enabled: boolean
event_mappings?: Record<string, unknown>
metadata?: Record<string, unknown>
inserted_at?: string
}
export type InboundWebhookInput = {
name: string
provider?: string
signing_secret?: string
signature_header?: string
signature_algorithm?: string
enabled?: boolean
}
export interface InboundWebhookDelivery {
id: string
status?: string
received_at?: string
inserted_at?: string
[key: string]: unknown
}
export async function listInboundWebhooks(
arcadia: ArcadiaClient,
id: string,
): Promise<InboundWebhookSource[]> {
const res = await arcadia.GET<{ data: InboundWebhookSource[] }>(
`/api/v1/admin/tenants/${id}/inbound-webhooks`,
)
return res.data
}
export async function createInboundWebhook(
arcadia: ArcadiaClient,
id: string,
input: InboundWebhookInput,
): Promise<InboundWebhookSource> {
const res = await arcadia.POST<{ data: InboundWebhookSource }>(
`/api/v1/admin/tenants/${id}/inbound-webhooks`,
{ body: input },
)
return res.data
}
export async function updateInboundWebhook(
arcadia: ArcadiaClient,
id: string,
sourceId: string,
input: Partial<InboundWebhookInput>,
): Promise<InboundWebhookSource> {
const res = await arcadia.PUT<{ data: InboundWebhookSource }>(
`/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}`,
{ body: input },
)
return res.data
}
export async function deleteInboundWebhook(
arcadia: ArcadiaClient,
id: string,
sourceId: string,
): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}`)
}
export async function listInboundWebhookDeliveries(
arcadia: ArcadiaClient,
id: string,
sourceId: string,
): Promise<InboundWebhookDelivery[]> {
const res = await arcadia.GET<{ data: InboundWebhookDelivery[] }>(
`/api/v1/admin/tenants/${id}/inbound-webhooks/${sourceId}/deliveries`,
)
return res.data
}
// --- Email & SMS delivery config ---
// GET 404s when unconfigured; credentials are write-only (never returned).
export interface EmailConfig {
id: string
provider: (typeof EMAIL_PROVIDERS)[number] | string
from_email: string | null
from_name: string | null
reply_to: string | null
enabled: boolean
}
export type EmailConfigInput = {
provider: string
from_email?: string
from_name?: string
reply_to?: string
credentials?: Record<string, unknown>
enabled?: boolean
}
export interface SmsConfig {
id: string
provider: (typeof SMS_PROVIDERS)[number] | string
from_number: string | null
enabled: boolean
}
export type SmsConfigInput = {
provider: string
from_number?: string
credentials?: Record<string, unknown>
enabled?: boolean
}
export interface TestResult {
ok: boolean
message: string
}
/** Resolves to null when no config exists (the endpoint 404s), so callers can
* distinguish "unconfigured" from a real load failure. */
export async function getEmailConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<EmailConfig | null> {
return getOrNull<EmailConfig>(arcadia, `/api/v1/admin/tenants/${id}/email-config`)
}
export async function upsertEmailConfig(
arcadia: ArcadiaClient,
id: string,
input: EmailConfigInput,
): Promise<EmailConfig> {
const res = await arcadia.PUT<{ data: EmailConfig }>(
`/api/v1/admin/tenants/${id}/email-config`,
{ body: { email_config: input } },
)
return res.data
}
export async function deleteEmailConfig(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/email-config`)
}
export async function testEmailConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<TestResult> {
return arcadia.POST<TestResult>(`/api/v1/admin/tenants/${id}/email-config/test`)
}
export async function getSmsConfig(
arcadia: ArcadiaClient,
id: string,
): Promise<SmsConfig | null> {
return getOrNull<SmsConfig>(arcadia, `/api/v1/admin/tenants/${id}/sms-config`)
}
export async function upsertSmsConfig(
arcadia: ArcadiaClient,
id: string,
input: SmsConfigInput,
): Promise<SmsConfig> {
const res = await arcadia.PUT<{ data: SmsConfig }>(`/api/v1/admin/tenants/${id}/sms-config`, {
body: { sms_config: input },
})
return res.data
}
export async function deleteSmsConfig(arcadia: ArcadiaClient, id: string): Promise<void> {
await arcadia.DELETE(`/api/v1/admin/tenants/${id}/sms-config`)
}
export async function testSmsConfig(arcadia: ArcadiaClient, id: string): Promise<TestResult> {
return arcadia.POST<TestResult>(`/api/v1/admin/tenants/${id}/sms-config/test`)
}
/** GET that treats a 404 as "not configured yet" (null) rather than an error,
* and rethrows anything else so real failures still surface. */
async function getOrNull<T>(arcadia: ArcadiaClient, path: string): Promise<T | null> {
try {
const res = await arcadia.GET<{ data: T }>(path)
return res.data
} catch (err) {
if (err instanceof ArcadiaError && err.status === 404) return null
throw err
}
}

View File

@@ -1,6 +1,6 @@
// Per-user usage + quota helpers.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export interface UserUsage {
storage_used_bytes: number

View File

@@ -4,7 +4,7 @@
// describe these operations as typed paths, so we hand-roll types and use
// the generic verb methods on the client. Same pattern as tenants.ts.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type UserStatus = "active" | "inactive" | "suspended"
@@ -109,3 +109,41 @@ export async function setUserStatus(
): Promise<User> {
return updateUser(arcadia, id, { status })
}
// --- Impersonation ---
// The operator (platform/tenant admin) can act as another user for support.
// `startImpersonation` returns a token scoped to the target; the client swaps
// to it (see session.beginImpersonation). Stopping is a client-side restore of
// the operator's parked session — the /stop endpoint can't be reached with the
// impersonation token (it lacks admin), so we don't rely on it.
export interface CanImpersonate {
can_impersonate: boolean
user: Pick<User, "id" | "email" | "status"> & { first_name?: string; last_name?: string }
}
export interface ImpersonationToken {
access_token: string
impersonated_by: string
expires_in: number
}
export async function canImpersonate(
arcadia: ArcadiaClient,
userId: string,
): Promise<CanImpersonate> {
const res = await arcadia.GET<{ data: CanImpersonate }>(
`/api/v1/admin/impersonate/${userId}/can-impersonate`,
)
return res.data
}
export async function startImpersonation(
arcadia: ArcadiaClient,
userId: string,
): Promise<ImpersonationToken> {
const res = await arcadia.POST<{ data: ImpersonationToken }>(
`/api/v1/admin/impersonate/${userId}`,
)
return res.data
}

View File

@@ -1,7 +1,7 @@
// Outbound webhook helpers.
// Backend: /api/v1/webhooks (CRUD + pause/resume/regenerate-secret/deliveries/stats/test).
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { ArcadiaClient } from "@crema/arcadia-core-client"
export type WebhookStatus = "active" | "paused" | "disabled"
export type WebhookRetryStrategy = "linear" | "exponential"

View File

@@ -39,6 +39,7 @@ export type Capability =
| "platform.search"
| "platform.ai"
| "platform.integrations" // external-API registry (keys/budgets) on the gateway
| "platform.feature_flags"
// Special — always-on; not gated.
| "always.assistant"
| "always.profile"
@@ -84,6 +85,7 @@ const PLATFORM_ADMIN_CAPS: Capability[] = [
"platform.search",
"platform.ai",
"platform.integrations",
"platform.feature_flags",
]
const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"]
@@ -114,11 +116,12 @@ export const ROUTE_CAPABILITY: Record<string, Capability> = {
"/memberships": "tenant.memberships",
"/storage": "tenant.storage",
"/buckets": "tenant.buckets",
"/activity": "tenant.activity",
"/audit-log": "tenant.activity",
"/activity": "tenant.activity", // legacy path → redirects to /audit-log
"/settings": "tenant.settings",
"/apps": "tenant.apps",
"/plan": "tenant.plan",
"/entitlements": "tenant.entitlements",
// Plan, Entitlements, and Apps collapsed into one Billing surface (Phase 3).
// They split back out under this same capability set once wired (Phase 5).
"/billing": "tenant.plan",
"/tenants": "platform.tenants",
"/organizations": "platform.organizations",
@@ -134,6 +137,7 @@ export const ROUTE_CAPABILITY: Record<string, Capability> = {
"/search": "platform.search",
"/ai": "platform.ai",
"/integrations": "platform.integrations",
"/feature-flags": "platform.feature_flags",
"/assistant": "always.assistant",
"/profile": "always.profile",

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

@@ -1,13 +1,13 @@
// Arcadia LLM-gateway client.
//
// The integration registry lives on arcadia-llm-gateway, not arcadia-app, so
// The integration registry lives on arcadia-llm-gateway, not arcadia-core, so
// it needs its own ArcadiaClient pointed at a different base URL. Everything
// else is identical to the arcadia-app client: the same access token (the
// gateway validates arcadia-app JWTs via the shared Guardian secret) and the
// else is identical to the arcadia-core client: the same access token (the
// gateway validates arcadia-core JWTs via the shared Guardian secret) and the
// same 401 cleanup. The gateway's CORS already allows localhost + any
// *.sky-ai.com origin, so the browser calls it directly.
import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-client"
import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-core-client"
const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015"

View File

@@ -11,7 +11,7 @@
// panel remains the place to switch between configs.
import { useEffect } from "react"
import { useArcadiaClient } from "@crema/arcadia-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import {
loadSettings,
saveSettings,

View File

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

View File

@@ -3,7 +3,7 @@
// immediately, without waiting for the user to navigate to /profile.
import { useEffect } from "react"
import { useArcadiaClient } from "@crema/arcadia-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { fetchDigitalObjectAsBlobUrl } from "~/lib/arcadia/digital-objects"
import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles"

View File

@@ -3,7 +3,7 @@
// routes after a successful arcadia API exchange. The shape here matches what
// AppShell + useUser expect.
import { useEffect, useSyncExternalStore } from "react"
import { useSyncExternalStore } from "react"
import { profileInitials } from "~/lib/profile"
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
@@ -27,9 +27,16 @@ export type Session = {
tenantSlug?: string
roles: string[]
availableTenants: AvailableTenant[]
// Set (to the operator's user id) when this session is an impersonation —
// derived from the JWT's `impersonated_by` claim. Drives the "Viewing as"
// banner and gates the normal identity chrome.
impersonatedBy?: string
}
const STORAGE_KEY = "crema.session"
// Where the operator's real session is parked while they impersonate someone,
// so Stop can restore it without another round-trip.
const IMPERSONATION_BACKUP_KEY = "crema.session.impersonation-backup"
const CHANGE_EVENT = "crema:session-change"
function readFromStorage(): Session | null {
@@ -66,6 +73,8 @@ function readFromStorage(): Session | null {
!!t && typeof (t as AvailableTenant).id === "string",
) as AvailableTenant[])
: [],
impersonatedBy:
typeof parsed.impersonatedBy === "string" ? parsed.impersonatedBy : undefined,
}
} catch {
return null
@@ -157,12 +166,118 @@ export function hasSession(): boolean {
return !!readFromStorage()
}
/** Build a Session from a bare access token (used for impersonation, where the
* server hands back a token but no user record — identity comes from the
* token's own claims). */
function sessionFromToken(token: string, fallbackEmail = "impersonated user"): Session {
const claims = decodeJwt(token) ?? {}
const email = typeof claims.email === "string" ? claims.email : fallbackEmail
// `sub` is "<user_id>:<tenant_id>" — take the user id.
const sub = typeof claims.sub === "string" ? claims.sub.split(":")[0] : ""
const availableTenants: AvailableTenant[] = Array.isArray(claims.available_tenants)
? (claims.available_tenants as AvailableTenantClaim[])
.filter((t) => t && typeof t.id === "string")
.map((t) => ({ id: t.id as string, slug: t.slug, name: t.name, roles: t.roles ?? [] }))
: []
return {
userId: (typeof claims.sub === "string" ? sub : "") || email,
name: email,
email,
token,
issuedAt: Date.now(),
tenantId: typeof claims.tenant_id === "string" ? claims.tenant_id : undefined,
tenantSlug: typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined,
roles: Array.isArray(claims.roles) ? (claims.roles as string[]) : [],
availableTenants,
impersonatedBy:
typeof claims.impersonated_by === "string" ? claims.impersonated_by : undefined,
}
}
/**
* Enter impersonation: park the operator's real session, then swap the active
* session + token to the impersonation token the server minted. Every
* subsequent request acts as the target user.
*/
export function beginImpersonation(impersonationToken: string): Session | null {
if (typeof window === "undefined") return null
const current = readFromStorage()
if (!current) return null
// Back up the operator's real session + API token so Stop can restore both.
const currentApiToken = sessionStorage.getItem("arcadia_access_token")
localStorage.setItem(
IMPERSONATION_BACKUP_KEY,
JSON.stringify({ session: current, apiToken: currentApiToken }),
)
const next = sessionFromToken(impersonationToken, current.email)
sessionStorage.setItem("arcadia_access_token", impersonationToken)
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
return next
}
/** True when an impersonation backup is parked (i.e. we can Stop). */
export function isImpersonating(): boolean {
if (typeof window === "undefined") return false
return !!localStorage.getItem(IMPERSONATION_BACKUP_KEY)
}
/**
* Leave impersonation: restore the operator's parked session + API token.
* Impersonation is stateless (just which token the client sends), so this is a
* pure client-side restore — no server round-trip needed.
*/
export function endImpersonation(): Session | null {
if (typeof window === "undefined") return null
const raw = localStorage.getItem(IMPERSONATION_BACKUP_KEY)
if (!raw) return null
try {
const { session, apiToken } = JSON.parse(raw) as {
session: Session
apiToken: string | null
}
if (apiToken) sessionStorage.setItem("arcadia_access_token", apiToken)
else sessionStorage.removeItem("arcadia_access_token")
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
localStorage.removeItem(IMPERSONATION_BACKUP_KEY)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
return session
} catch {
localStorage.removeItem(IMPERSONATION_BACKUP_KEY)
return null
}
}
// `useSyncExternalStore` demands a *referentially stable* snapshot: it must
// return the identical object until the underlying value genuinely changes.
// Keying the cache on the raw stored string gives us that for free — reparse
// only when the bytes differ.
//
// This used to keep a `cacheValid` flag that `useSession` cleared on mount,
// which meant the very next render reparsed storage and produced a brand-new
// Session object. Every `useEffect([session, …])` in the app then saw a
// "changed" session and refetched: three identical GETs per list screen, and
// enough request volume during navigation to trip arcadia's own rate limiter
// and greet the operator with 429 banners. The session had not changed at all.
let cached: Session | null = null
let cacheValid = false
let cachedRaw: string | null = null
let primed = false
function readRaw(): string | null {
if (typeof window === "undefined") return null
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
function subscribe(cb: () => void): () => void {
const onChange = () => {
cacheValid = false
// Force the next getSnapshot to reparse, then let React re-render.
primed = false
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
@@ -171,23 +286,25 @@ function subscribe(cb: () => void): () => void {
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Session | null {
if (!cacheValid) {
const raw = readRaw()
if (!primed || raw !== cachedRaw) {
cachedRaw = raw
// readFromStorage re-validates expiry and may clear the token; when it
// does, `raw` differs on the next read and we reparse again.
cached = readFromStorage()
cacheValid = true
primed = true
}
return cached
}
function getServerSnapshot(): Session | null {
return null
}
export function useSession(): Session | null {
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cacheValid = false
}, [])
return s
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
export function sessionInitials(session: Session | null): string {

View File

@@ -12,7 +12,7 @@ import "./app.css"
import { ToastProvider, Toaster } from "@crema/notification-ui"
import { CommandBusProvider } from "@crema/action-bus"
import { ArcadiaProvider } from "@crema/arcadia-client"
import { ArcadiaProvider } from "@crema/arcadia-core-client"
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
// CREMA:PROVIDERS-IMPORTS

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ import {
import { addLibraryItem } from "~/lib/library"
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import { pageTitle } from "~/lib/page-meta"
import { useArcadiaClient } from "@crema/arcadia-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
// Shape of a single hit returned by the `search_docs` tool. Defined here
// rather than imported from the lib because the tool wrapper in
@@ -2371,3 +2371,5 @@ function VoiceInputButton({
</button>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,174 @@
// Billing — the plan catalogue. Per-tenant plan assignment lives on each
// tenant's detail page (Plan & quotas tab); this is the platform view of what
// plans exist. Entitlements and Apps aren't wired to endpoints yet, so they're
// named honestly as still-to-come rather than given their own dead nav items.
import { useCallback, useEffect, useState } from "react"
import { Gauge, LayoutGrid, RefreshCw } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { DataState } from "~/components/data-state"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { EmptyState } from "@crema/feedback-ui"
import { listPlans, type Plan } from "~/lib/arcadia/billing"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Billing")
export default function BillingRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
setPlans(await listPlans(arcadia))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
return (
<AppShell>
<PageHeader
title="Billing"
description="The plans tenants can be placed on. Assign a plan to a tenant from its detail page."
actions={
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="billing-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
}
/>
<Card>
<CardHeader>
<CardTitle>Plan catalogue</CardTitle>
<CardDescription>
{plans.length} plan{plans.length === 1 ? "" : "s"} defined.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<DataState
loading={loading}
error={error}
isEmpty={plans.length === 0}
onRetry={refresh}
loadingLabel="Loading plans…"
empty={
<EmptyState
title="No plans defined"
description="Plans are created in arcadia-core. Once they exist, set a tenant's plan from its Plan & quotas tab."
className="py-12"
/>
}
>
<ul className="divide-y">
{plans.map((plan) => (
<li key={plan.slug} className="flex flex-col gap-1 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{plan.name}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{plan.slug}
</code>
<span className="text-xs text-muted-foreground">{plan.billing_track}</span>
{plan.trial_days > 0 ? (
<span className="text-xs text-muted-foreground">
· {plan.trial_days}-day trial
</span>
) : null}
</div>
{plan.description ? (
<p className="text-sm text-muted-foreground">{plan.description}</p>
) : null}
{plan.meters.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1.5">
{plan.meters.map((m) => (
<span
key={m.meter_key}
className="rounded-md border bg-card/40 px-2 py-0.5 text-xs text-muted-foreground"
>
{m.meter_key}
{m.included_units != null ? `: ${m.included_units} incl.` : ""}
</span>
))}
</div>
) : null}
</li>
))}
</ul>
</DataState>
</CardContent>
</Card>
<div className="grid gap-3 sm:grid-cols-2">
<ComingSoon
icon={Gauge}
title="Entitlements"
description="A tenant-rollup of metered allowances and usage. Per-tenant usage is on each tenant's Plan & quotas tab today; the platform rollup endpoint is pending."
/>
<ComingSoon
icon={LayoutGrid}
title="Apps"
description="Apps a tenant publishes and their per-app grants. Awaiting the catalog endpoint."
/>
</div>
</AppShell>
)
}
function ComingSoon({
icon: Icon,
title,
description,
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
}) {
return (
<div className="flex items-start gap-3 rounded-lg border bg-card/40 px-4 py-3">
<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">
<div className="flex items-center gap-2">
<p className="font-medium">{title}</p>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">
Soon
</span>
</div>
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
</div>
</div>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

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

View File

@@ -0,0 +1,365 @@
import { useCallback, useEffect, useState, type FormEvent } 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 { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { DataState, DialogError } from "~/components/data-state"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardHeader,
} 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 {
createPlatformFlag,
deletePlatformFlag,
listPlatformFlags,
updatePlatformFlag,
type PlatformFlag,
} from "~/lib/arcadia/feature-flags"
import { errorMessage } from "~/lib/errors"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Feature flags")
export default function FeatureFlagsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [flags, setFlags] = useState<PlatformFlag[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<PlatformFlag | null>(null)
const [pendingDelete, setPendingDelete] = useState<PlatformFlag | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const refresh = useCallback(async () => {
setError(null)
setLoading(true)
try {
setFlags(await listPlatformFlags(arcadia))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
if (session) refresh()
}, [session, refresh])
const toggleDefault = async (flag: PlatformFlag, next: boolean) => {
setBusy(flag.id)
setFlags((prev) =>
prev.map((f) => (f.id === flag.id ? { ...f, enabled_by_default: next } : f)),
)
try {
await updatePlatformFlag(arcadia, flag.id, { enabled_by_default: next })
toast.success(`${flag.key} defaults to ${next ? "on" : "off"}`)
} catch (err) {
setFlags((prev) => prev.map((f) => (f.id === flag.id ? flag : f)))
toast.error(errorMessage(err, `update ${flag.key}`))
} finally {
setBusy(null)
}
}
const remove = async () => {
if (!pendingDelete) return
const flag = pendingDelete
try {
await deletePlatformFlag(arcadia, flag.id)
setPendingDelete(null)
await refresh()
toast.success(`Deleted flag ${flag.key}`)
} catch (err) {
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${flag.key}`))
}
}
return (
<AppShell>
<PageHeader
title="Feature flags"
description="Platform-wide flags. Each defines a switch that every tenant inherits by default and can override from its own settings."
actions={
<>
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-action="feature-flags-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
data-action="feature-flags-create"
>
<Plus className="size-4" />
New flag
</Button>
</>
}
/>
<Card>
<CardHeader className="text-xs text-muted-foreground">
{flags.length} flag{flags.length === 1 ? "" : "s"}
</CardHeader>
<CardContent className="p-0">
<DataState
loading={loading}
error={error}
isEmpty={flags.length === 0}
onRetry={refresh}
loadingLabel="Loading feature flags…"
empty={
<EmptyState
title="No feature flags yet"
description="Create a flag to gate a feature across the platform. Tenants inherit its default and can override it per-tenant."
className="py-12"
/>
}
>
<ul className="divide-y">
{flags.map((flag) => (
<li key={flag.id} className="flex items-center gap-3 px-4 py-3">
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
setEditing(flag)
setEditorOpen(true)
}}
data-action={`feature-flag-${flag.key}-edit`}
>
<code className="font-mono text-sm">{flag.key}</code>
{flag.description ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{flag.description}
</p>
) : null}
</button>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Default</span>
<Switch
checked={flag.enabled_by_default}
onCheckedChange={(v) => toggleDefault(flag, v)}
disabled={busy === flag.id}
data-action={`feature-flag-${flag.key}-default`}
aria-label={`Default for ${flag.key}`}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPendingDelete(flag)}
aria-label={`Delete ${flag.key}`}
data-action={`feature-flag-${flag.key}-delete`}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
</DataState>
</CardContent>
</Card>
<FlagEditorDialog
open={editorOpen}
flag={editing}
onClose={() => setEditorOpen(false)}
onSaved={async (msg) => {
setEditorOpen(false)
await refresh()
toast.success(msg)
}}
/>
<ConfirmDialog
open={!!pendingDelete}
onOpenChange={(o) => !o && setPendingDelete(null)}
title="Delete feature flag?"
description={
pendingDelete
? `"${pendingDelete.key}" and every tenant's override of it are removed. Any code still reading this flag falls back to its built-in default.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={remove}
/>
</AppShell>
)
}
function FlagEditorDialog({
open,
flag,
onClose,
onSaved,
}: {
open: boolean
flag: PlatformFlag | null
onClose: () => void
onSaved: (message: string) => void
}) {
const arcadia = useArcadiaClient()
const isEdit = !!flag
const [key, setKey] = useState("")
const [description, setDescription] = useState("")
const [enabledByDefault, setEnabledByDefault] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (open) {
setKey(flag?.key ?? "")
setDescription(flag?.description ?? "")
setEnabledByDefault(flag?.enabled_by_default ?? false)
setError(null)
setSubmitting(false)
}
}, [open, flag])
const keyInvalid = key.length > 0 && !/^[a-z0-9_]+$/.test(key)
const canSubmit = !submitting && key.trim().length > 0 && !keyInvalid
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
if (isEdit) {
await updatePlatformFlag(arcadia, flag!.id, {
description: description.trim() || null,
enabled_by_default: enabledByDefault,
})
onSaved(`Updated flag ${flag!.key}`)
} else {
await createPlatformFlag(arcadia, {
key: key.trim(),
description: description.trim() || null,
enabled_by_default: enabledByDefault,
})
onSaved(`Created flag ${key.trim()}`)
}
} catch (err) {
setError(err)
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{isEdit ? "Edit flag" : "New feature flag"}</DialogTitle>
<DialogDescription>
{isEdit
? "The key is fixed once created — code references it."
: "The key is how code references this flag; it can't change later."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="flag-key">Key</Label>
<Input
id="flag-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="new_dashboard"
autoFocus={!isEdit}
disabled={isEdit}
className="font-mono"
data-action="feature-flag-form-key"
/>
<p className="text-xs text-muted-foreground">
{keyInvalid
? "Lowercase letters, digits, and underscores only."
: "Lowercase letters, digits, and underscores."}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="flag-description">Description</Label>
<Input
id="flag-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this flag controls"
data-action="feature-flag-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 by default</div>
<div className="text-xs text-muted-foreground">
Tenants inherit this unless they override it.
</div>
</div>
<Switch
checked={enabledByDefault}
onCheckedChange={setEnabledByDefault}
data-action="feature-flag-form-default"
/>
</div>
</div>
{error ? <DialogError error={error} context="save the flag" /> : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={submitting}
data-action="feature-flag-form-cancel"
>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit} data-action="feature-flag-form-save">
{submitting ? "Saving…" : isEdit ? "Save" : "Create flag"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -11,7 +11,7 @@ import {
Users as UsersIcon,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
@@ -37,6 +37,7 @@ import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
import { listUsers, type User } from "~/lib/arcadia/users"
import { useRegisterContext } from "@crema/aifirst-ui/context"
import { pageTitle } from "~/lib/page-meta"
import { errorMessage } from "~/lib/errors"
import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Overview")
@@ -56,7 +57,7 @@ export default function HomeRoute() {
const [data, setData] = useState<DashboardData>(EMPTY)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<unknown>(null)
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null)
const refresh = useCallback(async () => {
@@ -70,7 +71,7 @@ export default function HomeRoute() {
listAuditLogs(arcadia, { limit: 10 }),
getHealth(arcadia).catch(() => null),
]).catch((err) => {
setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.")
setError(err)
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
})
setData({ tenants, users, audit, health })
@@ -92,7 +93,7 @@ export default function HomeRoute() {
tenants: { total: data.tenants.length, active: activeTenants },
users: { total: data.users.length, active: activeUsers },
audit: { recent: data.audit.length, errors: errorEvents },
health: data.health?.status ?? "unconfigured",
health: deriveOverallStatus(data.health),
}
}, [data])
@@ -129,7 +130,7 @@ export default function HomeRoute() {
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
{errorMessage(error, "load the overview")}
</AlertBanner>
) : null}
@@ -408,6 +409,25 @@ function statusLabel(status: HealthStatus | string): string {
return "Unknown"
}
/**
* The overall health hero must never say "Unknown" while its own Subsystems
* card reports everything Healthy. When the backend gives no meaningful overall
* status (unknown/unconfigured/missing), derive it from the subsystem probes
* the same card shows: any error → Down, any degraded → Degraded, otherwise
* Healthy if at least one subsystem reported ok.
*/
function deriveOverallStatus(health: OverallHealth | null): HealthStatus | string {
if (!health) return "unconfigured"
if (health.status === "ok" || health.status === "degraded" || health.status === "error") {
return health.status
}
const statuses = Object.values(health.subsystems ?? {}).map((s) => s?.status)
if (statuses.some((s) => s === "error")) return "error"
if (statuses.some((s) => s === "degraded")) return "degraded"
if (statuses.some((s) => s === "ok")) return "ok"
return health.status ?? "unconfigured"
}
function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" {
if (status === "ok") return "ok"
if (status === "degraded") return "warning"
@@ -428,3 +448,5 @@ function timeAgo(iso: string): string {
const d = Math.round(hr / 24)
return `${d}d ago`
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

@@ -14,7 +14,7 @@ import {
Plus,
Trash2,
} from "lucide-react"
import { ArcadiaError } from "@crema/arcadia-client"
import { ArcadiaError } from "@crema/arcadia-core-client"
import { AppShell } from "~/components/layout/app-shell"
import { Badge } from "~/components/ui/badge"
@@ -630,3 +630,5 @@ function Field({
</div>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ import {
Zap,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner } from "@crema/feedback-ui"
import {
BarChart,
@@ -334,7 +334,15 @@ export default function MonitoringRoute() {
/>
<KpiTile
label="Disk (busiest mount)"
value={busiestDiskLabel(data.host.disks)}
// The percentage alone is the metric — keep it in `value` so it
// never clips. The mount path (which can be long) goes in the
// caption, truncated with the full path on hover.
value={busiestDiskValue(data.host.disks)}
caption={
<span className="block truncate" title={busiestDiskMount(data.host.disks)}>
{busiestDiskMount(data.host.disks)}
</span>
}
icon={<Database className="size-4" />}
tone={
busiestDiskPct(data.host.disks) > 90
@@ -691,10 +699,18 @@ function busiestDiskPct(disks: HostStats["disks"]): number {
return disks.reduce((m, d) => Math.max(m, d.used_pct), 0)
}
function busiestDiskLabel(disks: HostStats["disks"]): string {
if (disks.length === 0) return "—"
const busiest = disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a))
return `${busiest.used_pct}% (${busiest.mount})`
function busiestDisk(disks: HostStats["disks"]) {
if (disks.length === 0) return null
return disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a))
}
function busiestDiskValue(disks: HostStats["disks"]): string {
const d = busiestDisk(disks)
return d ? `${d.used_pct}%` : "—"
}
function busiestDiskMount(disks: HostStats["disks"]): string {
return busiestDisk(disks)?.mount ?? "no mounts"
}
function barColor(pct: number): string {
@@ -1210,3 +1226,5 @@ function severityColor(s: string): string {
if (s === "warning") return "#f59e0b"
return "#94a3b8"
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useState } from "react"
import { Check, RefreshCw, Trash2 } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { CopyId } from "~/components/copy-id"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
@@ -357,9 +358,11 @@ export default function ProfileRoute() {
</span>
{account ? (
<>
<span className="text-xs text-muted-foreground">
Tenant <code className="font-mono">{account.tenant_id}</code> ·
ID <code className="font-mono">{account.id}</code>
<span className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
Tenant
<CopyId value={account.tenant_id} label="tenant id" dataAction="profile-copy-tenant-id" />
· ID
<CopyId value={account.id} label="account id" dataAction="profile-copy-account-id" />
</span>
<span className="text-xs text-muted-foreground">
Last sign-in{" "}
@@ -621,3 +624,5 @@ function Field({
</label>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
import { useEffect, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { Link } from "react-router"
import {
Cpu,
Bot,
Palette,
User as UserIcon,
Info,
Users,
Building2,
Plus,
Trash2,
ExternalLink,
} from "lucide-react"
import { listModels } from "@crema/llm-ui"
import {
@@ -16,12 +19,24 @@ import {
useSettings as useProviderSettings,
type LLMProvidersSettings,
} from "@crema/llm-providers-ui"
import { useArcadiaClient } from "@crema/arcadia-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel"
import { AppShell } from "~/components/layout/app-shell"
import { DataState } from "~/components/data-state"
import { ThemeToggle } from "~/components/layout/theme-toggle"
import { BackgroundPicker } from "~/components/layout/background-picker"
import { FontSizePicker } from "~/components/layout/font-size-picker"
import { SurfacePicker } from "~/components/layout/surface-picker"
import { BrandingTab } from "~/components/tenant-detail/branding-tab"
import { LocalizationTab } from "~/components/tenant-detail/localization-tab"
import { getTenant, type Tenant } from "~/lib/arcadia/tenants"
import { useCapabilities } from "~/lib/capabilities"
import { useSession } from "~/lib/session"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
import {
Card,
CardContent,
@@ -44,20 +59,42 @@ export const meta = () => pageTitle("Settings")
const SECTION_KEY = "crema.settings.section"
type SectionId = "llm" | "agents" | "appearance" | "account" | "about"
type SectionId =
| "assistant"
| "personas"
| "tenant"
| "appearance"
| "account"
| "about"
const sections: {
type SectionDef = {
id: SectionId
label: string
icon: React.ComponentType<{ className?: string }>
description: string
}[] = [
{ id: "llm", label: "LLM", icon: Cpu, description: "Model endpoint & budgets" },
/** When set, the section only shows if the session holds this capability. */
requiresCapability?: string
}
const sections: SectionDef[] = [
{
id: "agents",
label: "Agents",
id: "assistant",
label: "Assistant",
icon: Bot,
description: "Model, endpoint & budgets",
},
{
id: "personas",
label: "Personas",
icon: Users,
description: "Personas, roles, sub-prompts",
description: "Assistant personas (this browser)",
},
{
id: "tenant",
label: "Tenant",
icon: Building2,
description: "Branding & localization",
requiresCapability: "platform.tenants",
},
{
id: "appearance",
@@ -65,7 +102,7 @@ const sections: {
icon: Palette,
description: "Theme, font size, surface, background",
},
{ id: "account", label: "Account", icon: UserIcon, description: "Profile & preferences" },
{ id: "account", label: "Account", icon: UserIcon, description: "Your profile" },
{ id: "about", label: "About", icon: Info, description: "Version & credits" },
]
@@ -157,13 +194,23 @@ export default function SettingsRoute() {
}
}
const caps = useCapabilities()
const visibleSections = sections.filter(
(s) => !s.requiresCapability || caps.has(s.requiresCapability as never),
)
const [section, setSection] = useState<SectionId>(() => {
if (typeof window === "undefined") return "llm"
if (typeof window === "undefined") return "assistant"
const stored = localStorage.getItem(SECTION_KEY)
return sections.some((s) => s.id === stored)
? (stored as SectionId)
: "llm"
: "assistant"
})
// If the stored section isn't available to this user (e.g. Tenant for a
// non-platform admin), fall back to Assistant.
useEffect(() => {
if (!visibleSections.some((s) => s.id === section)) setSection("assistant")
}, [visibleSections, section])
useEffect(() => {
if (typeof window !== "undefined")
localStorage.setItem(SECTION_KEY, section)
@@ -176,7 +223,7 @@ export default function SettingsRoute() {
aria-label="Settings sections"
className="flex flex-row flex-wrap gap-1 md:flex-col md:flex-nowrap md:gap-0.5"
>
{sections.map((s) => {
{visibleSections.map((s) => {
const Icon = s.icon
const active = section === s.id
return (
@@ -211,7 +258,7 @@ export default function SettingsRoute() {
</nav>
<div className="min-w-0">
{section === "llm" && (
{section === "assistant" && (
<div className="flex flex-col gap-4">
<LlmConfigurationsPanel />
@@ -243,39 +290,37 @@ export default function SettingsRoute() {
</div>
)}
{section === "agents" && <AgentsPanel />}
{section === "personas" && <AgentsPanel />}
{section === "tenant" && <TenantSettingsSection />}
{section === "appearance" && (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Theme, font size, surface tint, and background atmosphere are
in the appbar the toggles up top write to localStorage and
persist across sessions.
Theme, density, and background for this browser. These are
also in the top-right toolbar; they persist locally.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Use the icons in the appbar (top right) to change theme, font
size, surface tint, and background.
<CardContent className="flex flex-col divide-y">
<AppearanceRow label="Theme" hint="Light or dark.">
<ThemeToggle />
</AppearanceRow>
<AppearanceRow label="Font size" hint="Base text scale.">
<FontSizePicker />
</AppearanceRow>
<AppearanceRow label="Surface tint" hint="Panel background tint.">
<SurfacePicker />
</AppearanceRow>
<AppearanceRow label="Background" hint="Ambient page atmosphere.">
<BackgroundPicker />
</AppearanceRow>
</CardContent>
</Card>
)}
{section === "account" && (
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>
Identity and profile preferences.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Wire <code className="font-mono">~/lib/identity.ts</code> to a
real session to populate this panel.
</CardContent>
</Card>
)}
{section === "account" && <AccountSection />}
{section === "about" && (
<Card>
@@ -284,11 +329,8 @@ export default function SettingsRoute() {
<CardDescription>App version and credits.</CardDescription>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
<p>Arcadia Admin operator console for arcadia-core.</p>
<p>Built on the Crema design system.</p>
<p>
Hybrid traditional + AI-first scaffold with a virtual cursor
and command bus for assistant-driven UI control.
</p>
</CardContent>
</Card>
)}
@@ -316,6 +358,148 @@ function Field({
)
}
function AppearanceRow({
label,
hint,
children,
}: {
label: string
hint: string
children: React.ReactNode
}) {
return (
<div className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
<div>
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
{children}
</div>
)
}
function AccountSection() {
const session = useSession()
return (
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Your identity on this deployment.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<ReadOnlyFact label="Name" value={session?.name ?? "—"} />
<ReadOnlyFact label="Email" value={session?.email ?? "—"} />
<ReadOnlyFact label="Active tenant" value={session?.tenantSlug ?? "—"} mono />
<ReadOnlyFact
label="Roles"
value={session?.roles?.length ? session.roles.join(", ") : "—"}
/>
</div>
<div>
<Button asChild variant="outline" data-action="settings-account-profile">
<Link to="/profile">
Edit profile <ExternalLink className="size-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
)
}
function ReadOnlyFact({
label,
value,
mono,
}: {
label: string
value: string
mono?: boolean
}) {
return (
<div className="rounded-lg border bg-card/40 px-3 py-2">
<div className="text-xs uppercase tracking-wider text-muted-foreground">{label}</div>
<div className={`mt-0.5 text-sm font-medium ${mono ? "font-mono" : ""}`}>{value}</div>
</div>
)
}
/**
* Tenant settings — reuses the Phase-4 tenant-detail tab components, scoped to
* the operator's active tenant. The branding/localization endpoints are
* platform-gated, so this section is only offered to platform admins (see the
* `requiresCapability` on the section); if the load 403s anyway, DataState
* surfaces it cleanly.
*/
function TenantSettingsSection() {
const arcadia = useArcadiaClient()
const session = useSession()
const tenantId = session?.tenantId
const [tenant, setTenant] = useState<Tenant | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const reload = useCallback(async () => {
if (!tenantId) {
setLoading(false)
return
}
setError(null)
setLoading(true)
try {
setTenant(await getTenant(arcadia, tenantId))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, tenantId])
useEffect(() => {
reload()
}, [reload])
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Branding and localization for your active tenant
{session?.tenantSlug ? (
<>
{" "}
(<code className="font-mono text-xs">{session.tenantSlug}</code>)
</>
) : null}
. To manage a different tenant, open it from{" "}
<Link to="/tenants" className="underline">
Tenants
</Link>
.
</p>
<DataState
loading={loading}
error={error}
isEmpty={!tenant}
onRetry={reload}
loadingLabel="Loading tenant…"
empty={
<div className="rounded-lg border py-8 text-center text-sm text-muted-foreground">
No active tenant on this session.
</div>
}
>
{tenant ? (
<div className="flex flex-col gap-6">
<BrandingTab tenant={tenant} reload={reload} />
<LocalizationTab tenant={tenant} reload={reload} />
</div>
) : null}
</DataState>
</div>
)
}
function AgentsPanel() {
const agents = useAgents()
const [activeId, setActiveId] = useState<string>(() => loadActiveAgentId())
@@ -352,12 +536,12 @@ function AgentsPanel() {
return (
<Card>
<CardHeader>
<CardTitle>Agents</CardTitle>
<CardTitle>Personas</CardTitle>
<CardDescription>
Personas with their own sub-system prompts. Switch the active one in
the chat status bar the assistant inherits its skills, tone, and
scope. Lets you keep contexts focused: a coder agent doesn't carry
writing-task context; a writer doesn't carry codebase context.
Assistant personas with their own sub-system prompts. Switch the
active one in the chat status bar the assistant inherits its skills,
tone, and scope. Stored in this browser only, not synced to your
account.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -483,3 +667,5 @@ function AgentsPanel() {
</Card>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import {
CheckCircle2,
Eye,
@@ -14,7 +13,8 @@ import {
X,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -27,17 +27,13 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Card, CardContent, CardHeader } from "~/components/ui/card"
import {
Dialog,
DialogContent,
@@ -80,13 +76,15 @@ import {
deleteUser,
listUsers,
setUserStatus,
startImpersonation,
updateUser,
type User,
type UserInput,
type UserStatus,
} from "~/lib/arcadia/users"
import { pageTitle } from "~/lib/page-meta"
import { useSession } from "~/lib/session"
import { beginImpersonation, useSession } from "~/lib/session"
import { useNavigate } from "react-router"
import { useRegisterContext } from "@crema/aifirst-ui/context"
import { UserDetailSheet } from "~/components/users/user-detail-sheet"
@@ -99,44 +97,53 @@ export default function UsersRoute() {
const arcadia = useArcadiaClient()
const [tab, setTab] = useState<Tab>("users")
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Three independent lists, three independent load errors. A broken /roles
// call must not make the users table claim there are no users.
const [users, setUsers] = useState<User[]>([])
const [usersLoading, setUsersLoading] = useState(true)
const [usersError, setUsersError] = useState<unknown>(null)
const [invitations, setInvitations] = useState<Invitation[]>([])
const [invitationsLoading, setInvitationsLoading] = useState(true)
const [invitationsError, setInvitationsError] = useState<unknown>(null)
const [roles, setRoles] = useState<Role[]>([])
const [rolesLoading, setRolesLoading] = useState(true)
const [rolesError, setRolesError] = useState<unknown>(null)
const refreshUsers = useCallback(async () => {
setUsersError(null)
setUsersLoading(true)
try {
setUsers(await listUsers(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load users.")
// Raw throw: describeError() reads the status off it.
setUsersError(err)
} finally {
setUsersLoading(false)
}
}, [arcadia])
const refreshInvitations = useCallback(async () => {
setInvitationsError(null)
setInvitationsLoading(true)
try {
setInvitations(await listInvitations(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load invitations.")
setInvitationsError(err)
} finally {
setInvitationsLoading(false)
}
}, [arcadia])
const refreshRoles = useCallback(async () => {
setRolesError(null)
setRolesLoading(true)
try {
setRoles(await listRoles(arcadia))
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load roles.")
setRolesError(err)
} finally {
setRolesLoading(false)
}
@@ -179,17 +186,6 @@ export default function UsersRoute() {
</p>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
<TabsList>
<TabsTrigger value="users" data-action="users-tab-users">
@@ -208,9 +204,8 @@ export default function UsersRoute() {
users={users}
roles={roles}
loading={usersLoading}
error={usersError}
onRefresh={refreshUsers}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="invitations">
@@ -218,18 +213,16 @@ export default function UsersRoute() {
invitations={invitations}
roles={roles}
loading={invitationsLoading}
error={invitationsError}
onRefresh={refreshInvitations}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="roles">
<RolesPanel
roles={roles}
loading={rolesLoading}
error={rolesError}
onRefresh={refreshRoles}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
</Tabs>
@@ -244,18 +237,32 @@ function UsersPanel({
users,
roles,
loading,
error,
onRefresh,
onError,
onInfo,
}: {
users: User[]
roles: Role[]
loading: boolean
error: unknown
onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const navigate = useNavigate()
const impersonate = async (u: User) => {
try {
const token = await startImpersonation(arcadia, u.id)
// Swap the session to the impersonation token, then land on the user's
// own home. The "Viewing as…" banner appears from here on.
beginImpersonation(token.access_token)
toast.info(`Now viewing as ${u.email}`)
navigate("/")
} catch (err) {
toast.error(errorMessage(err, `impersonate ${u.email}`))
}
}
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all")
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null)
@@ -344,15 +351,15 @@ function UsersPanel({
setEditor,
setPendingDelete,
setDetailUser,
setError: onError,
setInfo: onInfo,
toast,
impersonate,
})}
triggerDataAction={`user-${u.id}-actions`}
/>
),
},
],
[arcadia, onError, onInfo, onRefresh],
[arcadia, onRefresh, toast],
)
const table = useTable<User>({
@@ -406,37 +413,43 @@ function UsersPanel({
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && users.length === 0} label="Loading users…" />
{table.total === 0 && !loading ? (
<EmptyState
title={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"
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRefresh}
loadingLabel="Loading users…"
empty={
<EmptyState
title={
search || statusFilter !== "all" ? "No users match those filters." : "No users yet."
}
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
/>
) : (
<>
<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}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</DataState>
</CardContent>
<ConfirmDialog
@@ -445,20 +458,22 @@ function UsersPanel({
title="Delete user?"
description={
pendingDelete
? `${pendingDelete.email} will be permanently removed. Their objects and audit history remain.`
? `${pendingDelete.email} will be permanently removed and can no longer sign in. Their objects and audit history are kept. Suspend instead if this is temporary.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const email = pendingDelete.email
try {
await deleteUser(arcadia, pendingDelete.id)
setPendingDelete(null)
await onRefresh()
toast.success(`Deleted ${email}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${email}`))
}
}}
/>
@@ -467,11 +482,11 @@ function UsersPanel({
state={editor}
roles={roles}
onClose={() => setEditor(null)}
onSaved={async () => {
onSaved={async (message) => {
setEditor(null)
await onRefresh()
toast.success(message)
}}
onError={onError}
/>
<UserDetailSheet
@@ -504,11 +519,12 @@ function userRowActions(
setEditor: (s: { mode: "edit"; user: User } | null) => void
setPendingDelete: (u: User | null) => void
setDetailUser: (u: User | null) => void
setError: (msg: string | null) => void
setInfo: (msg: string | null) => void
toast: ReturnType<typeof useToast>
impersonate: (u: User) => void
},
): ActionItem[] {
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, setError, setInfo } = ctx
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, toast, impersonate } =
ctx
const items: ActionItem[] = []
items.push({
@@ -519,6 +535,18 @@ function userRowActions(
onSelect: () => setDetailUser(u),
})
// Support-facing: act as this user. Only meaningful for a user who can sign
// in; the server re-checks eligibility and 403s if not allowed.
if (u.status === "active") {
items.push({
id: "impersonate",
label: "Impersonate",
icon: <Eye className="size-4" />,
dataAction: `user-${u.id}-impersonate`,
onSelect: () => impersonate(u),
})
}
items.push({
id: "edit",
label: "Edit",
@@ -535,10 +563,10 @@ function userRowActions(
onSelect: async () => {
try {
await setUserStatus(arcadia, u.id, "suspended")
setInfo(`${u.email} suspended.`)
await refresh()
toast.success(`Suspended ${u.email}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Suspend failed.")
toast.error(errorMessage(err, `suspend ${u.email}`))
}
},
})
@@ -551,10 +579,10 @@ function userRowActions(
onSelect: async () => {
try {
await setUserStatus(arcadia, u.id, "active")
setInfo(`${u.email} activated.`)
await refresh()
toast.success(`Activated ${u.email}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
toast.error(errorMessage(err, `activate ${u.email}`))
}
},
})
@@ -577,13 +605,11 @@ function UserEditorDialog({
roles,
onClose,
onSaved,
onError,
}: {
state: { mode: "create" } | { mode: "edit"; user: User } | null
roles: Role[]
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -597,9 +623,14 @@ function UserEditorDialog({
const [password, setPassword] = useState("")
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
// The failure belongs where the operator is looking — inside this dialog,
// with the form still filled in — not behind the modal scrim.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) return
setError(null)
setSaving(false)
if (initial) {
setEmail(initial.email)
setFirstName(initial.first_name ?? "")
@@ -627,7 +658,7 @@ function UserEditorDialog({
}
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const input: UserInput = {
@@ -637,18 +668,18 @@ function UserEditorDialog({
status,
role_ids: Array.from(selectedRoleIds),
}
if (!isEdit && password) input.password = password
else if (isEdit && password) input.password = password
if (password) input.password = password
if (isEdit && initial) {
await updateUser(arcadia, initial.id, input)
await onSaved(`Saved ${initial.email}`)
} else {
await createUser(arcadia, input)
await onSaved(`Created ${email.trim()}`)
}
await onSaved()
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
} finally {
// Keep the dialog open and the form intact so it can be fixed and resubmitted.
setError(err)
setSaving(false)
}
}
@@ -759,6 +790,10 @@ function UserEditorDialog({
</div>
</div>
{error ? (
<DialogError error={error} context={isEdit ? "save the user" : "create the user"} />
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="user-form-cancel">
Cancel
@@ -783,18 +818,17 @@ function InvitationsPanel({
invitations,
roles,
loading,
error,
onRefresh,
onError,
onInfo,
}: {
invitations: Invitation[]
roles: Role[]
loading: boolean
error: unknown
onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [search, setSearch] = useState("")
const [inviteOpen, setInviteOpen] = useState(false)
const [pendingRevoke, setPendingRevoke] = useState<Invitation | null>(null)
@@ -850,15 +884,14 @@ function InvitationsPanel({
arcadia,
refresh: onRefresh,
setPendingRevoke,
setError: onError,
setInfo: onInfo,
toast,
})}
triggerDataAction={`invitation-${i.id}-actions`}
/>
),
},
],
[arcadia, onError, onInfo, onRefresh],
[arcadia, onRefresh, toast],
)
const table = useTable<Invitation>({
@@ -906,39 +939,43 @@ function InvitationsPanel({
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && invitations.length === 0} label="Loading invitations…" />
{table.total === 0 && !loading ? (
<EmptyState
title={search ? "No invitations match." : "No invitations yet."}
description={
search
? "Try a different search."
: roles.length === 0
? "Create a role first, then invite users."
: "Invite your first user."
}
className="py-12"
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRefresh}
loadingLabel="Loading invitations…"
empty={
<EmptyState
title={search ? "No invitations match." : "No invitations yet."}
description={
search
? "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
/>
) : (
<>
<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}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</DataState>
</CardContent>
<ConfirmDialog
@@ -947,21 +984,22 @@ function InvitationsPanel({
title="Revoke invitation?"
description={
pendingRevoke
? `${pendingRevoke.email} will no longer be able to accept this invitation.`
? `The link sent to ${pendingRevoke.email} stops working immediately, and they can't accept it. You can invite them again later.`
: ""
}
confirmLabel="Revoke"
variant="danger"
onConfirm={async () => {
if (!pendingRevoke) return
const email = pendingRevoke.email
try {
await revokeInvitation(arcadia, pendingRevoke.id)
setPendingRevoke(null)
onInfo("Invitation revoked.")
await onRefresh()
toast.success(`Revoked the invitation to ${email}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Revoke failed.")
setPendingRevoke(null)
toast.error(errorMessage(err, `revoke the invitation to ${email}`))
}
}}
/>
@@ -970,12 +1008,13 @@ function InvitationsPanel({
open={inviteOpen}
roles={roles}
onClose={() => setInviteOpen(false)}
onSent={async () => {
onSent={async (email) => {
setInviteOpen(false)
onInfo("Invitation sent.")
await onRefresh()
toast.success(`Invitation sent to ${email}`, {
description: "They'll pick their own password when they accept.",
})
}}
onError={onError}
/>
</Card>
)
@@ -994,11 +1033,10 @@ function invitationRowActions(
arcadia: ReturnType<typeof useArcadiaClient>
refresh: () => Promise<void>
setPendingRevoke: (i: Invitation | null) => void
setError: (msg: string | null) => void
setInfo: (msg: string | null) => void
toast: ReturnType<typeof useToast>
},
): ActionItem[] {
const { arcadia, refresh, setPendingRevoke, setError, setInfo } = ctx
const { arcadia, refresh, setPendingRevoke, toast } = ctx
const status = invitationStatus(inv)
const items: ActionItem[] = []
@@ -1011,10 +1049,10 @@ function invitationRowActions(
onSelect: async () => {
try {
await resendInvitation(arcadia, inv.id)
setInfo(`Resent invitation to ${inv.email}.`)
await refresh()
toast.success(`Resent the invitation to ${inv.email}`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Resend failed.")
toast.error(errorMessage(err, `resend the invitation to ${inv.email}`))
}
},
})
@@ -1039,37 +1077,37 @@ function InviteDialog({
roles,
onClose,
onSent,
onError,
}: {
open: boolean
roles: Role[]
onClose: () => void
onSent: () => Promise<void>
onError: (msg: string | null) => void
onSent: (email: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [email, setEmail] = useState("")
const [roleId, setRoleId] = useState<string>("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) {
setEmail("")
setRoleId(roles[0]?.id ?? "")
setError(null)
setSaving(false)
} else {
setRoleId((prev) => prev || roles[0]?.id || "")
}
}, [open, roles])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
await createInvitation(arcadia, { email, role_id: roleId })
await onSent()
await onSent(email.trim())
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Invite failed.")
} finally {
setError(err)
setSaving(false)
}
}
@@ -1113,6 +1151,8 @@ function InviteDialog({
</div>
</div>
{error ? <DialogError error={error} context="send the invitation" /> : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="invite-form-cancel">
Cancel
@@ -1136,17 +1176,16 @@ function InviteDialog({
function RolesPanel({
roles,
loading,
error,
onRefresh,
onError,
onInfo,
}: {
roles: Role[]
loading: boolean
error: unknown
onRefresh: () => Promise<void>
onError: (msg: string | null) => void
onInfo: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; role: Role } | null>(null)
const [pendingDelete, setPendingDelete] = useState<Role | null>(null)
@@ -1253,33 +1292,37 @@ function RolesPanel({
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && roles.length === 0} label="Loading roles…" />
{table.total === 0 && !loading ? (
<EmptyState
title={search ? "No roles match." : "No roles yet."}
description={search ? "Try a different search." : "Create your first role."}
className="py-12"
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={onRefresh}
loadingLabel="Loading roles…"
empty={
<EmptyState
title={search ? "No roles match." : "No roles yet."}
description={search ? "Try a different search." : "Create your first role."}
className="py-12"
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(r) => r.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && roles.length > 0}
stickyHeader
/>
) : (
<>
<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}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</DataState>
</CardContent>
<ConfirmDialog
@@ -1288,21 +1331,22 @@ function RolesPanel({
title="Delete role?"
description={
pendingDelete
? `Users currently assigned to ${pendingDelete.name} will lose its permissions.`
? `${pendingDelete.name} is removed from every user who has it, and they immediately lose its ${pendingDelete.permissions.length} permission${pendingDelete.permissions.length === 1 ? "" : "s"}. This can't be undone.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const name = pendingDelete.name
try {
await deleteRole(arcadia, pendingDelete.id)
setPendingDelete(null)
onInfo("Role deleted.")
await onRefresh()
toast.success(`Deleted ${name}`)
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${name}`))
}
}}
/>
@@ -1310,11 +1354,11 @@ function RolesPanel({
<RoleEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async () => {
onSaved={async (message) => {
setEditor(null)
await onRefresh()
toast.success(message)
}}
onError={onError}
/>
</Card>
)
@@ -1351,12 +1395,10 @@ function RoleEditorDialog({
state,
onClose,
onSaved,
onError,
}: {
state: { mode: "create" } | { mode: "edit"; role: Role } | null
onClose: () => void
onSaved: () => Promise<void>
onError: (msg: string | null) => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -1369,9 +1411,12 @@ function RoleEditorDialog({
const [description, setDescription] = useState("")
const [permissionsText, setPermissionsText] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) return
setError(null)
setSaving(false)
if (initial) {
setName(initial.name)
setSlug(initial.slug)
@@ -1386,7 +1431,7 @@ function RoleEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setError(null)
setSaving(true)
try {
const permissions = permissionsText
@@ -1394,12 +1439,15 @@ function RoleEditorDialog({
.map((s) => s.trim())
.filter(Boolean)
const input: RoleInput = { name, slug, description: description || null, permissions }
if (isEdit && initial) await updateRole(arcadia, initial.id, input)
else await createRole(arcadia, input)
await onSaved()
if (isEdit && initial) {
await updateRole(arcadia, initial.id, input)
await onSaved(`Saved ${name.trim()}`)
} else {
await createRole(arcadia, input)
await onSaved(`Created ${name.trim()}`)
}
} catch (err) {
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
} finally {
setError(err)
setSaving(false)
}
}
@@ -1468,6 +1516,10 @@ function RoleEditorDialog({
</div>
</div>
{error ? (
<DialogError error={error} context={isEdit ? "save the role" : "create the role"} />
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="role-form-cancel">
{readOnly ? "Close" : "Cancel"}
@@ -1497,3 +1549,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return acc
}, {})
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"

View File

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

15
arcadia-admin-ux-spec.md Normal file
View File

@@ -0,0 +1,15 @@
## Phase 6 — as built (2026-07-14) — Settings restructure
`arcadia-admin` (branch `feat/admin-ux-overhaul`). Settings was "assistant config mislabeled as Settings, plus a dev-scaffold Account panel." Restructured honestly:
- **Assistant** (was "LLM") — model/endpoint/budgets + advanced transport + reset. Honestly named.
- **Personas** (was "Agents") — the persona editor, now labelled "Stored in this browser only, not synced to your account" (they're localStorage).
- **Tenant** (new, `platform.tenants`-gated) — **reuses the Phase-4 BrandingTab + LocalizationTab** scoped to the operator's active tenant (loaded via getTenant(session.tenantId)). This is the component-reuse the phase order was designed for; verified end-to-end (branding save → "Branding updated"). Gated to platform admins because the underlying endpoints are platform-only (my Phase-1 change); a non-holder simply doesn't see the section.
- **Appearance** — replaced the "use the icons in the appbar" fluff with the real inline pickers (Theme, Font size, Surface tint, Background — the same components the appbar mounts).
- **Account** — replaced the dev scaffold ("Wire ~/lib/identity.ts…" shown verbatim to users) with real read-only identity (name/email/active tenant/roles from the session) + an "Edit profile" link to /profile.
- **About** — trimmed to honest one-liners.
Sections filter by capability; the persisted section falls back to Assistant if unavailable to the user.
Verified: honest section labels, no dev scaffold text, real Account identity, real Appearance controls, "this browser" persona label, and the Tenant section reusing Phase-4 tabs with a working save. Typecheck 36→36 (zero added), 24-route sweep clean.

View File

@@ -1,6 +1,6 @@
# LLM Proxy Contract
> **Status: implemented.** Backend lives in `arcadia-app` at `apps/arcadia_core/lib/arcadia/ai/llm_proxy*` (see commit `75669f1`). This document remains the contract that `lib-llm-providers-ui` and `app/lib/arcadia/llm-proxy.ts` expect from arcadia — keep it in sync if either side changes.
> **Status: implemented.** Backend lives in `arcadia-core` at `apps/arcadia_core/lib/arcadia/ai/llm_proxy*` (see commit `75669f1`). This document remains the contract that `lib-llm-providers-ui` and `app/lib/arcadia/llm-proxy.ts` expect from arcadia — keep it in sync if either side changes.
## Why a proxy?

View File

@@ -14,7 +14,7 @@ shape.
|---|---|---|
| Lib / service | `@crema/lexical-rag-ui` | `arcadia-search` (Rust) |
| Engine | MiniSearch (BM25, JS) | Tantivy (BM25, Rust) |
| Where it runs | In the user's browser | Sibling of arcadia-app |
| Where it runs | In the user's browser | Sibling of arcadia-core |
| Index storage | Static JSON, fetched once | mmap'd disk, ~3080MB resident |
| Practical corpus size | ~510MB / ~50100k chunks | GB-scale, no hard cap |
| Update cadence | Static — rebuilt at app build time | Live — cron, webhook, or admin trigger |
@@ -58,7 +58,7 @@ The index is a single JSON file built offline by
**How it's wired here.**
- Build script: `arcadia-admin/scripts/build-docs-index.mjs` reads
markdown from `../reference/arcadia-app/`, chunks at H1H3,
markdown from `../reference/arcadia-core/`, chunks at H1H3,
produces `public/docs-index.json`. Runs on `npm run build:docs`
(and as the `prebuild` step before `npm run build`).
- Tool wrapper: `app/lib/admin-tools.ts` constructs a singleton
@@ -130,7 +130,7 @@ text extraction, webhook signature, service tokens) see
The default deploy runs **both**:
- `search_docs` indexes the same arcadia-app docs the parity corpus
- `search_docs` indexes the same arcadia-core docs the parity corpus
on `arcadia-search` indexes. Same content, two engines.
- This is intentional — it means the assistant always has *something*
to search, even if `arcadia-search` is down or unreachable. The

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
// Build the /docs-index.json bundle consumed by @crema/lexical-rag-ui at
// runtime. Thin wrapper — all engine logic lives in the lib's builder.ts;
// this file owns the per-app config (which arcadia-app docs to index and
// this file owns the per-app config (which arcadia-core docs to index and
// how to tag them).
//
// Run: npm run build:docs
@@ -18,11 +18,11 @@ import MiniSearch from "minisearch"
import { buildIndex } from "../../lib-lexical-rag-ui/src/builder.mjs"
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const ARCADIA = resolve(ROOT, "../reference/arcadia-app")
const ARCADIA = resolve(ROOT, "../reference/arcadia-core")
const OUT = resolve(ROOT, "public/docs-index.json")
const SOURCES = [
// Arcadia platform docs (resolved against ARCADIA = ../reference/arcadia-app).
// Arcadia platform docs (resolved against ARCADIA = ../reference/arcadia-core).
{ path: "README.md", tags: ["core"] },
{ path: "docs/ARCADIA.md", tags: ["core"] },
{ path: "docs/MODULAR_MONOLITH.md", tags: ["core"] },

View File

@@ -26,8 +26,8 @@
"@crema/llm-ui/*": ["../lib-llm-ui/src/*"],
"@crema/action-bus": ["../lib-action-bus/src/index.tsx"],
"@crema/action-bus/*": ["../lib-action-bus/src/*"],
"@crema/arcadia-client": ["../lib-arcadia-client/src/index.tsx"],
"@crema/arcadia-client/*": ["../lib-arcadia-client/src/*"],
"@crema/arcadia-core-client": ["../lib-arcadia-core-client/src/index.tsx"],
"@crema/arcadia-core-client/*": ["../lib-arcadia-core-client/src/*"],
"@crema/integration-registry-client": ["../lib-integration-registry-client/src/index.tsx"],
"@crema/integration-registry-client/*": ["../lib-integration-registry-client/src/*"],
"@crema/arcadia-auth-ui": ["../lib-arcadia-auth-ui/src/index.tsx"],

View File

@@ -60,7 +60,7 @@ const searchUiSrc = fileURLToPath(
new URL("../lib-search-ui/src", import.meta.url),
)
const arcadiaClientSrc = fileURLToPath(
new URL("../lib-arcadia-client/src", import.meta.url),
new URL("../lib-arcadia-core-client/src", import.meta.url),
)
const integrationRegistryClientSrc = fileURLToPath(
new URL("../lib-integration-registry-client/src", import.meta.url),
@@ -175,7 +175,7 @@ export default defineConfig({
{ find: "@crema/auth-ui", replacement: `${authUiSrc}/index.tsx` },
{ find: "@crema/table-ui", replacement: `${tableUiSrc}/index.tsx` },
{ find: "@crema/search-ui", replacement: `${searchUiSrc}/index.tsx` },
{ find: "@crema/arcadia-client", replacement: `${arcadiaClientSrc}/index.tsx` },
{ find: "@crema/arcadia-core-client", replacement: `${arcadiaClientSrc}/index.tsx` },
{
find: "@crema/integration-registry-client",
replacement: `${integrationRegistryClientSrc}/index.tsx`,