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