Compare commits
47 Commits
7ba415d78e
...
fix/alert-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5a994e1d8 | ||
|
|
3fcdddefda | ||
|
|
a03fbd9e9b | ||
|
|
af2c8d6663 | ||
|
|
7415b40240 | ||
|
|
938143f3f5 | ||
|
|
ab116f8465 | ||
|
|
4b817b85ff | ||
|
|
06490865d3 | ||
|
|
a299900021 | ||
|
|
a74550d73f | ||
|
|
a286b9cdce | ||
|
|
c968ac0735 | ||
|
|
2ab183596c | ||
|
|
ffe3fc0473 | ||
|
|
f6a92118da | ||
|
|
c2730e3c77 | ||
|
|
725540617b | ||
|
|
5b0281574e | ||
|
|
d1469059d8 | ||
|
|
eb7bc62d14 | ||
|
|
20c592dfa7 | ||
|
|
444516e900 | ||
|
|
628691d2df | ||
|
|
f5189305c7 | ||
|
|
49a9b019fc | ||
|
|
9cbe921db7 | ||
|
|
cdb96499be | ||
|
|
50afbd7686 | ||
|
|
169acf3cdd | ||
|
|
c640721c8e | ||
|
|
c379ebc37a | ||
|
|
20494d1620 | ||
|
|
e4ed05b815 | ||
|
|
a770faf6eb | ||
|
|
2a68389121 | ||
|
|
7eb5093071 | ||
|
|
066a16bb8b | ||
|
|
4f699bb90e | ||
|
|
c0eb85d2fe | ||
|
|
b397bbcb9e | ||
|
|
5dfceeff94 | ||
|
|
bfe61c220a | ||
|
|
8e07f4b9c0 | ||
|
|
baf42c4cec | ||
|
|
29030c9e72 | ||
|
|
0fcb9e40f1 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
|
||||||
# React Router
|
# React Router
|
||||||
@@ -9,3 +11,10 @@
|
|||||||
.demo.log
|
.demo.log
|
||||||
.demo.pid
|
.demo.pid
|
||||||
.boot.log
|
.boot.log
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -7,9 +7,9 @@ This file is a quick map, not a duplication of upstream docs.
|
|||||||
|
|
||||||
## What Arcadia Admin is
|
## 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.
|
- **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.
|
- **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.
|
- The brand string lives in **one place**: `app/lib/identity.ts` (`useBrand()` / `getBrand()`). Don't hardcode "Arcadia Admin" in components, page titles, or copy.
|
||||||
|
|
||||||
@@ -19,13 +19,14 @@ This file is a quick map, not a duplication of upstream docs.
|
|||||||
- **`useArcadiaClient()`** for typed/generic HTTP. `arcadia.typed.GET("/api/v1/...")` infers paths from the generated `paths` type; `arcadia.GET<T>(path)` is the generic escape hatch for spec-incomplete endpoints.
|
- **`useArcadiaClient()`** for typed/generic HTTP. `arcadia.typed.GET("/api/v1/...")` infers paths from the generated `paths` type; `arcadia.GET<T>(path)` is the generic escape hatch for spec-incomplete endpoints.
|
||||||
- **Login** — `app/routes/login.tsx` renders `<LoginForm>` from `@crema/arcadia-auth-ui`. Successful login writes tokens via `persistFromArcadiaLogin()` in `app/lib/session.ts`, which preserves the existing `Session` shape used by `useUser` / `AppShell`.
|
- **Login** — `app/routes/login.tsx` renders `<LoginForm>` from `@crema/arcadia-auth-ui`. Successful login writes tokens via `persistFromArcadiaLogin()` in `app/lib/session.ts`, which preserves the existing `Session` shape used by `useUser` / `AppShell`.
|
||||||
- **Realtime** — supported by the lib but not enabled at the provider here; pass `enableRealtime` + `userId` to opt in.
|
- **Realtime** — supported by the lib but not enabled at the provider here; pass `enableRealtime` + `userId` to opt in.
|
||||||
|
- **Search admin sidecar** — the `/search` route (`app/routes/search.tsx`) calls arcadia-search's privileged `/admin/*` surface (default `127.0.0.1:7801`) via `app/lib/search-admin.ts`. Configured by `VITE_ARCADIA_SEARCH_ADMIN_URL` + `VITE_ARCADIA_SEARCH_ADMIN_TOKEN`; the token must match `ADMIN_TOKEN` on the search box. See `arcadia-search/README.md` § *Admin sidecar*.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `npm run dev` — Vite dev server (React Router 7).
|
- `npm run dev` — Vite dev server (React Router 7).
|
||||||
- `npm run build` — production build (`react-router build`).
|
- `npm run build` — production build (`react-router build`).
|
||||||
- `npm run start` — serve the built app (`react-router-serve ./build/server/index.js`).
|
- `npm run start` — serve the built app (`react-router-serve ./build/server/index.js`).
|
||||||
- `npm run typecheck` — `react-router typegen && tsc`. See gotcha below; may crash.
|
- `npm run typecheck` — `react-router typegen && tsc`. **Works** (2026-07-14). Run it before every commit.
|
||||||
- `start.sh` / `stop.sh` — repo's preferred way to run/stop the dev server in the background.
|
- `start.sh` / `stop.sh` — repo's preferred way to run/stop the dev server in the background.
|
||||||
|
|
||||||
- `npm run test` — Vitest run (vibespace-inherited setup; jsdom + @testing-library/react).
|
- `npm run test` — Vitest run (vibespace-inherited setup; jsdom + @testing-library/react).
|
||||||
@@ -140,6 +141,7 @@ This repo was scaffolded from `create-crema-app`, which patches marker comments.
|
|||||||
|
|
||||||
## Known gotchas
|
## Known gotchas
|
||||||
|
|
||||||
- `npm run typecheck` may crash with a TypeScript internal error — pre-existing in the Crema toolchain. There's no test runner here, so rely on careful reads + dev server.
|
- `npm run typecheck` **does not crash** — verified 2026-07-14. The old "it crashes, rely on careful reads" note was stale, and it cost us: a route shipped using `Input`/`Textarea` without importing them, which `tsc` reports instantly as TS2304 but nobody was running it. It currently reports ~39 pre-existing errors in `app/` (mostly `TS2322` prop mismatches) and more in sibling libs; treat *new* errors as blocking even while that backlog stands.
|
||||||
|
- Every route that renders `<AppShell>` re-exports a shared route-level error boundary (`app/components/route-error.tsx`). Keep that export when adding routes — without it, one crashing panel replaces the entire console with an unstyled stack trace and strands the operator with no nav.
|
||||||
- Vite "Outdated Optimize Dep" 504s after editing `vite.config.ts` or `tsconfig.json`: stop dev, `rm -rf node_modules/.vite`, restart, hard-reload.
|
- Vite "Outdated Optimize Dep" 504s after editing `vite.config.ts` or `tsconfig.json`: stop dev, `rm -rf node_modules/.vite`, restart, hard-reload.
|
||||||
- After editing a sibling lib's exports, the dev server sometimes needs a manual restart to pick up the new types.
|
- After editing a sibling lib's exports, the dev server sometimes needs a manual restart to pick up the new types.
|
||||||
|
|||||||
40
PRODUCT.md
Normal file
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).
|
||||||
14
README.md
14
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Arcadia Admin
|
# 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.
|
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:
|
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`).
|
2. Visit `/login` and sign in with admin credentials. In dev seeds: `admin@example.com` / `AdminP@ssw0rd` (tenant `default`).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -24,6 +24,8 @@ To use it for real:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VITE_ARCADIA_URL` | `http://localhost:4000` | Base URL of arcadia-core. |
|
| `VITE_ARCADIA_URL` | `http://localhost:4000` | Base URL of arcadia-core. |
|
||||||
| `VITE_ARCADIA_TENANT` | `default` | Tenant id sent as `X-Tenant-ID`. Override per-deployment. |
|
| `VITE_ARCADIA_TENANT` | `default` | Tenant id sent as `X-Tenant-ID`. Override per-deployment. |
|
||||||
|
| `VITE_ARCADIA_SEARCH_URL` | `http://127.0.0.1:7800` | Base URL of arcadia-search (Tantivy). |
|
||||||
|
| `VITE_ARCADIA_SEARCH_TOKEN` | _(unset)_ | Service-principal JWT for the assistant's `search_kb`/`read_chunk` tools. Set this when arcadia-search runs in `AUTH_MODE=jwt` and doesn't share its signing secret with the arcadia issuing operator session tokens. When unset, the operator's own session JWT is used (works only with matched signing keys). |
|
||||||
|
|
||||||
## What's in here
|
## What's in here
|
||||||
|
|
||||||
@@ -31,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.
|
`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
|
### 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>`.
|
- [`@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
|
### Skyrise theme
|
||||||
@@ -48,8 +50,8 @@ Surface tints (`body[data-surface="snow|stone|sage|slate"]`) and dark mode (`htm
|
|||||||
your-workspace/
|
your-workspace/
|
||||||
arcadia-admin/ ← this repo
|
arcadia-admin/ ← this repo
|
||||||
vibespace/ ← starter that this was cloned from
|
vibespace/ ← starter that this was cloned from
|
||||||
reference/arcadia-app/ ← Phoenix backend (read-only reference)
|
reference/arcadia-core/ ← Phoenix backend (read-only reference)
|
||||||
lib-arcadia-client/
|
lib-arcadia-core-client/
|
||||||
lib-arcadia-auth-ui/
|
lib-arcadia-auth-ui/
|
||||||
lib-action-bus/
|
lib-action-bus/
|
||||||
lib-aifirst-ui/
|
lib-aifirst-ui/
|
||||||
@@ -82,4 +84,4 @@ your-workspace/
|
|||||||
- [`docs/AI_FIRST.md`](docs/AI_FIRST.md) — command-bus / DSL system tour
|
- [`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
|
- [`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
|
- `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)
|
||||||
|
|||||||
38
app/app.css
38
app/app.css
@@ -1,8 +1,12 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Geist+Mono:wght@100..900&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Geist+Mono:wght@100..900&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap");
|
||||||
|
|
||||||
/* Active theme — must be first so its @import url() font directives resolve
|
/* Active theme — must be first so its @import url() font directives resolve
|
||||||
* to the top of the output. Themes are self-contained: tokens + fonts. */
|
* to the top of the output. Themes are self-contained: tokens + fonts. */
|
||||||
@import "../../lib-theme-skyrise/theme.css"; /* CREMA:THEME */
|
@import "../../lib-theme-skyrise/theme.css"; /* CREMA:THEME */
|
||||||
|
|
||||||
|
/* Per-route alt theme — applied via [data-theme="console"] on AppShell. */
|
||||||
|
@import "./themes/console.css";
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
@source "../../lib-aifirst-ui/src";
|
@source "../../lib-aifirst-ui/src";
|
||||||
@source "../../lib-llm-ui/src";
|
@source "../../lib-llm-ui/src";
|
||||||
@source "../../lib-action-bus/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-arcadia-auth-ui/src";
|
||||||
@source "../../lib-table-ui/src";
|
@source "../../lib-table-ui/src";
|
||||||
@source "../../lib-search-ui/src";
|
@source "../../lib-search-ui/src";
|
||||||
@@ -19,6 +23,18 @@
|
|||||||
@source "../../lib-auth-ui/src";
|
@source "../../lib-auth-ui/src";
|
||||||
@source "../../lib-agent-ui/src";
|
@source "../../lib-agent-ui/src";
|
||||||
@source "../../lib-llm-providers-ui/src";
|
@source "../../lib-llm-providers-ui/src";
|
||||||
|
@source "../../lib-file-ui/src";
|
||||||
|
@source "../../lib-card-ui/src";
|
||||||
|
@source "../../lib-dashboard-ui/src";
|
||||||
|
@source "../../lib-chart-ui/src";
|
||||||
|
@source "../../lib-map-ui/src";
|
||||||
|
@source "../../lib-status-ui/src";
|
||||||
|
@source "../../lib-data-ui/src";
|
||||||
|
@source "../../lib-code-ui/src";
|
||||||
|
@source "../../lib-diagram-ui/src";
|
||||||
|
@source "../../lib-onboarding-ui/src";
|
||||||
|
@source "../../lib-lexical-rag-ui/src";
|
||||||
|
@source "../../lib-notification-ui/src";
|
||||||
/* CREMA:SOURCES */
|
/* CREMA:SOURCES */
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
@@ -140,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
// Renders an assistant message: GFM markdown for prose, custom ```card```
|
// Renders an assistant message: GFM markdown for prose, plus typed fenced
|
||||||
// blocks rendered as rich UI (status pills, tenant cards, KPIs), pills for
|
// code blocks rendered as rich UI from `@crema/*-ui` libs (charts, tables,
|
||||||
// command-bus action blocks, and tool-result cards (role: "tool").
|
// KPIs, code, diffs, status pills, callouts), pills for command-bus action
|
||||||
|
// blocks, and tool-result cards (role: "tool").
|
||||||
|
//
|
||||||
|
// Typed blocks recognized (each is a fenced ```<kind>\n<json>\n``` block):
|
||||||
|
// action — command-bus DSL (handled by extractActionBlocks; replaced
|
||||||
|
// with a "Ran N actions" pill)
|
||||||
|
// card — { kind: "pill" | "stat" | "callout", ... } (legacy)
|
||||||
|
// chart-spark — { values: number[], stroke?, fill? }
|
||||||
|
// chart-bar — { data: [{ label, value, color? }] }
|
||||||
|
// chart-line — { series: [{ x, y }] }
|
||||||
|
// chart-donut — { data: [{ label, value, color? }] }
|
||||||
|
// table — { columns: [{ id, header, accessor? }], rows: [...] }
|
||||||
|
// kpi — { items: [{ label, value, unit? }] }
|
||||||
|
// code — { code, language?, title?, lineNumbers? }
|
||||||
|
// diff — { oldCode, newCode, language?, title? }
|
||||||
|
|
||||||
import { type ReactNode, useMemo } from "react"
|
import { type ReactNode, useMemo } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
@@ -9,9 +23,33 @@ import { Sparkles, Wrench } from "lucide-react"
|
|||||||
|
|
||||||
import { extractActionBlocks } from "@crema/action-bus"
|
import { extractActionBlocks } from "@crema/action-bus"
|
||||||
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
|
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
|
||||||
|
import {
|
||||||
|
Sparkline,
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
Donut,
|
||||||
|
type ChartDatum,
|
||||||
|
type SeriesPoint,
|
||||||
|
} from "@crema/chart-ui"
|
||||||
|
import { DataTable, type Column } from "@crema/table-ui"
|
||||||
|
import { KPIRow } from "@crema/data-ui"
|
||||||
|
import { CodeBlock, DiffViewer } from "@crema/code-ui"
|
||||||
|
import { FlowChart, OrgChart } from "@crema/diagram-ui"
|
||||||
|
import { StepTrail, type AgentStep } from "@crema/agent-ui"
|
||||||
|
import {
|
||||||
|
OnboardingChecklist,
|
||||||
|
WelcomeCard,
|
||||||
|
HintCard,
|
||||||
|
type ChecklistTask,
|
||||||
|
type OnboardingTone,
|
||||||
|
} from "@crema/onboarding-ui"
|
||||||
|
|
||||||
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
|
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
|
||||||
const CARD_BLOCK_RE = /```card\s*\n([\s\S]*?)```/g
|
|
||||||
|
// Captures the kind tag and the body of every fenced block we render as UI.
|
||||||
|
// Kept alongside the markdown ones — react-markdown ignores anything we strip.
|
||||||
|
const TYPED_BLOCK_RE =
|
||||||
|
/```(card|chart-spark|chart-bar|chart-line|chart-donut|table|kpi|code|diff|flowchart|orgchart|steps|checklist|welcome|hint)\s*\n([\s\S]*?)```/g
|
||||||
|
|
||||||
export type MessageBodyProps = {
|
export type MessageBodyProps = {
|
||||||
content: string
|
content: string
|
||||||
@@ -19,33 +57,231 @@ export type MessageBodyProps = {
|
|||||||
toolCalls?: ToolCall[]
|
toolCalls?: ToolCall[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Segment =
|
||||||
|
| { type: "prose"; text: string }
|
||||||
|
| { type: "block"; kind: string; spec: unknown; raw: string }
|
||||||
|
|
||||||
|
function parseSegments(content: string): Segment[] {
|
||||||
|
const segments: Segment[] = []
|
||||||
|
TYPED_BLOCK_RE.lastIndex = 0
|
||||||
|
let lastIndex = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = TYPED_BLOCK_RE.exec(content)) !== null) {
|
||||||
|
const [raw, kind, body] = match
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
segments.push({ type: "prose", text: content.slice(lastIndex, match.index) })
|
||||||
|
}
|
||||||
|
let spec: unknown = null
|
||||||
|
try {
|
||||||
|
spec = JSON.parse(body.trim())
|
||||||
|
} catch {
|
||||||
|
// malformed → emit the raw fence as prose so the user sees the model output
|
||||||
|
segments.push({ type: "prose", text: raw })
|
||||||
|
lastIndex = match.index + raw.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segments.push({ type: "block", kind, spec, raw })
|
||||||
|
lastIndex = match.index + raw.length
|
||||||
|
}
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
segments.push({ type: "prose", text: content.slice(lastIndex) })
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlock(kind: string, spec: any, key: number): ReactNode {
|
||||||
|
switch (kind) {
|
||||||
|
case "card":
|
||||||
|
return <CardBlock key={key} spec={spec} />
|
||||||
|
case "chart-spark":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-2 inline-block text-primary">
|
||||||
|
<Sparkline
|
||||||
|
values={spec.values ?? []}
|
||||||
|
width={spec.width ?? 240}
|
||||||
|
height={spec.height ?? 48}
|
||||||
|
stroke={spec.stroke ?? "currentColor"}
|
||||||
|
fill={spec.fill}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "chart-bar":
|
||||||
|
return (
|
||||||
|
<ChartFrame key={key} title={spec.title}>
|
||||||
|
<div className="text-primary">
|
||||||
|
<BarChart data={(spec.data ?? []) as ChartDatum[]} width={spec.width ?? 360} height={spec.height ?? 180} />
|
||||||
|
</div>
|
||||||
|
<ChartLegend data={spec.data} />
|
||||||
|
</ChartFrame>
|
||||||
|
)
|
||||||
|
case "chart-line":
|
||||||
|
return (
|
||||||
|
<ChartFrame key={key} title={spec.title}>
|
||||||
|
<div className="text-primary">
|
||||||
|
<LineChart series={(spec.series ?? []) as SeriesPoint[]} width={spec.width ?? 360} height={spec.height ?? 180} />
|
||||||
|
</div>
|
||||||
|
</ChartFrame>
|
||||||
|
)
|
||||||
|
case "chart-donut":
|
||||||
|
return (
|
||||||
|
<ChartFrame key={key} title={spec.title}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-primary">
|
||||||
|
<Donut data={(spec.data ?? []) as ChartDatum[]} size={spec.size ?? 140} thickness={spec.thickness ?? 20} />
|
||||||
|
</div>
|
||||||
|
<ChartLegend data={spec.data} />
|
||||||
|
</div>
|
||||||
|
</ChartFrame>
|
||||||
|
)
|
||||||
|
case "table":
|
||||||
|
return <TableBlock key={key} spec={spec} />
|
||||||
|
case "kpi":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<KPIRow items={spec.items ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<CodeBlock
|
||||||
|
code={spec.code ?? ""}
|
||||||
|
language={spec.language}
|
||||||
|
title={spec.title}
|
||||||
|
showLineNumbers={spec.lineNumbers ?? false}
|
||||||
|
highlightLines={spec.highlightLines}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "diff":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<DiffViewer
|
||||||
|
oldCode={spec.oldCode ?? ""}
|
||||||
|
newCode={spec.newCode ?? ""}
|
||||||
|
language={spec.language}
|
||||||
|
title={spec.title}
|
||||||
|
mode={spec.mode ?? "unified"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "flowchart":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
|
||||||
|
<FlowChart nodes={spec.nodes ?? []} edges={spec.edges ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "orgchart":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
|
||||||
|
<OrgChart data={spec.data} horizontal={spec.horizontal} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "steps":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3 rounded-lg border bg-card/50 p-3">
|
||||||
|
<StepTrail steps={(spec.steps ?? []) as AgentStep[]} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "checklist":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<OnboardingChecklist
|
||||||
|
title={spec.title}
|
||||||
|
description={spec.description}
|
||||||
|
tasks={(spec.tasks ?? []) as ChecklistTask[]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "welcome":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<WelcomeCard
|
||||||
|
title={spec.title}
|
||||||
|
description={spec.description}
|
||||||
|
badge={spec.badge}
|
||||||
|
primaryAction={spec.primaryAction}
|
||||||
|
secondaryAction={spec.secondaryAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "hint":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<HintCard
|
||||||
|
title={spec.title}
|
||||||
|
tone={(spec.tone ?? "info") as OnboardingTone}
|
||||||
|
action={spec.action}
|
||||||
|
>
|
||||||
|
{spec.body ?? ""}
|
||||||
|
</HintCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<pre key={key} className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||||
|
{JSON.stringify(spec, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartFrame({ title, children }: { title?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 rounded-lg border bg-card/50 p-3">
|
||||||
|
{title && <div className="mb-2 text-xs font-medium text-muted-foreground">{title}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartLegend({ data }: { data?: ChartDatum[] }) {
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{data.map((d) => (
|
||||||
|
<li key={d.label} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block size-2 rounded-sm"
|
||||||
|
style={{ background: d.color ?? "currentColor" }}
|
||||||
|
/>
|
||||||
|
<span>{d.label}</span>
|
||||||
|
<span className="tabular-nums text-foreground/70">{d.value}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBlock({ spec }: { spec: any }) {
|
||||||
|
const rows: Record<string, unknown>[] = Array.isArray(spec.rows) ? spec.rows : []
|
||||||
|
const columns: Column<Record<string, unknown>>[] = (spec.columns ?? []).map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
header: c.header ?? c.id,
|
||||||
|
accessor: c.accessor ?? c.id,
|
||||||
|
sortable: c.sortable ?? true,
|
||||||
|
align: c.align,
|
||||||
|
}))
|
||||||
|
const idKey = spec.idKey ?? columns[0]?.id ?? "id"
|
||||||
|
return (
|
||||||
|
<div className="my-3 overflow-x-auto rounded-lg border bg-card/50">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
getRowId={(r) => String(r[idKey] ?? Math.random())}
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type CardSpec =
|
type CardSpec =
|
||||||
| { kind: "pill"; status: string; label?: string }
|
| { kind: "pill"; status: string; label?: string }
|
||||||
| { kind: "stat"; label: string; value: string | number; tone?: string }
|
| { kind: "stat"; label: string; value: string | number; tone?: string }
|
||||||
| { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
| { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||||
| { kind: string; [k: string]: unknown }
|
| { kind: string; [k: string]: unknown }
|
||||||
|
|
||||||
function parseCardBlocks(content: string): { blocks: CardSpec[]; stripped: string } {
|
function CardBlock({ spec }: { spec: CardSpec }) {
|
||||||
const blocks: CardSpec[] = []
|
|
||||||
CARD_BLOCK_RE.lastIndex = 0
|
|
||||||
const stripped = content.replace(CARD_BLOCK_RE, (_, body: string) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body.trim()) as CardSpec
|
|
||||||
if (parsed && typeof parsed === "object" && typeof parsed.kind === "string") {
|
|
||||||
blocks.push(parsed)
|
|
||||||
return "" // strip from prose
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// malformed — leave the original block in the prose so the user can see
|
|
||||||
// what the model tried to emit.
|
|
||||||
return _
|
|
||||||
}
|
|
||||||
return _
|
|
||||||
})
|
|
||||||
return { blocks, stripped }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCard(spec: CardSpec): ReactNode {
|
|
||||||
switch (spec.kind) {
|
switch (spec.kind) {
|
||||||
case "pill": {
|
case "pill": {
|
||||||
const s = spec as { kind: "pill"; status: string; label?: string }
|
const s = spec as { kind: "pill"; status: string; label?: string }
|
||||||
@@ -58,9 +294,7 @@ function renderCard(spec: CardSpec): ReactNode {
|
|||||||
? "border-rose-500/40 bg-rose-500/15 text-rose-700 dark:text-rose-300"
|
? "border-rose-500/40 bg-rose-500/15 text-rose-700 dark:text-rose-300"
|
||||||
: "border-border bg-muted text-muted-foreground"
|
: "border-border bg-muted text-muted-foreground"
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`my-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}>
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}
|
|
||||||
>
|
|
||||||
{s.label ?? s.status}
|
{s.label ?? s.status}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -68,19 +302,14 @@ function renderCard(spec: CardSpec): ReactNode {
|
|||||||
case "stat": {
|
case "stat": {
|
||||||
const s = spec as { kind: "stat"; label: string; value: string | number }
|
const s = spec as { kind: "stat"; label: string; value: string | number }
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
<span className="my-1 inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">{s.label}</span>
|
<span className="text-xs text-muted-foreground">{s.label}</span>
|
||||||
<span className="font-semibold tabular-nums">{s.value}</span>
|
<span className="font-semibold tabular-nums">{s.value}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "callout": {
|
case "callout": {
|
||||||
const s = spec as {
|
const s = spec as { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||||
kind: "callout"
|
|
||||||
title?: string
|
|
||||||
tone?: "info" | "warning" | "danger" | "success"
|
|
||||||
body?: string
|
|
||||||
}
|
|
||||||
const tone = s.tone ?? "info"
|
const tone = s.tone ?? "info"
|
||||||
const palette: Record<string, string> = {
|
const palette: Record<string, string> = {
|
||||||
info: "border-sky-500/40 bg-sky-500/10",
|
info: "border-sky-500/40 bg-sky-500/10",
|
||||||
@@ -89,7 +318,7 @@ function renderCard(spec: CardSpec): ReactNode {
|
|||||||
success: "border-emerald-500/40 bg-emerald-500/10",
|
success: "border-emerald-500/40 bg-emerald-500/10",
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
|
<div className={`my-2 rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
|
||||||
{s.title && <div className="mb-1 font-medium">{s.title}</div>}
|
{s.title && <div className="mb-1 font-medium">{s.title}</div>}
|
||||||
{s.body && <div className="text-muted-foreground">{s.body}</div>}
|
{s.body && <div className="text-muted-foreground">{s.body}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,24 +326,67 @@ function renderCard(spec: CardSpec): ReactNode {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<pre className="rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
<pre className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||||
{JSON.stringify(spec, null, 2)}
|
{JSON.stringify(spec, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROSE_COMPONENTS = {
|
||||||
|
p: ({ children }: any) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
||||||
|
code: ({ children, className }: any) => {
|
||||||
|
const isBlock = className?.startsWith("language-")
|
||||||
|
if (isBlock) {
|
||||||
|
return (
|
||||||
|
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||||
|
<code className="font-mono">{children}</code>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||||
|
},
|
||||||
|
ul: ({ children }: any) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||||
|
ol: ({ children }: any) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
|
||||||
|
li: ({ children }: any) => <li className="my-0.5">{children}</li>,
|
||||||
|
a: ({ children, href }: any) => (
|
||||||
|
<a href={href} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
table: ({ children }: any) => (
|
||||||
|
<div className="my-2 overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }: any) => <thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>,
|
||||||
|
th: ({ children }: any) => <th className="px-3 py-2 text-left font-medium">{children}</th>,
|
||||||
|
td: ({ children }: any) => <td className="border-t px-3 py-2">{children}</td>,
|
||||||
|
input: ({ checked, type, ...rest }: any) =>
|
||||||
|
type === "checkbox" ? (
|
||||||
|
<input type="checkbox" checked={!!checked} readOnly {...rest} className="mr-1.5 align-middle" />
|
||||||
|
) : (
|
||||||
|
<input type={type} {...rest} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProseChunk({ text }: { text: string }) {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
return (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={PROSE_COMPONENTS}>
|
||||||
|
{trimmed}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
|
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
|
||||||
const { prose, actionCount, cardBlocks } = useMemo(() => {
|
const { segments, actionCount } = useMemo(() => {
|
||||||
const blocks = extractActionBlocks(content)
|
const blocks = extractActionBlocks(content)
|
||||||
const cleaned = stripToolCallTags(content)
|
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "")
|
||||||
.replace(ACTION_BLOCK_RE, "")
|
|
||||||
.trim()
|
|
||||||
const { blocks: cardBlocks, stripped } = parseCardBlocks(cleaned)
|
|
||||||
return {
|
return {
|
||||||
prose: stripped.trim(),
|
segments: parseSegments(cleaned),
|
||||||
actionCount: blocks.length,
|
actionCount: blocks.length,
|
||||||
cardBlocks,
|
|
||||||
}
|
}
|
||||||
}, [content])
|
}, [content])
|
||||||
|
|
||||||
@@ -134,76 +406,8 @@ export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||||
{prose && (
|
{segments.map((seg, i) =>
|
||||||
<ReactMarkdown
|
seg.type === "prose" ? <ProseChunk key={i} text={seg.text} /> : renderBlock(seg.kind, seg.spec, i),
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
|
||||||
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
|
||||||
code: ({ children, className }) => {
|
|
||||||
const isBlock = className?.startsWith("language-")
|
|
||||||
if (isBlock) {
|
|
||||||
return (
|
|
||||||
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
|
||||||
<code className="font-mono">{children}</code>
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
|
||||||
ol: ({ children }) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
|
|
||||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
|
||||||
a: ({ children, href }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className="text-primary underline underline-offset-2"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
table: ({ children }) => (
|
|
||||||
<div className="my-2 overflow-x-auto rounded-md border">
|
|
||||||
<table className="w-full text-sm">{children}</table>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
thead: ({ children }) => (
|
|
||||||
<thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>
|
|
||||||
),
|
|
||||||
th: ({ children }) => (
|
|
||||||
<th className="px-3 py-2 text-left font-medium">{children}</th>
|
|
||||||
),
|
|
||||||
td: ({ children }) => <td className="border-t px-3 py-2">{children}</td>,
|
|
||||||
input: ({ checked, type, ...rest }) =>
|
|
||||||
type === "checkbox" ? (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!checked}
|
|
||||||
readOnly
|
|
||||||
{...rest}
|
|
||||||
className="mr-1.5 align-middle"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input type={type} {...rest} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{prose}
|
|
||||||
</ReactMarkdown>
|
|
||||||
)}
|
|
||||||
{cardBlocks.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
||||||
{cardBlocks.map((spec, i) => (
|
|
||||||
<span key={i} className={spec.kind === "callout" ? "block w-full" : ""}>
|
|
||||||
{renderCard(spec)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{actionCount > 0 && (
|
{actionCount > 0 && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
33
app/components/auth/auth-shell.tsx
Normal file
33
app/components/auth/auth-shell.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { type ReactNode } from "react"
|
||||||
|
|
||||||
|
import { useBrand } from "~/lib/identity"
|
||||||
|
|
||||||
|
export function AuthShell({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="dark relative isolate flex min-h-svh items-center justify-center p-4"
|
||||||
|
style={{ background: "var(--background)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthBrand() {
|
||||||
|
const brand = useBrand()
|
||||||
|
const BrandIcon = brand.icon
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--primary)",
|
||||||
|
color: "var(--primary-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrandIcon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">{brand.name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
|
||||||
const SIDEBAR_KEY = "crema.shell.sidebar"
|
const SIDEBAR_KEY = "crema.shell.sidebar"
|
||||||
import { NavLink, useNavigate } from "react-router"
|
const NAV_GROUPS_KEY = "crema.shell.nav-groups"
|
||||||
|
import { NavLink, useLocation, useNavigate } from "react-router"
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
LogOut,
|
LogOut,
|
||||||
HelpCircle,
|
|
||||||
Menu,
|
Menu,
|
||||||
Play,
|
Play,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
@@ -25,6 +25,21 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Webhook as WebhookIcon,
|
Webhook as WebhookIcon,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
Gauge,
|
||||||
|
UserCheck,
|
||||||
|
Network,
|
||||||
|
Building,
|
||||||
|
ShieldCheck,
|
||||||
|
Megaphone,
|
||||||
|
AlertOctagon,
|
||||||
|
SearchCode,
|
||||||
|
ChevronDown,
|
||||||
|
Database,
|
||||||
|
Plug,
|
||||||
|
MessageSquare,
|
||||||
|
CreditCard,
|
||||||
|
Flag,
|
||||||
|
Eye,
|
||||||
// CREMA:NAV-ICONS
|
// CREMA:NAV-ICONS
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
@@ -52,14 +67,14 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "~/components/ui/popover"
|
} from "~/components/ui/popover"
|
||||||
import { profileInitials, useProfile } from "~/lib/profile"
|
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 {
|
import {
|
||||||
addNotification,
|
addNotification,
|
||||||
dismiss,
|
dismiss,
|
||||||
dismissAll,
|
dismissAll,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
markRead,
|
markRead,
|
||||||
seedIfEmpty,
|
|
||||||
unreadCount,
|
unreadCount,
|
||||||
useNotifications,
|
useNotifications,
|
||||||
} from "~/lib/notifications"
|
} from "~/lib/notifications"
|
||||||
@@ -82,6 +97,7 @@ import {
|
|||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "~/components/ui/sheet"
|
} from "~/components/ui/sheet"
|
||||||
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
|
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
|
||||||
|
import { RouteGuard } from "~/components/route-guard"
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
to: string
|
to: string
|
||||||
@@ -90,22 +106,114 @@ type NavItem = {
|
|||||||
end?: boolean
|
end?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
type NavGroup = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned items render flat at the top of the rail, above any groups.
|
||||||
|
const pinnedTop: NavItem[] = [
|
||||||
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
|
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
key: "tenancy",
|
||||||
|
label: "Tenancy",
|
||||||
|
icon: Building2,
|
||||||
|
items: [
|
||||||
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
||||||
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
{ to: "/organizations", icon: Building, label: "Organizations" },
|
||||||
{ to: "/users", icon: UsersIcon, label: "Users" },
|
{ to: "/users", icon: UsersIcon, label: "Users" },
|
||||||
|
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
|
||||||
|
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "billing",
|
||||||
|
label: "Billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
// 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",
|
||||||
|
label: "Data",
|
||||||
|
icon: Database,
|
||||||
|
items: [
|
||||||
|
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
||||||
|
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
||||||
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
|
{ 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: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
||||||
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
||||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
{ to: "/integrations", icon: Plug, label: "Integrations" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "infrastructure",
|
||||||
|
label: "Infrastructure",
|
||||||
|
icon: Network,
|
||||||
|
items: [
|
||||||
|
{ to: "/networking", icon: Network, label: "Networking" },
|
||||||
|
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "comms",
|
||||||
|
label: "Communications",
|
||||||
|
icon: MessageSquare,
|
||||||
|
items: [
|
||||||
|
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
||||||
|
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ai",
|
||||||
|
label: "AI & Search",
|
||||||
|
icon: Sparkles,
|
||||||
|
items: [
|
||||||
{ to: "/ai", icon: Bot, label: "AI" },
|
{ to: "/ai", icon: Bot, label: "AI" },
|
||||||
{ to: "/settings", icon: Settings, label: "Settings" },
|
{ to: "/search", icon: SearchCode, label: "Search" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Items appended by `crema add <lib>` land here. Rendered ungrouped at
|
||||||
|
// the bottom of the groups, above the pinned footer.
|
||||||
|
const extraNavItems: NavItem[] = [
|
||||||
// CREMA:NAV-ITEMS
|
// CREMA:NAV-ITEMS
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function readNavGroupState(): Record<string, boolean> {
|
||||||
|
if (typeof window === "undefined") return {}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(NAV_GROUPS_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
title: string
|
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
brand?: Brand
|
brand?: Brand
|
||||||
user?: User
|
user?: User
|
||||||
@@ -118,7 +226,6 @@ type AppShellProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({
|
export function AppShell({
|
||||||
title,
|
|
||||||
children,
|
children,
|
||||||
brand: brandOverride,
|
brand: brandOverride,
|
||||||
user: userOverride,
|
user: userOverride,
|
||||||
@@ -128,16 +235,14 @@ export function AppShell({
|
|||||||
const defaultUser = useUser()
|
const defaultUser = useUser()
|
||||||
const profile = useProfile()
|
const profile = useProfile()
|
||||||
const session = useSession()
|
const session = useSession()
|
||||||
|
const caps = useCapabilities()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const brand = brandOverride ?? defaultBrand
|
const brand = brandOverride ?? defaultBrand
|
||||||
// Prefer the live session for identity, fall back to the editable profile,
|
// Prefer the live session for identity, fall back to the stub user.
|
||||||
// fall back to the stub user.
|
|
||||||
const user = userOverride ?? {
|
const user = userOverride ?? {
|
||||||
name: session?.name || profile.name || defaultUser.name,
|
name: session?.name || defaultUser.name,
|
||||||
email: session?.email || profile.email || defaultUser.email,
|
email: session?.email || defaultUser.email,
|
||||||
initials: profileInitials(
|
initials: profileInitials(session?.name || defaultUser.name),
|
||||||
session?.name || profile.name || defaultUser.name,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected shell: bounce to /login when there's no session.
|
// Protected shell: bounce to /login when there's no session.
|
||||||
@@ -150,20 +255,96 @@ export function AppShell({
|
|||||||
navigate(`/login?next=${next}`, { replace: true })
|
navigate(`/login?next=${next}`, { replace: true })
|
||||||
}
|
}
|
||||||
}, [session, navigate])
|
}, [session, navigate])
|
||||||
if (!session) return null
|
// All hooks must run unconditionally — keep them above the session
|
||||||
|
// 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>(() => {
|
const [expanded, setExpanded] = useState<boolean>(() => {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return true
|
||||||
return localStorage.getItem(SIDEBAR_KEY) === "1"
|
// Default to expanded on first run — an icon-only rail of ~18 pictograms is
|
||||||
|
// unreadable to anyone who hasn't memorised it. Collapse stays available and
|
||||||
|
// is remembered once chosen.
|
||||||
|
const stored = localStorage.getItem(SIDEBAR_KEY)
|
||||||
|
return stored === null ? true : stored === "1"
|
||||||
})
|
})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
|
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
|
||||||
}, [expanded])
|
}, [expanded])
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const [scriptsOpen, setScriptsOpen] = useState(false)
|
const [scriptsOpen, setScriptsOpen] = useState(false)
|
||||||
const BrandIcon = brand.icon
|
|
||||||
|
|
||||||
useScriptsHotkey(() => setScriptsOpen(true))
|
useScriptsHotkey(() => setScriptsOpen(true))
|
||||||
|
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Filter the nav by what the active session can actually reach. A
|
||||||
|
// capability map exists for every protected route — items without one
|
||||||
|
// (or whose capability isn't held) are dropped here, so the sidebar
|
||||||
|
// doesn't advertise routes the user will only hit a 403 from.
|
||||||
|
const allowed = (item: NavItem): boolean => {
|
||||||
|
const cap = capabilityForPath(item.to)
|
||||||
|
if (!cap) return true // unknown routes default to visible
|
||||||
|
return caps.has(cap)
|
||||||
|
}
|
||||||
|
const visiblePinnedTop = useMemo(
|
||||||
|
() => pinnedTop.filter(allowed),
|
||||||
|
[caps],
|
||||||
|
)
|
||||||
|
const visiblePinnedBottom = useMemo(
|
||||||
|
() => pinnedBottom.filter(allowed),
|
||||||
|
[caps],
|
||||||
|
)
|
||||||
|
const visibleNavGroups: NavGroup[] = useMemo(
|
||||||
|
() =>
|
||||||
|
navGroups
|
||||||
|
.map((g) => ({ ...g, items: g.items.filter(allowed) }))
|
||||||
|
.filter((g) => g.items.length > 0),
|
||||||
|
[caps],
|
||||||
|
)
|
||||||
|
const visibleExtraItems = useMemo(
|
||||||
|
() => extraNavItems.filter(allowed),
|
||||||
|
[caps],
|
||||||
|
)
|
||||||
|
const visibleAllNavItems: NavItem[] = useMemo(
|
||||||
|
() => [
|
||||||
|
...visiblePinnedTop,
|
||||||
|
...visibleNavGroups.flatMap((g) => g.items),
|
||||||
|
...visibleExtraItems,
|
||||||
|
...visiblePinnedBottom,
|
||||||
|
],
|
||||||
|
[visiblePinnedTop, visibleNavGroups, visibleExtraItems, visiblePinnedBottom],
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeGroupKey = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleNavGroups.find((g) =>
|
||||||
|
g.items.some((it) => location.pathname.startsWith(it.to)),
|
||||||
|
)?.key ?? null,
|
||||||
|
[location.pathname, visibleNavGroups],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() =>
|
||||||
|
readNavGroupState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-open the group that owns the current route on first mount or
|
||||||
|
// navigation, but never auto-close — the user's explicit toggles win.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeGroupKey) return
|
||||||
|
setOpenGroups((prev) =>
|
||||||
|
prev[activeGroupKey] ? prev : { ...prev, [activeGroupKey]: true },
|
||||||
|
)
|
||||||
|
}, [activeGroupKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
localStorage.setItem(NAV_GROUPS_KEY, JSON.stringify(openGroups))
|
||||||
|
}, [openGroups])
|
||||||
|
|
||||||
|
const toggleGroup = (key: string) =>
|
||||||
|
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
|
||||||
|
if (!session) return null
|
||||||
|
const BrandIcon = brand.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-theme={theme}
|
data-theme={theme}
|
||||||
@@ -179,7 +360,7 @@ export function AppShell({
|
|||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
data-expanded={expanded ? "true" : "false"}
|
data-expanded={expanded ? "true" : "false"}
|
||||||
className={[
|
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",
|
expanded ? "w-60" : "w-16",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
@@ -205,31 +386,67 @@ export function AppShell({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||||
{navItems.map((item) => {
|
{expanded ? (
|
||||||
const Icon = item.icon
|
<>
|
||||||
|
{visiblePinnedTop.map((item) => (
|
||||||
|
<NavRow key={item.label} item={item} expanded />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{visibleNavGroups.map((group) => {
|
||||||
|
const isOpen = !!openGroups[group.key]
|
||||||
|
const GroupIcon = group.icon
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
|
||||||
key={item.label}
|
<button
|
||||||
to={item.to}
|
type="button"
|
||||||
end={item.end}
|
data-action={`nav-group-${group.key}`}
|
||||||
title={expanded ? undefined : item.label}
|
onClick={() => toggleGroup(group.key)}
|
||||||
data-action={`nav-${item.label.toLowerCase()}`}
|
aria-expanded={isOpen}
|
||||||
className={({ isActive }) =>
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
|
||||||
[
|
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors duration-fast ease-standard",
|
|
||||||
expanded ? "justify-start" : "justify-center",
|
|
||||||
isActive
|
|
||||||
? "bg-primary/10 text-primary"
|
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
|
||||||
].join(" ")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon className="size-5 shrink-0" />
|
<GroupIcon className="size-3.5 shrink-0" />
|
||||||
{expanded && <span className="truncate">{item.label}</span>}
|
<span className="flex-1 truncate">{group.label}</span>
|
||||||
</NavLink>
|
<ChevronDown
|
||||||
|
className={[
|
||||||
|
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
|
||||||
|
isOpen ? "" : "-rotate-90",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<NavRow key={item.label} item={item} expanded inGroup />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{visibleExtraItems.length > 0 ? (
|
||||||
|
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||||
|
{visibleExtraItems.map((item) => (
|
||||||
|
<NavRow key={item.label} item={item} expanded />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||||
|
{visiblePinnedBottom.map((item) => (
|
||||||
|
<NavRow key={item.label} item={item} expanded />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Icon-only rail: flat list, no group headers.
|
||||||
|
<>
|
||||||
|
{visibleAllNavItems.map((item) => (
|
||||||
|
<NavRow key={item.label} item={item} expanded={false} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="shrink-0 border-t p-2">
|
<div className="shrink-0 border-t p-2">
|
||||||
@@ -254,6 +471,7 @@ export function AppShell({
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<ImpersonationBanner />
|
||||||
{/* Mobile-only menu trigger, floating top-left of main */}
|
{/* Mobile-only menu trigger, floating top-left of main */}
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
<SheetTrigger
|
<SheetTrigger
|
||||||
@@ -263,8 +481,11 @@ export function AppShell({
|
|||||||
>
|
>
|
||||||
<Menu className="size-5" />
|
<Menu className="size-5" />
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-72 p-0">
|
<SheetContent
|
||||||
<SheetHeader className="border-b">
|
side="left"
|
||||||
|
className="flex h-svh w-72 flex-col p-0"
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 border-b">
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle className="flex items-center gap-2">
|
||||||
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
<BrandIcon className="size-4" />
|
<BrandIcon className="size-4" />
|
||||||
@@ -272,30 +493,81 @@ export function AppShell({
|
|||||||
{brand.name}
|
{brand.name}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav className="flex flex-col gap-1 p-2">
|
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||||
{navItems.map((item) => {
|
{visiblePinnedTop.map((item) => (
|
||||||
const Icon = item.icon
|
<NavRow
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.label}
|
key={item.label}
|
||||||
to={item.to}
|
item={item}
|
||||||
end={item.end}
|
expanded
|
||||||
onClick={() => setMobileOpen(false)}
|
mobile
|
||||||
data-action={`nav-mobile-${item.label.toLowerCase()}`}
|
onNavigate={() => setMobileOpen(false)}
|
||||||
className={({ isActive }) =>
|
/>
|
||||||
[
|
))}
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors",
|
|
||||||
isActive
|
{visibleNavGroups.map((group) => {
|
||||||
? "bg-primary/10 text-primary"
|
const isOpen = !!openGroups[group.key]
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
const GroupIcon = group.icon
|
||||||
].join(" ")
|
return (
|
||||||
}
|
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action={`nav-mobile-group-${group.key}`}
|
||||||
|
onClick={() => toggleGroup(group.key)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Icon className="size-5 shrink-0" />
|
<GroupIcon className="size-3.5 shrink-0" />
|
||||||
<span>{item.label}</span>
|
<span className="flex-1 truncate">{group.label}</span>
|
||||||
</NavLink>
|
<ChevronDown
|
||||||
|
className={[
|
||||||
|
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
|
||||||
|
isOpen ? "" : "-rotate-90",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<NavRow
|
||||||
|
key={item.label}
|
||||||
|
item={item}
|
||||||
|
expanded
|
||||||
|
mobile
|
||||||
|
inGroup
|
||||||
|
onNavigate={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{visibleExtraItems.length > 0 ? (
|
||||||
|
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||||
|
{visibleExtraItems.map((item) => (
|
||||||
|
<NavRow
|
||||||
|
key={item.label}
|
||||||
|
item={item}
|
||||||
|
expanded
|
||||||
|
mobile
|
||||||
|
onNavigate={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||||
|
{visiblePinnedBottom.map((item) => (
|
||||||
|
<NavRow
|
||||||
|
key={item.label}
|
||||||
|
item={item}
|
||||||
|
expanded
|
||||||
|
mobile
|
||||||
|
onNavigate={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -328,7 +600,11 @@ export function AppShell({
|
|||||||
>
|
>
|
||||||
<Avatar className="size-7 cursor-pointer">
|
<Avatar className="size-7 cursor-pointer">
|
||||||
{profile.avatarUrl ? (
|
{profile.avatarUrl ? (
|
||||||
<AvatarImage src={profile.avatarUrl} alt={user.name} />
|
<AvatarImage
|
||||||
|
key={profile.avatarUrl}
|
||||||
|
src={profile.avatarUrl}
|
||||||
|
alt={user.name}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarFallback>{user.initials}</AvatarFallback>
|
<AvatarFallback>{user.initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -355,9 +631,6 @@ export function AppShell({
|
|||||||
>
|
>
|
||||||
<Settings /> Settings
|
<Settings /> Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem data-action="avatar-help">
|
|
||||||
<HelpCircle /> Help
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-action="avatar-signout"
|
data-action="avatar-signout"
|
||||||
@@ -376,9 +649,19 @@ export function AppShell({
|
|||||||
<div
|
<div
|
||||||
id="main-content"
|
id="main-content"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none"
|
className="flex flex-1 flex-col focus:outline-none"
|
||||||
>
|
>
|
||||||
{children}
|
{/* Centered content column. Caps line lengths and frames pages
|
||||||
|
on wide displays so the canvas reads as composed instead of
|
||||||
|
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. */}
|
||||||
|
{/* 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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -388,6 +671,62 @@ export function AppShell({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NavRow({
|
||||||
|
item,
|
||||||
|
expanded,
|
||||||
|
mobile = false,
|
||||||
|
inGroup = false,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
item: NavItem
|
||||||
|
expanded: boolean
|
||||||
|
mobile?: boolean
|
||||||
|
/** True when rendered inside a collapsible group — hides the per-item
|
||||||
|
* icon and indents the label so it aligns under the group header. */
|
||||||
|
inGroup?: boolean
|
||||||
|
onNavigate?: () => void
|
||||||
|
}) {
|
||||||
|
const Icon = item.icon
|
||||||
|
const prefix = mobile ? "nav-mobile-" : "nav-"
|
||||||
|
// Icons are hidden inside groups in the expanded rail. The collapsed
|
||||||
|
// icon-only rail (expanded=false) always shows icons regardless.
|
||||||
|
const showIcon = !inGroup || !expanded
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
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 }) =>
|
||||||
|
[
|
||||||
|
"relative flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard",
|
||||||
|
// 2px left accent rail on active. Absolute-positioned so the rail
|
||||||
|
// anchors to the rail's edge regardless of per-item left padding,
|
||||||
|
// and a fixed 14px height keeps it from filling tall rows.
|
||||||
|
"before:absolute before:left-0 before:top-1/2 before:h-3.5 before:w-[2px] before:-translate-y-1/2 before:rounded-r-full before:bg-primary before:opacity-0 before:transition-opacity before:duration-fast",
|
||||||
|
expanded
|
||||||
|
? inGroup
|
||||||
|
? // Indent the label by chevron(12) + gap(8) = 20px so it
|
||||||
|
// visually aligns under the group header label.
|
||||||
|
"justify-start pl-[1.625rem] pr-3"
|
||||||
|
: "justify-start px-3"
|
||||||
|
: "justify-center px-3",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/[0.08] text-primary before:opacity-100"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
|
].join(" ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showIcon ? <Icon className="size-5 shrink-0" /> : null}
|
||||||
|
{expanded ? <span className="truncate">{item.label}</span> : null}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationDispatcher() {
|
function NotificationDispatcher() {
|
||||||
// Hidden bridge so the action bus can create real notifications:
|
// Hidden bridge so the action bus can create real notifications:
|
||||||
// fill notify-title "Hello"
|
// fill notify-title "Hello"
|
||||||
@@ -458,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() {
|
function NotificationsBell() {
|
||||||
const items = useNotifications()
|
const items = useNotifications()
|
||||||
const unread = unreadCount(items)
|
const unread = unreadCount(items)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
seedIfEmpty()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
|
|||||||
36
app/components/layout/page-header.tsx
Normal file
36
app/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { type ReactNode } from "react"
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: ReactNode
|
||||||
|
description?: ReactNode
|
||||||
|
/** Inline indicators after the title (badges, status pills). */
|
||||||
|
badges?: ReactNode
|
||||||
|
/** Toolbar rendered below the title row — primary actions go here. */
|
||||||
|
actions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-side space for the appbar's floating actions pill is reserved by
|
||||||
|
// the AppShell's first-child padding rule, not here — keep this layout
|
||||||
|
// concerned only with title/description/actions composition.
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
badges,
|
||||||
|
actions,
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{badges}
|
||||||
|
</div>
|
||||||
|
{description ? (
|
||||||
|
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
|
||||||
|
) : null}
|
||||||
|
{actions ? (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">{actions}</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
app/components/route-guard.tsx
Normal file
51
app/components/route-guard.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Per-route capability guard. Wrap the page body — if the active
|
||||||
|
// session doesn't hold the route's capability, render a 403 instead of
|
||||||
|
// the page. Server-side authz is still the real gate; this is UX so a
|
||||||
|
// deep link doesn't 500 inside a route loader that assumes access.
|
||||||
|
|
||||||
|
import { useLocation } from "react-router"
|
||||||
|
import { ShieldAlert } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
capabilityForPath,
|
||||||
|
useCapabilities,
|
||||||
|
type Capability,
|
||||||
|
} from "~/lib/capabilities"
|
||||||
|
import { Card, CardContent } from "~/components/ui/card"
|
||||||
|
|
||||||
|
type RouteGuardProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
/** Override the capability derived from the current path. Useful for
|
||||||
|
* nested routes where you want to check a specific cap. */
|
||||||
|
capability?: Capability
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteGuard({ children, capability }: RouteGuardProps) {
|
||||||
|
const caps = useCapabilities()
|
||||||
|
const location = useLocation()
|
||||||
|
const required = capability ?? capabilityForPath(location.pathname)
|
||||||
|
// No mapping = route is intentionally unguarded (e.g. login flows
|
||||||
|
// never reach AppShell anyway).
|
||||||
|
if (!required) return <>{children}</>
|
||||||
|
if (caps.has(required)) return <>{children}</>
|
||||||
|
return <Forbidden capability={required} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Forbidden({ capability }: { capability: Capability }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
|
||||||
|
<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 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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ export function ScriptsDialog({
|
|||||||
data-action="scripts-dsl"
|
data-action="scripts-dsl"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
placeholder={"navigate /resources\nclick nav-resources"}
|
placeholder={"navigate /tenants\nclick nav-tenants"}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
rows={6}
|
rows={6}
|
||||||
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
|
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||||
|
|||||||
905
app/components/settings/llm-configurations-panel.tsx
Normal file
905
app/components/settings/llm-configurations-panel.tsx
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
// LLM configurations panel.
|
||||||
|
//
|
||||||
|
// One unified surface for everything LLM-config-related: server-persisted
|
||||||
|
// configurations, the per-operator "active" choice (which one the assistant
|
||||||
|
// uses on the next message), and 30-day spend per row. The "active" toggle
|
||||||
|
// writes to the same localStorage key @crema/llm-providers-ui reads via
|
||||||
|
// loadSettings/saveSettings, so the existing assistant code picks it up
|
||||||
|
// without any plumbing changes.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
|
||||||
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import {
|
||||||
|
loadSettings as loadActiveSettings,
|
||||||
|
saveSettings as saveActiveSettings,
|
||||||
|
type LLMProvidersSettings,
|
||||||
|
} from "@crema/llm-providers-ui"
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import { Switch } from "~/components/ui/switch"
|
||||||
|
import {
|
||||||
|
createConfiguration,
|
||||||
|
deleteConfiguration,
|
||||||
|
findSpend,
|
||||||
|
formatCost,
|
||||||
|
getCatalog,
|
||||||
|
getUsageByModel,
|
||||||
|
getUsageSummary,
|
||||||
|
listConfigurations,
|
||||||
|
REASONING_EFFORTS,
|
||||||
|
saveActiveReasoning,
|
||||||
|
updateConfiguration,
|
||||||
|
type CatalogEntry,
|
||||||
|
type LlmConfiguration,
|
||||||
|
type LlmConfigurationInput,
|
||||||
|
type LlmProvider,
|
||||||
|
type LlmUsageSummary,
|
||||||
|
type ReasoningEffort,
|
||||||
|
type UsageByModelRow,
|
||||||
|
} from "~/lib/arcadia/llm-configs"
|
||||||
|
import { listSecrets, type Secret } from "~/lib/arcadia/secrets"
|
||||||
|
|
||||||
|
const PROVIDERS: LlmProvider[] = ["openai", "anthropic", "deepseek", "qwen", "lmstudio"]
|
||||||
|
const LOCAL_SETTINGS_KEY = "crema.llm-providers.settings"
|
||||||
|
|
||||||
|
// Curated picks for the "Seed catalog" empty-state action — operators get
|
||||||
|
// a sensible starting set instead of a blank panel and 19 manual creates.
|
||||||
|
const SEED_PICKS: Array<{ name: string; provider: LlmProvider; model: string }> = [
|
||||||
|
{ name: "GPT-4o mini (cheap default)", provider: "openai", model: "gpt-4o-mini" },
|
||||||
|
{ name: "GPT-4o", provider: "openai", model: "gpt-4o" },
|
||||||
|
{ name: "Claude Sonnet 4.6", provider: "anthropic", model: "claude-sonnet-4-6" },
|
||||||
|
{ name: "Claude Haiku 4.5", provider: "anthropic", model: "claude-haiku-4-5" },
|
||||||
|
{ name: "DeepSeek V4 Flash", provider: "deepseek", model: "deepseek-v4-flash" },
|
||||||
|
{ name: "LM Studio (local)", provider: "lmstudio", model: "local-model" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LlmConfigurationsPanel() {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const [configs, setConfigs] = useState<LlmConfiguration[]>([])
|
||||||
|
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
|
||||||
|
const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
|
||||||
|
const [usageByModel, setUsageByModel] = useState<UsageByModelRow[]>([])
|
||||||
|
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [active, setActive] = useState<LLMProvidersSettings | null>(null)
|
||||||
|
const [editing, setEditing] = useState<LlmConfiguration | "new" | null>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [list, cat, sum, byModel, secs] = await Promise.all([
|
||||||
|
listConfigurations(arcadia),
|
||||||
|
getCatalog(arcadia).catch(() => [] as CatalogEntry[]),
|
||||||
|
getUsageSummary(arcadia, { days: 30 }).catch(() => null),
|
||||||
|
getUsageByModel(arcadia, { days: 30 }).catch(() => [] as UsageByModelRow[]),
|
||||||
|
listSecrets(arcadia).catch(() => [] as Secret[]),
|
||||||
|
])
|
||||||
|
setConfigs(list)
|
||||||
|
setCatalog(cat)
|
||||||
|
setUsage(sum)
|
||||||
|
setUsageByModel(byModel)
|
||||||
|
setSecrets(secs)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load configurations.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [arcadia])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
if (typeof window !== "undefined") setActive(loadActiveSettings())
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const isActive = useCallback(
|
||||||
|
(c: LlmConfiguration) =>
|
||||||
|
!!active &&
|
||||||
|
active.providerId === c.provider &&
|
||||||
|
active.model === c.model &&
|
||||||
|
(active.secretName || "") === (c.secret_name || ""),
|
||||||
|
[active],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onMakeActive = (c: LlmConfiguration) => {
|
||||||
|
const current = loadActiveSettings()
|
||||||
|
saveActiveSettings({
|
||||||
|
...current,
|
||||||
|
providerId: c.provider as LLMProvidersSettings["providerId"],
|
||||||
|
model: c.model,
|
||||||
|
baseURL: c.base_url || undefined,
|
||||||
|
secretName: c.secret_name || undefined,
|
||||||
|
})
|
||||||
|
// Inherit this config's reasoning default. The /ai composer chip
|
||||||
|
// listens for this and updates live; if the operator already
|
||||||
|
// override it via the chip, the next save propagates here.
|
||||||
|
saveActiveReasoning(c.reasoning_effort ?? "off")
|
||||||
|
setActive(loadActiveSettings())
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleEnabled = async (c: LlmConfiguration) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await updateConfiguration(arcadia, c.id, { enabled: !c.enabled })
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Update failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDelete = async (c: LlmConfiguration) => {
|
||||||
|
setError(null)
|
||||||
|
if (!window.confirm(`Delete "${c.name}"? Historical usage rows are preserved.`)) return
|
||||||
|
try {
|
||||||
|
await deleteConfiguration(arcadia, c.id)
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Delete failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSave = async (input: LlmConfigurationInput, existing: LlmConfiguration | null) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (existing) {
|
||||||
|
await updateConfiguration(arcadia, existing.id, input)
|
||||||
|
} else {
|
||||||
|
await createConfiguration(arcadia, input)
|
||||||
|
}
|
||||||
|
setEditing(null)
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
throw e instanceof Error ? e : new Error(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSeed = async () => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
// Seed sequentially to surface conflicts cleanly.
|
||||||
|
for (const pick of SEED_PICKS) {
|
||||||
|
try {
|
||||||
|
await createConfiguration(arcadia, pick)
|
||||||
|
} catch {
|
||||||
|
// skip dupes — they're benign on a re-seed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Seed failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImportFromLocal = async () => {
|
||||||
|
setError(null)
|
||||||
|
const raw = typeof window !== "undefined" ? localStorage.getItem(LOCAL_SETTINGS_KEY) : null
|
||||||
|
if (!raw) {
|
||||||
|
setError("No local settings found to import.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const local = JSON.parse(raw) as LLMProvidersSettings
|
||||||
|
if (!local.providerId || !local.model) {
|
||||||
|
setError("Local settings are incomplete.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await createConfiguration(arcadia, {
|
||||||
|
name: `Imported (${local.providerId})`,
|
||||||
|
provider: local.providerId as LlmProvider,
|
||||||
|
model: local.model,
|
||||||
|
base_url: local.baseURL || null,
|
||||||
|
secret_name: local.secretName || null,
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Import failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLocalSettings =
|
||||||
|
typeof window !== "undefined" && !!localStorage.getItem(LOCAL_SETTINGS_KEY)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle>LLM configurations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Server-persisted provider/model/secret/cost settings. Toggle the star to
|
||||||
|
pick which one the Assistant uses on the next message.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{usage ? (
|
||||||
|
<div className="flex shrink-0 flex-col items-start rounded-md border bg-muted/40 px-3 py-2 text-left sm:items-end sm:text-right">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Spend (30d)
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-base font-semibold tabular-nums">
|
||||||
|
{formatCost(usage.total_cost_cents ?? 0)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{(usage.total_requests ?? 0).toLocaleString()} req ·{" "}
|
||||||
|
{(usage.total_tokens ?? 0).toLocaleString()} tok
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
{hasLocalSettings && configs.length === 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onImportFromLocal}
|
||||||
|
data-action="llm-config-import-local"
|
||||||
|
>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
Import local
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing("new")}
|
||||||
|
data-action="llm-config-add"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-4 text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<EmptyState onSeed={onSeed} onImport={hasLocalSettings ? onImportFromLocal : null} />
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y border-y">
|
||||||
|
{configs.map((c) => (
|
||||||
|
<ConfigRow
|
||||||
|
key={c.id}
|
||||||
|
config={c}
|
||||||
|
spend={findSpend(usageByModel, c)}
|
||||||
|
isActive={isActive(c)}
|
||||||
|
onMakeActive={() => onMakeActive(c)}
|
||||||
|
onToggleEnabled={() => onToggleEnabled(c)}
|
||||||
|
onEdit={() => setEditing(c)}
|
||||||
|
onDelete={() => onDelete(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<ConfigDialog
|
||||||
|
existing={editing === "new" ? null : editing}
|
||||||
|
catalog={catalog}
|
||||||
|
secrets={secrets}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSave={onSave}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Empty state ---------------------------------------------------------
|
||||||
|
|
||||||
|
function EmptyState({
|
||||||
|
onSeed,
|
||||||
|
onImport,
|
||||||
|
}: {
|
||||||
|
onSeed: () => void
|
||||||
|
onImport: (() => void) | null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed bg-muted/20 px-6 py-10 text-center">
|
||||||
|
<Sparkles className="size-6 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm font-medium">No configurations yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Seed a starter set from the curated catalog (GPT-4o, Claude, DeepSeek, LM Studio)
|
||||||
|
and tweak from there.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={onSeed} data-action="llm-config-seed">
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
Seed from catalog
|
||||||
|
</Button>
|
||||||
|
{onImport ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={onImport}>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
Import local
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Single row ----------------------------------------------------------
|
||||||
|
|
||||||
|
function ConfigRow({
|
||||||
|
config: c,
|
||||||
|
spend,
|
||||||
|
isActive,
|
||||||
|
onMakeActive,
|
||||||
|
onToggleEnabled,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
config: LlmConfiguration
|
||||||
|
spend: UsageByModelRow | undefined
|
||||||
|
isActive: boolean
|
||||||
|
onMakeActive: () => void
|
||||||
|
onToggleEnabled: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="flex flex-col items-stretch justify-between gap-3 px-1 py-2.5 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMakeActive}
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label={isActive ? "Active configuration" : "Make active"}
|
||||||
|
data-action={`llm-config-activate-${c.id}`}
|
||||||
|
title={isActive ? "Currently active for this browser" : "Make active for this browser"}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`size-4 ${isActive ? "fill-amber-400 text-amber-400" : ""}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
{c.tenant_id == null ? (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
platform
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!c.enabled ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground">disabled</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{c.provider} · <code className="font-mono">{c.model}</code>
|
||||||
|
{c.secret_name ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· secret <code className="font-mono">{c.secret_name}</code>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatRate(c.input_cost_per_million)}/1M in ·{" "}
|
||||||
|
{formatRate(c.output_cost_per_million)}/1M out
|
||||||
|
{c.reasoning_effort && c.reasoning_effort !== "off" ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· <span className="uppercase tracking-wider">think</span>{" "}
|
||||||
|
<span className="text-[var(--console-amber,oklch(0.78_0.15_60))]">
|
||||||
|
{c.reasoning_effort}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-3 pl-7 sm:pl-0">
|
||||||
|
{spend && spend.cost_cents > 0 ? (
|
||||||
|
<div className="flex flex-col items-end text-right">
|
||||||
|
<span className="font-mono text-sm tabular-nums">
|
||||||
|
{formatCost(spend.cost_cents)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
30d · {spend.requests.toLocaleString()} req
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
checked={c.enabled}
|
||||||
|
onCheckedChange={onToggleEnabled}
|
||||||
|
size="sm"
|
||||||
|
aria-label={c.enabled ? "Disable" : "Enable"}
|
||||||
|
data-action={`llm-config-enabled-${c.id}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onEdit}
|
||||||
|
data-action={`llm-config-edit-${c.id}`}
|
||||||
|
aria-label="Edit"
|
||||||
|
title={c.tenant_id == null ? "Edit platform default (visible to all tenants)" : "Edit"}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
data-action={`llm-config-delete-${c.id}`}
|
||||||
|
aria-label="Delete"
|
||||||
|
title={c.tenant_id == null ? "Delete platform default" : "Delete"}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add/Edit modal ------------------------------------------------------
|
||||||
|
|
||||||
|
function ConfigDialog({
|
||||||
|
existing,
|
||||||
|
catalog,
|
||||||
|
secrets,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
existing: LlmConfiguration | null
|
||||||
|
catalog: CatalogEntry[]
|
||||||
|
secrets: Secret[]
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (
|
||||||
|
input: LlmConfigurationInput,
|
||||||
|
existing: LlmConfiguration | null,
|
||||||
|
) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<LlmConfigurationInput>(
|
||||||
|
existing
|
||||||
|
? {
|
||||||
|
name: existing.name,
|
||||||
|
provider: existing.provider,
|
||||||
|
model: existing.model,
|
||||||
|
base_url: existing.base_url,
|
||||||
|
secret_name: existing.secret_name,
|
||||||
|
input_cost_per_million: existing.input_cost_per_million,
|
||||||
|
output_cost_per_million: existing.output_cost_per_million,
|
||||||
|
enabled: existing.enabled,
|
||||||
|
reasoning_effort: existing.reasoning_effort,
|
||||||
|
}
|
||||||
|
: emptyDraft(),
|
||||||
|
)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const modelsForProvider = useMemo(
|
||||||
|
() => catalog.filter((c) => c.provider === draft.provider && c.model !== "*"),
|
||||||
|
[catalog, draft.provider],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
await onSave(draft, existing)
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : "Save failed.")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = draft.name.trim() !== "" && draft.model.trim() !== ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{existing ? "Edit configuration" : "New configuration"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Costs auto-fill from the curated catalog when you pick a known model.
|
||||||
|
Override below if you have a negotiated rate.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3 py-2 sm:grid-cols-2">
|
||||||
|
<Field label="Name" className="sm:col-span-2">
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
placeholder="Production GPT-4o-mini"
|
||||||
|
autoFocus={!existing}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Provider">
|
||||||
|
<Select
|
||||||
|
value={draft.provider}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDraft({ ...draft, provider: v as LlmProvider, model: "" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROVIDERS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Model">
|
||||||
|
<ModelPicker
|
||||||
|
value={draft.model}
|
||||||
|
models={modelsForProvider}
|
||||||
|
onChange={(model) => {
|
||||||
|
// Auto-fill costs when picking a catalog model.
|
||||||
|
const entry = modelsForProvider.find((m) => m.model === model)
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
model,
|
||||||
|
...(entry && {
|
||||||
|
input_cost_per_million: entry.input_cost_per_million,
|
||||||
|
output_cost_per_million: entry.output_cost_per_million,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Vault secret (optional)" className="sm:col-span-2">
|
||||||
|
<SecretPicker
|
||||||
|
value={draft.secret_name ?? null}
|
||||||
|
secrets={secrets}
|
||||||
|
onChange={(name) => setDraft({ ...draft, secret_name: name })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Base URL (optional)" className="sm:col-span-2">
|
||||||
|
<Input
|
||||||
|
value={draft.base_url ?? ""}
|
||||||
|
onChange={(e) => setDraft({ ...draft, base_url: e.target.value || null })}
|
||||||
|
placeholder="leave blank for provider default"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Input cost (USD per 1M tokens)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
value={draft.input_cost_per_million ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
input_cost_per_million: e.target.value === "" ? null : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="0.15"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Output cost (USD per 1M tokens)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
value={draft.output_cost_per_million ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
output_cost_per_million: e.target.value === "" ? null : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="0.60"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Reasoning effort (thinking models)" className="sm:col-span-2">
|
||||||
|
<Select
|
||||||
|
value={draft.reasoning_effort ?? "off"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
reasoning_effort: (v === "off" ? null : v) as ReasoningEffort | null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{REASONING_EFFORTS.map((e) => (
|
||||||
|
<SelectItem key={e} value={e}>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span className="capitalize">{e}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{reasoningHint(e)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!valid || saving}
|
||||||
|
data-action="llm-config-save"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : existing ? "Save changes" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Model picker (Select + Custom… escape) -------------------------------
|
||||||
|
|
||||||
|
function ModelPicker({
|
||||||
|
value,
|
||||||
|
models,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
models: CatalogEntry[]
|
||||||
|
onChange: (model: string) => void
|
||||||
|
}) {
|
||||||
|
const known = useMemo(() => new Set(models.map((m) => m.model)), [models])
|
||||||
|
const isCustom = value !== "" && !known.has(value)
|
||||||
|
const [customMode, setCustomMode] = useState(isCustom)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCustom) setCustomMode(false)
|
||||||
|
}, [models, isCustom])
|
||||||
|
|
||||||
|
if (customMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="custom-model-id"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomMode(false)
|
||||||
|
onChange("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Catalog
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={known.has(value) ? value : ""}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === "__custom__") {
|
||||||
|
setCustomMode(true)
|
||||||
|
onChange("")
|
||||||
|
} else {
|
||||||
|
onChange(v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={models.length ? "Pick a model…" : "Type a model id"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{models.map((m) => (
|
||||||
|
<SelectItem key={m.model} value={m.model}>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span>{m.model}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
${m.input_cost_per_million.toFixed(2)} / ${m.output_cost_per_million.toFixed(2)} per 1M
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="__custom__">
|
||||||
|
<span className="text-muted-foreground">Custom…</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret picker: Select populated from /api/v1/admin/secrets, filtered to
|
||||||
|
* api_key category (LLM keys live there). Includes a "(none)" option for
|
||||||
|
* keyless providers (lmstudio) and "Type a name…" for secrets that haven't
|
||||||
|
* been created yet — the latter switches to free-text and the user can
|
||||||
|
* type any name; the proxy will fail loudly at request time if it's wrong.
|
||||||
|
*/
|
||||||
|
function SecretPicker({
|
||||||
|
value,
|
||||||
|
secrets,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string | null
|
||||||
|
secrets: Secret[]
|
||||||
|
onChange: (name: string | null) => void
|
||||||
|
}) {
|
||||||
|
const apiKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
secrets
|
||||||
|
.filter((s) => s.category === "api_key" && s.enabled)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[secrets],
|
||||||
|
)
|
||||||
|
|
||||||
|
const known = useMemo(() => new Set(apiKeys.map((s) => s.name)), [apiKeys])
|
||||||
|
const isCustom = value != null && value !== "" && !known.has(value)
|
||||||
|
const [customMode, setCustomMode] = useState(isCustom)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCustom) setCustomMode(false)
|
||||||
|
}, [secrets, isCustom])
|
||||||
|
|
||||||
|
if (customMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
placeholder="secret-name-not-yet-in-vault"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomMode(false)
|
||||||
|
onChange(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pick
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode null as the empty string for the Select — Radix/base-ui can't
|
||||||
|
// bind an actual null/undefined value cleanly.
|
||||||
|
const NONE = "__none__"
|
||||||
|
const CUSTOM = "__custom__"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value == null ? NONE : known.has(value) ? value : ""}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === NONE) onChange(null)
|
||||||
|
else if (v === CUSTOM) {
|
||||||
|
setCustomMode(true)
|
||||||
|
onChange("")
|
||||||
|
} else onChange(v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={apiKeys.length ? "Pick a secret…" : "No api_key secrets yet"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>
|
||||||
|
<span className="text-muted-foreground">(none — keyless / local)</span>
|
||||||
|
</SelectItem>
|
||||||
|
{apiKeys.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.name}>
|
||||||
|
<span className="flex flex-col items-start">
|
||||||
|
<span className="font-mono text-xs">{s.name}</span>
|
||||||
|
{s.description ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{s.description}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value={CUSTOM}>
|
||||||
|
<span className="text-muted-foreground">Type a name…</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1 ${className}`}>
|
||||||
|
<Label className="text-xs">{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraft(): LlmConfigurationInput {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
provider: "openai",
|
||||||
|
model: "",
|
||||||
|
base_url: null,
|
||||||
|
secret_name: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRate(rate: number | null): string {
|
||||||
|
if (rate == null) return "—"
|
||||||
|
if (rate === 0) return "free"
|
||||||
|
return `$${rate.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningHint(e: ReasoningEffort): string {
|
||||||
|
switch (e) {
|
||||||
|
case "off":
|
||||||
|
return "no thinking"
|
||||||
|
case "low":
|
||||||
|
return "~2k thinking tokens"
|
||||||
|
case "medium":
|
||||||
|
return "~8k thinking tokens"
|
||||||
|
case "high":
|
||||||
|
return "~24k thinking tokens"
|
||||||
|
case "max":
|
||||||
|
return "~64k — slowest, most thorough"
|
||||||
|
}
|
||||||
|
}
|
||||||
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({
|
function AlertDialogAction({
|
||||||
className,
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<AlertDialogPrimitive.Close
|
||||||
data-slot="alert-dialog-action"
|
data-slot="alert-dialog-action"
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as React from "react"
|
||||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
@@ -40,19 +41,61 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button component.
|
||||||
|
*
|
||||||
|
* Supports the Radix-style `asChild` ergonomic for cases like
|
||||||
|
* `<Button asChild><Link to="/foo">…</Link></Button>` so the consumer doesn't
|
||||||
|
* have to reach for base-ui's `render` prop directly. Internally translates
|
||||||
|
* `asChild` → base-ui `render` so the underlying `<a>` (or whatever the child
|
||||||
|
* is) actually rendered, instead of nesting a `<button>` around it.
|
||||||
|
*/
|
||||||
|
type ButtonProps = ButtonPrimitive.Props &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
/**
|
||||||
|
* When true, render the single child element instead of a `<button>`.
|
||||||
|
* Compatible with Radix-style usage. Internally bridged to base-ui's
|
||||||
|
* `render` prop.
|
||||||
|
*/
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
|
asChild,
|
||||||
|
children,
|
||||||
|
render,
|
||||||
...props
|
...props
|
||||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
}: ButtonProps) {
|
||||||
|
const mergedClassName = cn(buttonVariants({ variant, size, className }))
|
||||||
|
|
||||||
|
// asChild: take the single child element, hand it to base-ui as the render
|
||||||
|
// target. base-ui merges its props (including className) into the element.
|
||||||
|
if (asChild) {
|
||||||
|
const child = React.Children.only(children) as React.ReactElement
|
||||||
return (
|
return (
|
||||||
<ButtonPrimitive
|
<ButtonPrimitive
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={mergedClassName}
|
||||||
|
render={child}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={mergedClassName}
|
||||||
|
render={render}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ButtonPrimitive>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
export type { ButtonProps }
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} 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 { AlertBanner, ConfirmDialog } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { Badge } from "~/components/ui/badge"
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
// Shared state surface that any admin page can publish to so the assistant
|
|
||||||
// can read live data without scraping the DOM.
|
|
||||||
//
|
|
||||||
// Pages call `useRegisterAdminContext("tenants", { tenants: [...] })` while
|
|
||||||
// mounted; the assistant calls `getAdminContextSnapshot()` each turn to
|
|
||||||
// inject a structured snapshot into the system prompt.
|
|
||||||
|
|
||||||
import { useEffect } from "react"
|
|
||||||
|
|
||||||
type Surface = Record<string, unknown>
|
|
||||||
|
|
||||||
export type AdminContextSnapshot = {
|
|
||||||
route: string
|
|
||||||
surfaces: Record<string, Surface>
|
|
||||||
}
|
|
||||||
|
|
||||||
const surfaces = new Map<string, Surface>()
|
|
||||||
|
|
||||||
export function publishAdminSurface(name: string, data: Surface): void {
|
|
||||||
surfaces.set(name, data)
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
;(window as unknown as { __adminContext?: unknown }).__adminContext = getAdminContextSnapshot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAdminSurface(name: string): void {
|
|
||||||
surfaces.delete(name)
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
;(window as unknown as { __adminContext?: unknown }).__adminContext = getAdminContextSnapshot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminContextSnapshot(): AdminContextSnapshot {
|
|
||||||
const route = typeof window !== "undefined" ? window.location.pathname : ""
|
|
||||||
return {
|
|
||||||
route,
|
|
||||||
surfaces: Object.fromEntries(surfaces.entries()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a snapshot as a markdown block for the LLM system prompt.
|
|
||||||
* Keeps it compact: route, then one section per surface with JSON.
|
|
||||||
*/
|
|
||||||
export function formatAdminContextForPrompt(snapshot = getAdminContextSnapshot()): string {
|
|
||||||
const sections: string[] = [`Admin context (read-only — for answering factual questions):`]
|
|
||||||
sections.push(`Route: ${snapshot.route || "?"}`)
|
|
||||||
const names = Object.keys(snapshot.surfaces)
|
|
||||||
if (names.length === 0) {
|
|
||||||
sections.push(`Surfaces: (none registered)`)
|
|
||||||
} else {
|
|
||||||
for (const name of names) {
|
|
||||||
const json = safeJson(snapshot.surfaces[name])
|
|
||||||
sections.push(`Surface "${name}":\n${json}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sections.join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeJson(value: unknown): string {
|
|
||||||
try {
|
|
||||||
const text = JSON.stringify(value, null, 2)
|
|
||||||
if (text.length > 4000) return text.slice(0, 4000) + "\n…(truncated)"
|
|
||||||
return text
|
|
||||||
} catch {
|
|
||||||
return "(unserializable)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hook: publish a surface while the component is mounted. */
|
|
||||||
export function useRegisterAdminContext(name: string, data: Surface): void {
|
|
||||||
useEffect(() => {
|
|
||||||
publishAdminSurface(name, data)
|
|
||||||
return () => clearAdminSurface(name)
|
|
||||||
}, [name, data])
|
|
||||||
}
|
|
||||||
@@ -5,41 +5,170 @@
|
|||||||
// Each tool is a named function with documented args. The LLM never sees
|
// Each tool is a named function with documented args. The LLM never sees
|
||||||
// raw HTTP — only the menu below.
|
// raw HTTP — only the menu below.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import type { Tool, ToolCall as LLMToolCall } from "@crema/llm-ui"
|
import {
|
||||||
|
createToolRuntime,
|
||||||
|
type ToolDef,
|
||||||
|
} from "@crema/aifirst-ui/tools"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
activateTenant,
|
activateTenant,
|
||||||
|
deactivateTenant,
|
||||||
getTenant,
|
getTenant,
|
||||||
listTenants,
|
listTenants,
|
||||||
suspendTenant,
|
suspendTenant,
|
||||||
type Tenant,
|
type Tenant,
|
||||||
} from "~/lib/arcadia/tenants"
|
} from "~/lib/arcadia/tenants"
|
||||||
|
import {
|
||||||
|
assignRole,
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
removeRole,
|
||||||
|
setUserStatus,
|
||||||
|
updateUser,
|
||||||
|
type UserStatus,
|
||||||
|
} from "~/lib/arcadia/users"
|
||||||
|
import { listMemberships } from "~/lib/arcadia/memberships"
|
||||||
|
import { listRoles } from "~/lib/arcadia/roles"
|
||||||
|
import { revokeUserApiKey } from "~/lib/arcadia/api-keys"
|
||||||
|
import { createRAGClient } from "@crema/lexical-rag-ui"
|
||||||
|
import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas"
|
||||||
|
import { searchAdmin, SearchAdminError } from "~/lib/search-admin"
|
||||||
|
|
||||||
export type ToolCall = {
|
// Lazy singleton — first tool call fetches /docs-index.json, subsequent
|
||||||
name: string
|
// calls reuse the parsed MiniSearch instance.
|
||||||
args: Record<string, unknown>
|
const docsClient = createRAGClient("/docs-index.json")
|
||||||
|
|
||||||
|
// Server-side Tantivy backend (arcadia-search).
|
||||||
|
//
|
||||||
|
// URL: comes from window.__ARCADIA_SEARCH_URL (override hook) or
|
||||||
|
// VITE_ARCADIA_SEARCH_URL build-time env, defaulting to localhost.
|
||||||
|
//
|
||||||
|
// Token (resolution order):
|
||||||
|
// 1. window.__ARCADIA_SEARCH_TOKEN — runtime override hook for tests/devtools.
|
||||||
|
// 2. VITE_ARCADIA_SEARCH_TOKEN — build-time service-principal token.
|
||||||
|
// Required when arcadia-search runs in AUTH_MODE=jwt and arcadia-admin
|
||||||
|
// talks to a remote arcadia whose JWT signing secret arcadia-search
|
||||||
|
// doesn't share. Issue this once from arcadia-admin's service-principal
|
||||||
|
// 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-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
|
||||||
|
return (import.meta as unknown as { env?: Record<string, string | undefined> })
|
||||||
|
.env?.[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolResult = {
|
const KB_BASE_URL: string =
|
||||||
name: string
|
(typeof window !== "undefined" &&
|
||||||
args: Record<string, unknown>
|
(window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) ||
|
||||||
ok: boolean
|
readEnv("VITE_ARCADIA_SEARCH_URL") ||
|
||||||
data?: unknown
|
"http://127.0.0.1:7800"
|
||||||
error?: string
|
|
||||||
|
const KB_SERVICE_TOKEN: string | undefined = readEnv("VITE_ARCADIA_SEARCH_TOKEN")
|
||||||
|
|
||||||
|
type TokenSource = "override" | "service" | "session" | "dev"
|
||||||
|
|
||||||
|
function kbAuthToken(): { token: string; source: TokenSource } {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const override = (window as unknown as { __ARCADIA_SEARCH_TOKEN?: string })
|
||||||
|
.__ARCADIA_SEARCH_TOKEN
|
||||||
|
if (override) return { token: override, source: "override" }
|
||||||
|
}
|
||||||
|
if (KB_SERVICE_TOKEN) return { token: KB_SERVICE_TOKEN, source: "service" }
|
||||||
|
if (typeof window === "undefined") return { token: "dev", source: "dev" }
|
||||||
|
try {
|
||||||
|
const stored = window.sessionStorage.getItem("arcadia_access_token")
|
||||||
|
if (stored) return { token: stored, source: "session" }
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return { token: "dev", source: "dev" }
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolDef = {
|
// True when the operator's session JWT was minted by an arcadia other than
|
||||||
name: string
|
// the one hosting search — i.e. signing keys almost certainly don't match
|
||||||
description: string
|
// and a session-token fallback will 401 silently. We treat any non-localhost
|
||||||
parameters: Record<string, unknown> // JSON Schema for OpenAI tool calling
|
// arcadia URL as "remote" for this heuristic.
|
||||||
isWrite: boolean
|
function isRemoteArcadia(): boolean {
|
||||||
run: (args: Record<string, unknown>, ctx: ToolCtx) => Promise<unknown>
|
const url = readEnv("VITE_ARCADIA_URL") ?? ""
|
||||||
|
if (!url) return false
|
||||||
|
return !/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function kbAuthHint(source: TokenSource): string {
|
||||||
|
if (source === "service" || source === "override") {
|
||||||
|
return "VITE_ARCADIA_SEARCH_TOKEN was rejected — verify it's signed with the secret arcadia-search expects (JWT_HMAC_SECRET) and hasn't expired."
|
||||||
|
}
|
||||||
|
if (source === "session" && isRemoteArcadia()) {
|
||||||
|
return "Set VITE_ARCADIA_SEARCH_TOKEN in arcadia-admin/.env.local to a service-principal JWT signed with arcadia-search's JWT_HMAC_SECRET. The operator session JWT (from a remote arcadia) won't validate against a locally-keyed arcadia-search."
|
||||||
|
}
|
||||||
|
if (source === "session") {
|
||||||
|
return "Operator session JWT was rejected — arcadia-search's JWT_HMAC_SECRET likely doesn't match the arcadia that issued the session. Either align secrets or set VITE_ARCADIA_SEARCH_TOKEN."
|
||||||
|
}
|
||||||
|
return "arcadia-search rejected the dev fallback — it's running in AUTH_MODE=jwt. Set VITE_ARCADIA_SEARCH_TOKEN or restart arcadia-search with AUTH_MODE=dev for local testing."
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kbFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const { token, source } = kbAuthToken()
|
||||||
|
const res = await fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
throw new Error(
|
||||||
|
`arcadia-search rejected the request (${res.status}). ${kbAuthHint(source)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
type KBHit = {
|
||||||
|
chunk_id: string
|
||||||
|
title: string
|
||||||
|
source_path: string
|
||||||
|
heading_path: string
|
||||||
|
tags: string[]
|
||||||
|
snippet: string
|
||||||
|
score: number
|
||||||
|
mtime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kbSearch(
|
||||||
|
query: string,
|
||||||
|
corpus: string,
|
||||||
|
limit: number,
|
||||||
|
tags?: string[],
|
||||||
|
): Promise<{ count: number; hits: KBHit[] }> {
|
||||||
|
const res = await kbFetch(`${KB_BASE_URL}/search`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query, corpus, limit, tags }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`arcadia-search ${res.status}: ${await res.text()}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as { count: number; hits: KBHit[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kbRead(chunkId: string, corpus: string): Promise<unknown> {
|
||||||
|
const url = `${KB_BASE_URL}/chunks/${encodeURIComponent(chunkId)}?corpus=${encodeURIComponent(corpus)}`
|
||||||
|
const res = await kbFetch(url)
|
||||||
|
if (res.status === 404) return null
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`arcadia-search ${res.status}: ${await res.text()}`)
|
||||||
|
}
|
||||||
|
return await res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolCtx = { arcadia: ArcadiaClient }
|
type ToolCtx = { arcadia: ArcadiaClient }
|
||||||
|
|
||||||
const TOOLS: ToolDef[] = [
|
const TOOLS: ToolDef<ToolCtx>[] = [
|
||||||
{
|
{
|
||||||
name: "list_tenants",
|
name: "list_tenants",
|
||||||
description:
|
description:
|
||||||
@@ -221,6 +350,544 @@ const TOOLS: ToolDef[] = [
|
|||||||
return summarize(updated)
|
return summarize(updated)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "deactivate_tenant",
|
||||||
|
description:
|
||||||
|
"Permanently deactivate a tenant by slug. Stronger than suspend — also revokes API keys and disables billing. Reversible only via activate_tenant. Use when a tenant is closing the account, not for short-term holds. Requires user confirmation before executing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
slug: { type: "string", description: "The tenant's slug." },
|
||||||
|
},
|
||||||
|
required: ["slug"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const slug = typeof args.slug === "string" ? args.slug : null
|
||||||
|
if (!slug) throw new Error("deactivate_tenant requires { slug }")
|
||||||
|
const tenants = await listTenants(arcadia)
|
||||||
|
const target = tenants.find((t) => t.slug === slug)
|
||||||
|
if (!target) throw new Error(`No tenant with slug "${slug}"`)
|
||||||
|
const updated = await deactivateTenant(arcadia, target.id)
|
||||||
|
return summarize(updated)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set_user_status",
|
||||||
|
description:
|
||||||
|
"Change a user's status to active, inactive, or suspended. Suspended users cannot sign in; inactive users are hidden from default lists but retain their data. Pass the user's id (UUID). Requires user confirmation before executing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: { type: "string", description: "User UUID." },
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["active", "inactive", "suspended"],
|
||||||
|
description: "Target status.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["user_id", "status"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||||
|
const status = typeof args.status === "string" ? (args.status as UserStatus) : null
|
||||||
|
if (!userId || !status)
|
||||||
|
throw new Error("set_user_status requires { user_id, status }")
|
||||||
|
const updated = await setUserStatus(arcadia, userId, status)
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
status: updated.status,
|
||||||
|
full_name: updated.full_name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_user",
|
||||||
|
description:
|
||||||
|
"Permanently delete a user by id. Cascades to their memberships and API keys. NOT reversible — prefer set_user_status with 'inactive' or 'suspended' unless the user explicitly asks for permanent deletion. Requires user confirmation before executing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: { type: "string", description: "User UUID." },
|
||||||
|
},
|
||||||
|
required: ["user_id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||||
|
if (!userId) throw new Error("delete_user requires { user_id }")
|
||||||
|
await deleteUser(arcadia, userId)
|
||||||
|
return { id: userId, deleted: true }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_memberships",
|
||||||
|
description:
|
||||||
|
"List user-to-tenant memberships. Returns user/tenant pairs with role assignments and primary-membership flag. Filter by tenant_slug to answer 'who's in tenant X', or by user_id to answer 'which tenants does user Y belong to'.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tenant_slug: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional: filter to a single tenant by slug.",
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional: filter to a single user by UUID.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const slug = typeof args.tenant_slug === "string" ? args.tenant_slug : null
|
||||||
|
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||||
|
const all = await listMemberships(arcadia)
|
||||||
|
const filtered = all.filter((m) => {
|
||||||
|
if (slug && m.tenant?.slug !== slug) return false
|
||||||
|
if (userId && m.user?.id !== userId) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return filtered.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
tenant: m.tenant ? { slug: m.tenant.slug, name: m.tenant.name } : null,
|
||||||
|
user: m.user
|
||||||
|
? {
|
||||||
|
id: m.user.id,
|
||||||
|
email: m.user.email,
|
||||||
|
name:
|
||||||
|
[m.user.first_name, m.user.last_name].filter(Boolean).join(" ") ||
|
||||||
|
null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
status: m.status,
|
||||||
|
is_primary: m.is_primary,
|
||||||
|
roles: m.roles.map((r) => r.slug),
|
||||||
|
joined_at: m.joined_at,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_roles",
|
||||||
|
description:
|
||||||
|
"List every role defined in the current tenant. Returns slug, name, description, permission set, and is_system flag. Use to answer 'what roles are available' or before assigning a role.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async (_args, { arcadia }) => {
|
||||||
|
const roles = await listRoles(arcadia)
|
||||||
|
return roles.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
permissions: r.permissions,
|
||||||
|
is_system: r.is_system,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_user",
|
||||||
|
description:
|
||||||
|
"Create a new user in the current tenant. Pass email (required) plus optional first_name, last_name, status, password, and role_ids. If password is omitted the user must set one via the password-reset flow. Requires user confirmation before executing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
email: { type: "string", description: "User email address." },
|
||||||
|
first_name: { type: "string" },
|
||||||
|
last_name: { type: "string" },
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["active", "inactive", "suspended"],
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Optional initial password. Omit to require the user to use the password-reset flow.",
|
||||||
|
},
|
||||||
|
role_ids: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Optional UUIDs of roles to assign on creation.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["email"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const email = typeof args.email === "string" ? args.email : null
|
||||||
|
if (!email) throw new Error("create_user requires { email }")
|
||||||
|
const created = await createUser(arcadia, {
|
||||||
|
email,
|
||||||
|
first_name: typeof args.first_name === "string" ? args.first_name : undefined,
|
||||||
|
last_name: typeof args.last_name === "string" ? args.last_name : undefined,
|
||||||
|
status:
|
||||||
|
typeof args.status === "string"
|
||||||
|
? (args.status as UserStatus)
|
||||||
|
: undefined,
|
||||||
|
password: typeof args.password === "string" ? args.password : undefined,
|
||||||
|
role_ids: Array.isArray(args.role_ids)
|
||||||
|
? (args.role_ids.filter((r) => typeof r === "string") as string[])
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
email: created.email,
|
||||||
|
full_name: created.full_name,
|
||||||
|
status: created.status,
|
||||||
|
roles: created.roles.map((r) => r.slug),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_user",
|
||||||
|
description:
|
||||||
|
"Update a user's name or email by id. For status changes use set_user_status; for role assignment use assign_role/remove_role. Requires user confirmation before executing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: { type: "string", description: "User UUID." },
|
||||||
|
email: { type: "string" },
|
||||||
|
first_name: { type: "string" },
|
||||||
|
last_name: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["user_id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||||
|
if (!userId) throw new Error("update_user requires { user_id }")
|
||||||
|
const patch: Record<string, string> = {}
|
||||||
|
if (typeof args.email === "string") patch.email = args.email
|
||||||
|
if (typeof args.first_name === "string") patch.first_name = args.first_name
|
||||||
|
if (typeof args.last_name === "string") patch.last_name = args.last_name
|
||||||
|
if (Object.keys(patch).length === 0)
|
||||||
|
throw new Error("update_user needs at least one field to change")
|
||||||
|
const updated = await updateUser(arcadia, userId, patch)
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
full_name: updated.full_name,
|
||||||
|
status: updated.status,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "assign_role",
|
||||||
|
description:
|
||||||
|
"Grant a role to a user by user_id and role_id. Idempotent — re-granting an existing role is a no-op. Use list_roles first to find the role's id. Requires user confirmation before executing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: { type: "string", description: "User UUID." },
|
||||||
|
role_id: { type: "string", description: "Role UUID." },
|
||||||
|
},
|
||||||
|
required: ["user_id", "role_id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||||
|
const roleId = typeof args.role_id === "string" ? args.role_id : null
|
||||||
|
if (!userId || !roleId)
|
||||||
|
throw new Error("assign_role requires { user_id, role_id }")
|
||||||
|
const updated = await assignRole(arcadia, userId, roleId)
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
roles: updated.roles.map((r) => r.slug),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove_role",
|
||||||
|
description:
|
||||||
|
"Revoke a role from a user by user_id and role_id. Idempotent — removing a role the user doesn't have is a no-op. Requires user confirmation before executing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: { type: "string", description: "User UUID." },
|
||||||
|
role_id: { type: "string", description: "Role UUID." },
|
||||||
|
},
|
||||||
|
required: ["user_id", "role_id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||||
|
const roleId = typeof args.role_id === "string" ? args.role_id : null
|
||||||
|
if (!userId || !roleId)
|
||||||
|
throw new Error("remove_role requires { user_id, role_id }")
|
||||||
|
const updated = await removeRole(arcadia, userId, roleId)
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
email: updated.email,
|
||||||
|
roles: updated.roles.map((r) => r.slug),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "revoke_api_key",
|
||||||
|
description:
|
||||||
|
"Revoke a user's API key by id. The key stops working immediately and cannot be un-revoked — the user must mint a new one. Use for compromised keys or offboarding. Requires user confirmation before executing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: { type: "string", description: "Owner user UUID." },
|
||||||
|
key_id: { type: "string", description: "API key UUID." },
|
||||||
|
reason: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional audit-log reason for the revocation.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["user_id", "key_id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args, { arcadia }) => {
|
||||||
|
const userId = typeof args.user_id === "string" ? args.user_id : null
|
||||||
|
const keyId = typeof args.key_id === "string" ? args.key_id : null
|
||||||
|
const reason = typeof args.reason === "string" ? args.reason : undefined
|
||||||
|
if (!userId || !keyId)
|
||||||
|
throw new Error("revoke_api_key requires { user_id, key_id }")
|
||||||
|
await revokeUserApiKey(arcadia, userId, keyId, reason)
|
||||||
|
return { user_id: userId, key_id: keyId, revoked: true }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search_docs",
|
||||||
|
description:
|
||||||
|
"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: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Lexical search query. Use specific terms from the docs (endpoint names, schema fields, concept names) — paraphrase poorly.",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Max passages to return. Default 5, cap 10.",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async (args) => {
|
||||||
|
const query = typeof args.query === "string" ? args.query.trim() : ""
|
||||||
|
if (!query) throw new Error("search_docs requires a non-empty { query }")
|
||||||
|
const limit = Math.min(
|
||||||
|
10,
|
||||||
|
Math.max(1, typeof args.limit === "number" ? args.limit : 5),
|
||||||
|
)
|
||||||
|
const hits = await docsClient.search(query, { limit })
|
||||||
|
// Tool-shape parity with the previous searchDocs() return: collapse
|
||||||
|
// tags[] back to category for now so the agent's prior expectations
|
||||||
|
// and any cached examples still parse cleanly.
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
count: hits.length,
|
||||||
|
hits: hits.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
title: h.title,
|
||||||
|
sourcePath: h.sourcePath,
|
||||||
|
category: h.tags[0] ?? "",
|
||||||
|
excerpt: h.excerpt,
|
||||||
|
score: h.score,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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-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: {
|
||||||
|
query: { type: "string", description: "Lexical search query." },
|
||||||
|
corpus: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Which indexed corpus to search. See list_search_corpora for the live set; common values: `docs`, `operator-tools`, `files`.",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Max hits. Default 5, cap 20.",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 20,
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description:
|
||||||
|
"Optional tag filter — return only hits whose chunk has at least one matching tag.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query", "corpus"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async (args) => {
|
||||||
|
const query = typeof args.query === "string" ? args.query.trim() : ""
|
||||||
|
const corpus = typeof args.corpus === "string" ? args.corpus.trim() : ""
|
||||||
|
if (!query) throw new Error("search_kb requires a non-empty { query }")
|
||||||
|
if (!corpus) throw new Error("search_kb requires a { corpus } name")
|
||||||
|
const limit = Math.min(20, Math.max(1, typeof args.limit === "number" ? args.limit : 5))
|
||||||
|
const tags = Array.isArray(args.tags) ? (args.tags as string[]) : undefined
|
||||||
|
return await kbSearch(query, corpus, limit, tags)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read_chunk",
|
||||||
|
description:
|
||||||
|
"Fetch the full body of one chunk by id from the arcadia-search backend, after `search_kb` returned it as a snippet. Use this to expand a hit when the snippet looked promising but you need more context to answer.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
chunk_id: { type: "string", description: "The chunk_id from a prior search_kb hit." },
|
||||||
|
corpus: { type: "string", description: "Same corpus the chunk came from." },
|
||||||
|
},
|
||||||
|
required: ["chunk_id", "corpus"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async (args) => {
|
||||||
|
const chunkId = typeof args.chunk_id === "string" ? args.chunk_id : ""
|
||||||
|
const corpus = typeof args.corpus === "string" ? args.corpus : ""
|
||||||
|
if (!chunkId || !corpus) {
|
||||||
|
throw new Error("read_chunk requires { chunk_id, corpus }")
|
||||||
|
}
|
||||||
|
const result = await kbRead(chunkId, corpus)
|
||||||
|
if (result === null) {
|
||||||
|
return { error: "chunk not found", chunk_id: chunkId, corpus }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_search_corpora",
|
||||||
|
description:
|
||||||
|
"Enumerate the corpora currently configured on the arcadia-search admin sidecar. Returns each tenant's corpora with build status (indexed?, num_docs). Call this when you don't know what corpora exist before invoking `search_kb`, or when the user asks what knowledge is available. Requires the search admin token to be configured.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async () => {
|
||||||
|
try {
|
||||||
|
const tenantsRes = await searchAdmin.listTenants()
|
||||||
|
const tenants = await Promise.all(
|
||||||
|
tenantsRes.tenants.map(async (t) => {
|
||||||
|
try {
|
||||||
|
const c = await searchAdmin.listCorpora(t.id)
|
||||||
|
return {
|
||||||
|
tenant: t.id,
|
||||||
|
corpora: c.corpora.map((cc) => ({
|
||||||
|
corpus: cc.corpus,
|
||||||
|
indexed: cc.indexed,
|
||||||
|
num_docs: cc.num_docs,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { tenant: t.id, corpora: [] }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { tenants }
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SearchAdminError) {
|
||||||
|
return {
|
||||||
|
error: `search-admin ${err.status}: ${err.message}`,
|
||||||
|
hint: "VITE_ARCADIA_SEARCH_ADMIN_TOKEN may be unset, or the sidecar (default :7801) may be down.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rebuild_search_corpus",
|
||||||
|
description:
|
||||||
|
"Trigger a synchronous rebuild of one corpus on arcadia-search. Use when the operator says the index is stale, after they've uploaded new files, or when search_kb returned suspiciously few/old hits. Returns chunk_count and built_at on success. The operator confirms before the rebuild runs (rebuilds can take seconds–minutes depending on corpus size).",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tenant: {
|
||||||
|
type: "string",
|
||||||
|
description: "Search tenant id (e.g. `platform-admin`). See list_search_corpora for available tenants.",
|
||||||
|
},
|
||||||
|
corpus: {
|
||||||
|
type: "string",
|
||||||
|
description: "Corpus name within that tenant (e.g. `docs`, `operator-tools`, `files`).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["tenant", "corpus"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: true,
|
||||||
|
run: async (args) => {
|
||||||
|
const tenant = typeof args.tenant === "string" ? args.tenant.trim() : ""
|
||||||
|
const corpus = typeof args.corpus === "string" ? args.corpus.trim() : ""
|
||||||
|
if (!tenant || !corpus) {
|
||||||
|
throw new Error("rebuild_search_corpus requires { tenant, corpus }")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await searchAdmin.rebuild(tenant, corpus)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SearchAdminError) {
|
||||||
|
return { error: `search-admin ${err.status}: ${err.message}` }
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_block_schema",
|
||||||
|
description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries(
|
||||||
|
BLOCK_INDEX,
|
||||||
|
)
|
||||||
|
.map(([k, v]) => `${k} (${v})`)
|
||||||
|
.join(", ")}.`,
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
kind: {
|
||||||
|
type: "string",
|
||||||
|
description: "The block kind to fetch the schema for.",
|
||||||
|
enum: Object.keys(BLOCK_INDEX),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["kind"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async (args) => {
|
||||||
|
const kind = typeof args.kind === "string" ? args.kind : ""
|
||||||
|
const schema = getBlockSchema(kind)
|
||||||
|
if (!schema) {
|
||||||
|
return {
|
||||||
|
error: `Unknown block kind "${kind}". Available: ${Object.keys(BLOCK_INDEX).join(", ")}.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind, schema }
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
@@ -242,58 +909,6 @@ interface UserEntry {
|
|||||||
roles?: { slug?: string; name?: string }[]
|
roles?: { slug?: string; name?: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OpenAI-format tool list to pass into ChatRequest.tools. */
|
|
||||||
export function getOpenAITools(): Tool[] {
|
|
||||||
return TOOLS.map((t) => ({
|
|
||||||
name: t.name,
|
|
||||||
description: t.description,
|
|
||||||
parameters: t.parameters,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split an LLM tool-call list into reads (run automatically) and writes
|
|
||||||
* (held for user confirmation). Unknown tools fall into reads so the runner
|
|
||||||
* can surface a structured "unknown tool" error to the model. */
|
|
||||||
export function classifyCalls(calls: LLMToolCall[]): {
|
|
||||||
reads: LLMToolCall[]
|
|
||||||
writes: LLMToolCall[]
|
|
||||||
} {
|
|
||||||
const reads: LLMToolCall[] = []
|
|
||||||
const writes: LLMToolCall[] = []
|
|
||||||
for (const c of calls) {
|
|
||||||
const def = TOOL_BY_NAME.get(c.name)
|
|
||||||
if (def?.isWrite) writes.push(c)
|
|
||||||
else reads.push(c)
|
|
||||||
}
|
|
||||||
return { reads, writes }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Synthesise tool-result messages saying the user denied a write call. */
|
|
||||||
export function buildDenialMessages(
|
|
||||||
calls: LLMToolCall[],
|
|
||||||
): { role: "tool"; content: string; toolCallId: string; name: string }[] {
|
|
||||||
return calls.map((c) => ({
|
|
||||||
role: "tool",
|
|
||||||
content: JSON.stringify({
|
|
||||||
error: "User denied this write. Do not retry without re-asking the user.",
|
|
||||||
}),
|
|
||||||
toolCallId: c.id,
|
|
||||||
name: c.name,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pretty-print args for the confirm UI. */
|
|
||||||
export function formatToolCallArgs(c: LLMToolCall): string {
|
|
||||||
try {
|
|
||||||
const parsed = c.arguments ? JSON.parse(c.arguments) : {}
|
|
||||||
const keys = Object.keys(parsed)
|
|
||||||
if (keys.length === 0) return ""
|
|
||||||
return keys.map((k) => `${k}=${JSON.stringify(parsed[k])}`).join(", ")
|
|
||||||
} catch {
|
|
||||||
return c.arguments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(t: Tenant) {
|
function summarize(t: Tenant) {
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
@@ -305,62 +920,15 @@ function summarize(t: Tenant) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOL_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]))
|
const runtime = createToolRuntime(TOOLS)
|
||||||
|
|
||||||
function safeJson(value: unknown): string {
|
export const getOpenAITools = runtime.getOpenAITools
|
||||||
try {
|
export const classifyCalls = runtime.classifyCalls
|
||||||
const text = JSON.stringify(value, null, 2)
|
export const runLLMToolCalls = runtime.runLLMToolCalls
|
||||||
if (text.length > 6000) return text.slice(0, 6000) + "\n…(truncated)"
|
|
||||||
return text
|
|
||||||
} catch {
|
|
||||||
return "(unserializable)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run a list of provider-native tool calls and return `tool` role messages
|
export {
|
||||||
* ready to push back into useChat history. */
|
buildDenialMessages,
|
||||||
export async function runLLMToolCalls(
|
formatToolCallArgs,
|
||||||
calls: LLMToolCall[],
|
type ToolCall,
|
||||||
ctx: ToolCtx,
|
type ToolResult,
|
||||||
opts: { allowWrites?: boolean } = {},
|
} from "@crema/aifirst-ui/tools"
|
||||||
): Promise<{
|
|
||||||
results: ToolResult[]
|
|
||||||
toolMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
|
|
||||||
}> {
|
|
||||||
const results: ToolResult[] = []
|
|
||||||
const toolMessages: { role: "tool"; content: string; toolCallId: string; name: string }[] = []
|
|
||||||
for (const call of calls) {
|
|
||||||
const def = TOOL_BY_NAME.get(call.name)
|
|
||||||
let parsed: Record<string, unknown> = {}
|
|
||||||
try {
|
|
||||||
parsed = call.arguments ? (JSON.parse(call.arguments) as Record<string, unknown>) : {}
|
|
||||||
} catch {
|
|
||||||
const err = `Could not parse arguments JSON: ${call.arguments}`
|
|
||||||
results.push({ name: call.name, args: {}, ok: false, error: err })
|
|
||||||
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!def) {
|
|
||||||
const err = `Unknown tool: ${call.name}`
|
|
||||||
results.push({ name: call.name, args: parsed, ok: false, error: err })
|
|
||||||
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (def.isWrite && !opts.allowWrites) {
|
|
||||||
const err = "Write tools require user confirmation."
|
|
||||||
results.push({ name: call.name, args: parsed, ok: false, error: err })
|
|
||||||
toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await def.run(parsed, ctx)
|
|
||||||
results.push({ name: call.name, args: parsed, ok: true, data })
|
|
||||||
toolMessages.push({ role: "tool", content: safeJson(data), toolCallId: call.id, name: call.name })
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
results.push({ name: call.name, args: parsed, ok: false, error: msg })
|
|
||||||
toolMessages.push({ role: "tool", content: JSON.stringify({ error: msg }), toolCallId: call.id, name: call.name })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { results, toolMessages }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
// Agent personas — named, role-scoped sub-system prompts.
|
// Arcadia Admin's agent roster + migration config.
|
||||||
// Each persona stacks on top of the main systemPrompt to specialize the
|
// The persona machinery lives in @crema/aifirst-ui/agents — this file
|
||||||
// assistant for a task. Persisted in localStorage; reactive across tabs.
|
// just owns the *which personas* config and re-exports the runtime so
|
||||||
|
// route code keeps importing from "~/lib/agents".
|
||||||
|
|
||||||
import { useEffect, useSyncExternalStore } from "react"
|
import { configureAgents, type Agent } from "@crema/aifirst-ui/agents"
|
||||||
|
|
||||||
export type Agent = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
role: string
|
|
||||||
prompt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_AGENTS: Agent[] = [
|
export const DEFAULT_AGENTS: Agent[] = [
|
||||||
{
|
{
|
||||||
@@ -21,148 +15,58 @@ export const DEFAULT_AGENTS: Agent[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "auditor",
|
id: "auditor",
|
||||||
name: "Ledger",
|
name: "Notary",
|
||||||
role: "Auditor",
|
role: "Auditor",
|
||||||
prompt:
|
prompt:
|
||||||
"You're an audit-focused assistant inside Arcadia Admin. Specialise in audit logs, access reviews, and 'who did what when' questions. Always cite the actor_type (user / platform_admin / api_key / system) and timestamp when summarising audit entries. Be cautious about claims you can't back with a tool result — call a tool first.",
|
"You're an audit-focused assistant inside Arcadia Admin. Specialise in audit logs, access reviews, and 'who did what when' questions. Always cite the actor_type (user / platform_admin / api_key / system) and timestamp when summarising audit entries. Be cautious about claims you can't back with a tool result — call a tool first.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "triage",
|
id: "triage",
|
||||||
name: "Beacon",
|
name: "Tracer",
|
||||||
role: "Incident Triage",
|
role: "Incident Triage",
|
||||||
prompt:
|
prompt:
|
||||||
"You're an incident-triage assistant inside Arcadia Admin. When the user reports a problem (a tenant member can't sign in, a billing call is 402'ing, a webhook is failing), walk the diagnostic tree: identify the tenant, check tenant status, check the user's roles, check the billing-config / api-metering / feature-flag overrides as relevant. Suggest impersonation only when it's the right escalation. Keep a clear hypothesis → check → result rhythm.",
|
"You're an incident-triage assistant inside Arcadia Admin. When the user reports a problem (a tenant member can't sign in, a billing call is 402'ing, a webhook is failing), walk the diagnostic tree: identify the tenant, check tenant status, check the user's roles, check the billing-config / api-metering / feature-flag overrides as relevant. Suggest impersonation only when it's the right escalation. Keep a clear hypothesis → check → result rhythm.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "analyst",
|
id: "analyst",
|
||||||
name: "Tally",
|
name: "Census",
|
||||||
role: "Platform Analyst",
|
role: "Platform Analyst",
|
||||||
prompt:
|
prompt:
|
||||||
"You're an analyst inside Arcadia Admin. Answer numerical and aggregate questions across the platform: tenant counts by status, plan distribution, audit-log volume, growth. Always pull live data via tools — never guess from stale snapshots. Present findings in plain prose first, then a small table when the breakdown helps.",
|
"You're an analyst inside Arcadia Admin. Answer numerical and aggregate questions across the platform: tenant counts by status, plan distribution, audit-log volume, growth. Always pull live data via tools — never guess from stale snapshots. Present findings in plain prose first, then a small table when the breakdown helps.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ui-driver",
|
id: "ui-driver",
|
||||||
name: "Cursor",
|
name: "Pilot",
|
||||||
role: "UI Operator",
|
role: "UI Operator",
|
||||||
prompt:
|
prompt:
|
||||||
"You specialise in driving Arcadia Admin's UI on the operator's behalf. Prefer doing over explaining. When the user asks for an action that maps to a UI element, emit an action block immediately (using `data-action` ids the host has documented). For data questions, prefer tool calls over UI navigation.",
|
"You specialise in driving Arcadia Admin's UI on the operator's behalf. Prefer doing over explaining. When the user asks for an action that maps to a UI element, emit an action block immediately (using `data-action` ids the host has documented). For data questions, prefer tool calls over UI navigation.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const STORAGE_KEY = "crema.agents"
|
|
||||||
const ACTIVE_KEY = "crema.assistant.activeAgent"
|
|
||||||
const CHANGE_EVENT = "crema:agents-change"
|
|
||||||
|
|
||||||
function isAgent(v: unknown): v is Agent {
|
|
||||||
return (
|
|
||||||
!!v &&
|
|
||||||
typeof v === "object" &&
|
|
||||||
typeof (v as Agent).id === "string" &&
|
|
||||||
typeof (v as Agent).name === "string" &&
|
|
||||||
typeof (v as Agent).role === "string" &&
|
|
||||||
typeof (v as Agent).prompt === "string"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Old Vibespace agent ids — used to auto-migrate operators stuck on the
|
// Old Vibespace agent ids — used to auto-migrate operators stuck on the
|
||||||
// generic defaults from before Arcadia Admin had its own personas.
|
// generic defaults from before Arcadia Admin had its own personas.
|
||||||
const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"])
|
const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"])
|
||||||
|
|
||||||
function isLegacyDefaultSet(agents: Agent[]): boolean {
|
// Retired arcadia-era persona names. If we see any of these in storage, the
|
||||||
return agents.some((a) => LEGACY_AGENT_IDS.has(a.id))
|
// operator hasn't customised their roster — re-seed with the current names
|
||||||
}
|
// so a rename in DEFAULT_AGENTS actually reaches the UI.
|
||||||
|
const RETIRED_AGENT_NAMES = new Set(["Ledger", "Beacon", "Tally", "Cursor"])
|
||||||
|
|
||||||
function readFromStorage(): Agent[] {
|
configureAgents({
|
||||||
if (typeof window === "undefined") return DEFAULT_AGENTS
|
defaults: DEFAULT_AGENTS,
|
||||||
try {
|
shouldReseed: (stored) =>
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
stored.some((a) => LEGACY_AGENT_IDS.has(a.id)) ||
|
||||||
if (!raw) return DEFAULT_AGENTS
|
stored.some((a) => RETIRED_AGENT_NAMES.has(a.name)),
|
||||||
const parsed = JSON.parse(raw)
|
})
|
||||||
if (!Array.isArray(parsed)) return DEFAULT_AGENTS
|
|
||||||
const cleaned = parsed.filter(isAgent)
|
|
||||||
if (cleaned.length === 0) return DEFAULT_AGENTS
|
|
||||||
if (isLegacyDefaultSet(cleaned)) {
|
|
||||||
// Auto-migrate: stored set still contains pre-arcadia personas.
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_AGENTS))
|
|
||||||
localStorage.removeItem(ACTIVE_KEY)
|
|
||||||
return DEFAULT_AGENTS
|
|
||||||
}
|
|
||||||
return cleaned
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_AGENTS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadAgents(): Agent[] {
|
export {
|
||||||
return readFromStorage()
|
composeSystemPrompt,
|
||||||
}
|
loadActiveAgentId,
|
||||||
|
loadAgents,
|
||||||
export function saveAgents(next: Agent[]) {
|
newAgentId,
|
||||||
if (typeof window === "undefined") return
|
resetAgents,
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
saveActiveAgentId,
|
||||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
saveAgents,
|
||||||
}
|
useAgents,
|
||||||
|
type Agent,
|
||||||
export function resetAgents() {
|
} from "@crema/aifirst-ui/agents"
|
||||||
saveAgents(DEFAULT_AGENTS)
|
|
||||||
}
|
|
||||||
|
|
||||||
let cached: Agent[] | null = null
|
|
||||||
|
|
||||||
function subscribe(cb: () => void): () => void {
|
|
||||||
const onChange = () => {
|
|
||||||
cached = null
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
window.addEventListener(CHANGE_EVENT, onChange)
|
|
||||||
window.addEventListener("storage", (e) => {
|
|
||||||
if (e.key === STORAGE_KEY || e.key === ACTIVE_KEY) onChange()
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(CHANGE_EVENT, onChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSnapshot(): Agent[] {
|
|
||||||
if (!cached) cached = readFromStorage()
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerSnapshot(): Agent[] {
|
|
||||||
return DEFAULT_AGENTS
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgents(): Agent[] {
|
|
||||||
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
||||||
useEffect(() => {
|
|
||||||
cached = null
|
|
||||||
}, [])
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadActiveAgentId(): string {
|
|
||||||
if (typeof window === "undefined") return DEFAULT_AGENTS[0].id
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(ACTIVE_KEY) ?? DEFAULT_AGENTS[0].id
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_AGENTS[0].id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveActiveAgentId(id: string) {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
localStorage.setItem(ACTIVE_KEY, id)
|
|
||||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function composeSystemPrompt(
|
|
||||||
base: string,
|
|
||||||
agent: Agent | undefined,
|
|
||||||
): string {
|
|
||||||
if (!agent) return base
|
|
||||||
return `${base}\n\nActive persona: ${agent.name} — ${agent.role}\n${agent.prompt}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function newAgentId(): string {
|
|
||||||
return `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Domain primer baked into the assistant's system prompt so it understands
|
// 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.
|
// together. Keep this tight — it costs context tokens on every turn.
|
||||||
|
|
||||||
export const ARCADIA_KNOWLEDGE = `Arcadia (the backend you administer):
|
export const ARCADIA_KNOWLEDGE = `Arcadia (the backend you administer):
|
||||||
@@ -16,6 +16,7 @@ Core entities and how they relate:
|
|||||||
- **Audit log entry** — append-only record of who did what. \`actor_type\` is one of: \`user\`, \`platform_admin\`, \`api_key\`, \`system\`. Per-tenant and platform-wide entries coexist.
|
- **Audit log entry** — append-only record of who did what. \`actor_type\` is one of: \`user\`, \`platform_admin\`, \`api_key\`, \`system\`. Per-tenant and platform-wide entries coexist.
|
||||||
- **Feature flag** — boolean / variant gate. Platform-wide default + per-tenant override.
|
- **Feature flag** — boolean / variant gate. Platform-wide default + per-tenant override.
|
||||||
- **Storage / billing config / SSO IdP / inbound webhook / API quota / data retention policy / approval workflow / announcement** — per-tenant or platform-level configurations the operator can manage.
|
- **Storage / billing config / SSO IdP / inbound webhook / API quota / data retention policy / approval workflow / announcement** — per-tenant or platform-level configurations the operator can manage.
|
||||||
|
- **Search corpus** — a Tantivy index over a set of source documents, served by the arcadia-search service. Each corpus belongs to a search tenant (a separate id space from platform tenants — typically \`platform-admin\` for the operator's own knowledge). The operator manages corpora at \`/search\`: create/edit configuration JSON, rebuild on demand, restart the service. Built-ins on \`platform-admin\`: \`docs\` (arcadia architecture), \`operator-tools\` (arcadia-search + arcadia-admin docs), \`files\` (uploaded markdown/text files).
|
||||||
|
|
||||||
Tenant lifecycle (status field):
|
Tenant lifecycle (status field):
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ 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.
|
- 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".
|
- 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.
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
79
app/lib/arcadia/announcements.ts
Normal file
79
app/lib/arcadia/announcements.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Platform announcements helpers.
|
||||||
|
// Backend: /api/v1/admin/announcements (admin CRUD).
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
export type AnnouncementType =
|
||||||
|
| "info"
|
||||||
|
| "warning"
|
||||||
|
| "maintenance"
|
||||||
|
| "incident"
|
||||||
|
| "feature"
|
||||||
|
| string
|
||||||
|
|
||||||
|
export type AnnouncementAudience = "all" | "tenant" | "platform" | string
|
||||||
|
|
||||||
|
export interface Announcement {
|
||||||
|
id: string
|
||||||
|
tenant_id: string | null
|
||||||
|
announcement_type: AnnouncementType
|
||||||
|
title: string
|
||||||
|
body: string | null
|
||||||
|
action_label: string | null
|
||||||
|
action_url: string | null
|
||||||
|
starts_at: string | null
|
||||||
|
ends_at: string | null
|
||||||
|
audience: AnnouncementAudience
|
||||||
|
dismissible: boolean
|
||||||
|
active: boolean
|
||||||
|
created_by_id: string | null
|
||||||
|
inserted_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementInput {
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
announcement_type?: AnnouncementType
|
||||||
|
audience?: AnnouncementAudience
|
||||||
|
action_label?: string | null
|
||||||
|
action_url?: string | null
|
||||||
|
starts_at?: string | null
|
||||||
|
ends_at?: string | null
|
||||||
|
dismissible?: boolean
|
||||||
|
active?: boolean
|
||||||
|
/** Platform-wide if null, otherwise scoped. */
|
||||||
|
tenant_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1/admin/announcements"
|
||||||
|
|
||||||
|
export async function listAnnouncements(arcadia: ArcadiaClient): Promise<Announcement[]> {
|
||||||
|
const res = await arcadia.GET<{ data: Announcement[] }>(BASE)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAnnouncement(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: AnnouncementInput,
|
||||||
|
): Promise<Announcement> {
|
||||||
|
const res = await arcadia.POST<{ data: Announcement }>(BASE, {
|
||||||
|
body: { announcement: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAnnouncement(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<AnnouncementInput>,
|
||||||
|
): Promise<Announcement> {
|
||||||
|
const res = await arcadia.PUT<{ data: Announcement }>(`${BASE}/${id}`, {
|
||||||
|
body: { announcement: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAnnouncement(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/${id}`)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// once — list/show endpoints only return the prefix. Callers must surface
|
// once — list/show endpoints only return the prefix. Callers must surface
|
||||||
// the value to the user immediately on create.
|
// 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 {
|
export interface ApiKey {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Audit log + observability helpers.
|
// Audit log + observability helpers.
|
||||||
// All endpoints are read-only; the backend writes audit events itself.
|
// 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
|
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
|
||||||
|
}
|
||||||
217
app/lib/arcadia/buckets.ts
Normal file
217
app/lib/arcadia/buckets.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// Platform-level bucket management.
|
||||||
|
// 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-core-client"
|
||||||
|
|
||||||
|
export interface Bucket {
|
||||||
|
name: string
|
||||||
|
region?: string
|
||||||
|
size_bytes?: number | null
|
||||||
|
object_count?: number | null
|
||||||
|
created_at?: string | null
|
||||||
|
/** Backend may return additional provider-specific fields. */
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BucketObject {
|
||||||
|
key: string
|
||||||
|
size: number
|
||||||
|
last_modified: string | null
|
||||||
|
etag: string | null
|
||||||
|
storage_class: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListObjectsResponse {
|
||||||
|
objects: BucketObject[]
|
||||||
|
is_truncated: boolean
|
||||||
|
continuation_token: string | null
|
||||||
|
prefix: string | null
|
||||||
|
bucket_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBucketInput {
|
||||||
|
storage_config_id: string
|
||||||
|
bucket_name: string
|
||||||
|
region?: string
|
||||||
|
acl?: "private" | "public-read" | string
|
||||||
|
versioning?: boolean
|
||||||
|
/** Pre-validate without creating. Default false. */
|
||||||
|
dry_run?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteBucketInput {
|
||||||
|
storage_config_id: string
|
||||||
|
bucket_name: string
|
||||||
|
/** 6-digit code from /confirmation-code. */
|
||||||
|
confirmation_code?: string
|
||||||
|
/** DANGEROUS — empty the bucket first. */
|
||||||
|
force_empty?: boolean
|
||||||
|
/** Verify a backup exists before delete. Default true. */
|
||||||
|
verify_backup?: boolean
|
||||||
|
/** Preview-only. Default true on first call so the UI can confirm. */
|
||||||
|
dry_run?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1/platform/buckets"
|
||||||
|
|
||||||
|
export async function listBuckets(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
storageConfigId: string,
|
||||||
|
): Promise<Bucket[]> {
|
||||||
|
const res = await arcadia.GET<{ buckets: Bucket[]; count: number }>(`${BASE}/list`, {
|
||||||
|
params: { storage_config_id: storageConfigId },
|
||||||
|
})
|
||||||
|
return res.buckets ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBucket(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: CreateBucketInput,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/create`, { body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBucket(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: DeleteBucketInput,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/delete`, { body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateConfirmationCode(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
storageConfigId: string,
|
||||||
|
bucketName: string,
|
||||||
|
): Promise<{ code: string; expires_at?: string }> {
|
||||||
|
return arcadia.GET(`${BASE}/confirmation-code`, {
|
||||||
|
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRegions(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
storageConfigId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const res = await arcadia.GET<{ regions?: string[]; data?: string[] }>(`${BASE}/regions`, {
|
||||||
|
params: { storage_config_id: storageConfigId },
|
||||||
|
})
|
||||||
|
return res.regions ?? res.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Versioning / lifecycle / replication / policy / CORS -----------------
|
||||||
|
|
||||||
|
export async function configureVersioning(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: { storage_config_id: string; bucket_name: string; enabled: boolean; dry_run?: boolean },
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/versioning`, { body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configureLifecycle(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: {
|
||||||
|
storage_config_id: string
|
||||||
|
bucket_name: string
|
||||||
|
rules: Array<Record<string, unknown>>
|
||||||
|
dry_run?: boolean
|
||||||
|
},
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/lifecycle`, { body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configureReplication(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: {
|
||||||
|
storage_config_id: string
|
||||||
|
bucket_name: string
|
||||||
|
destination_bucket: string
|
||||||
|
destination_region?: string
|
||||||
|
dry_run?: boolean
|
||||||
|
},
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/replication`, { body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configurePolicy(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: {
|
||||||
|
storage_config_id: string
|
||||||
|
bucket_name: string
|
||||||
|
policy: Record<string, unknown>
|
||||||
|
dry_run?: boolean
|
||||||
|
},
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/policy`, { body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorsRule {
|
||||||
|
allowed_origins: string[]
|
||||||
|
allowed_methods: string[]
|
||||||
|
allowed_headers?: string[]
|
||||||
|
expose_headers?: string[]
|
||||||
|
max_age_seconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCors(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
storageConfigId: string,
|
||||||
|
bucketName: string,
|
||||||
|
): Promise<{ rules: CorsRule[] } | null> {
|
||||||
|
return arcadia.GET(`${BASE}/cors`, {
|
||||||
|
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configureCors(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: {
|
||||||
|
storage_config_id: string
|
||||||
|
bucket_name: string
|
||||||
|
rules: CorsRule[]
|
||||||
|
dry_run?: boolean
|
||||||
|
},
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/cors`, { body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCors(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
storageConfigId: string,
|
||||||
|
bucketName: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.DELETE(`${BASE}/cors`, {
|
||||||
|
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Objects ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listObjects(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
params: {
|
||||||
|
storage_config_id: string
|
||||||
|
bucket_name: string
|
||||||
|
prefix?: string
|
||||||
|
max_keys?: number
|
||||||
|
continuation_token?: string
|
||||||
|
},
|
||||||
|
): Promise<ListObjectsResponse> {
|
||||||
|
return arcadia.GET<ListObjectsResponse>(`${BASE}/objects`, {
|
||||||
|
params: params as Record<string, string | number | boolean | null | undefined>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPresignedUrl(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
params: {
|
||||||
|
storage_config_id: string
|
||||||
|
bucket_name: string
|
||||||
|
key: string
|
||||||
|
expires_in?: number
|
||||||
|
},
|
||||||
|
): Promise<{ url: string; expires_at?: string; expires_in?: number }> {
|
||||||
|
return arcadia.GET(`${BASE}/presigned-url`, {
|
||||||
|
params: params as Record<string, string | number | undefined>,
|
||||||
|
})
|
||||||
|
}
|
||||||
130
app/lib/arcadia/digital-objects.ts
Normal file
130
app/lib/arcadia/digital-objects.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Arcadia digital objects API — minimal client covering the upload flow
|
||||||
|
// 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-core-client"
|
||||||
|
|
||||||
|
export interface DigitalObject {
|
||||||
|
id: string
|
||||||
|
tenant_id?: string
|
||||||
|
user_id?: string
|
||||||
|
storage_config_id?: string
|
||||||
|
filename?: string
|
||||||
|
original_filename?: string
|
||||||
|
content_type?: string
|
||||||
|
size_bytes?: number
|
||||||
|
key?: string
|
||||||
|
object_key?: string
|
||||||
|
status?: "active" | "archived" | "deleted"
|
||||||
|
inserted_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadSession {
|
||||||
|
id: string
|
||||||
|
upload_url: string
|
||||||
|
presigned_url?: string
|
||||||
|
expires_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUploadSessionInput {
|
||||||
|
filename: string
|
||||||
|
content_type: string
|
||||||
|
size_bytes: number
|
||||||
|
storage_config_id?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-step upload: open a session, PUT the bytes to the presigned URL
|
||||||
|
* the session returns, complete the session to land the digital_object.
|
||||||
|
*
|
||||||
|
* Returns the finalized DigitalObject record.
|
||||||
|
*/
|
||||||
|
export async function uploadFile(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
file: File,
|
||||||
|
opts: { storage_config_id?: string; tags?: string[] } = {},
|
||||||
|
): Promise<DigitalObject> {
|
||||||
|
const sessionInput: CreateUploadSessionInput = {
|
||||||
|
filename: file.name,
|
||||||
|
content_type: file.type || "application/octet-stream",
|
||||||
|
size_bytes: file.size,
|
||||||
|
storage_config_id: opts.storage_config_id,
|
||||||
|
tags: opts.tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await arcadia.POST<{ data: UploadSession }>(
|
||||||
|
"/api/v1/digital_objects/upload_sessions",
|
||||||
|
{ body: { upload_session: sessionInput } },
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadUrl = session.data.upload_url || session.data.presigned_url
|
||||||
|
if (!uploadUrl) throw new Error("Upload session returned no upload URL")
|
||||||
|
|
||||||
|
const putRes = await fetch(uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": sessionInput.content_type },
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
if (!putRes.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Upload to storage failed: ${putRes.status} ${await putRes.text().catch(() => "")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = await arcadia.POST<{ data: DigitalObject }>(
|
||||||
|
`/api/v1/digital_objects/upload_sessions/${encodeURIComponent(session.data.id)}/complete`,
|
||||||
|
{ body: {} },
|
||||||
|
)
|
||||||
|
return completed.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDigitalObject(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(`/api/v1/digital_objects/${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the raw bytes of a digital object and return a browser blob URL
|
||||||
|
* suitable for `<img src>`. Used as an immediate-display fallback when
|
||||||
|
* the async variant URLs aren't ready yet (e.g. fresh avatar upload).
|
||||||
|
*
|
||||||
|
* Bypasses the arcadia-client because that client only parses JSON or
|
||||||
|
* text — for binary we need response.blob(). Auth is injected manually
|
||||||
|
* from sessionStorage to match the rest of the auth surface.
|
||||||
|
*
|
||||||
|
* The returned blob URL is per-page; it does NOT survive a reload.
|
||||||
|
* Caller should not persist it to localStorage — only render in memory
|
||||||
|
* until the persistent variant URLs come through (e.g. on next mount).
|
||||||
|
*/
|
||||||
|
export async function fetchDigitalObjectAsBlobUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
id: string,
|
||||||
|
token: string,
|
||||||
|
tenantId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "*/*",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}
|
||||||
|
if (tenantId) headers["X-Tenant-ID"] = tenantId
|
||||||
|
|
||||||
|
const url = `${baseUrl.replace(/\/+$/, "")}/api/v1/digital_objects/${encodeURIComponent(
|
||||||
|
id,
|
||||||
|
)}/content`
|
||||||
|
const res = await fetch(url, { headers })
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch digital object content: ${res.status} ${await res.text().catch(() => "")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const blob = await res.blob()
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.info(
|
||||||
|
`[digital-objects] fetched blob id=${id} type=${blob.type} size=${blob.size}B`,
|
||||||
|
)
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
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}`)
|
||||||
|
}
|
||||||
94
app/lib/arcadia/health.ts
Normal file
94
app/lib/arcadia/health.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Arcadia health probes.
|
||||||
|
//
|
||||||
|
// 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-core commit f427892.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
export type HealthSubsystem = "api" | "db" | "workers" | "storage"
|
||||||
|
|
||||||
|
export type HealthStatus = "ok" | "degraded" | "error" | "unconfigured"
|
||||||
|
|
||||||
|
export interface SubsystemHealth {
|
||||||
|
status: HealthStatus
|
||||||
|
/** Optional human-readable detail. */
|
||||||
|
message?: string
|
||||||
|
/** Free-form metrics — shape is subsystem-specific. */
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverallHealth {
|
||||||
|
status: HealthStatus
|
||||||
|
checked_at: string
|
||||||
|
subsystems: Record<HealthSubsystem, SubsystemHealth>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailedHealth extends OverallHealth {
|
||||||
|
/** BEAM info — present on /health/detailed only. */
|
||||||
|
system?: {
|
||||||
|
otp_release?: string
|
||||||
|
elixir_version?: string
|
||||||
|
process_count?: number
|
||||||
|
memory_total_bytes?: number
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostStats {
|
||||||
|
cpu: {
|
||||||
|
util_pct: number | null
|
||||||
|
per_cpu_pct: number[]
|
||||||
|
load_avg_1: number | null
|
||||||
|
load_avg_5: number | null
|
||||||
|
load_avg_15: number | null
|
||||||
|
schedulers_online: number
|
||||||
|
num_cpus: number | null
|
||||||
|
}
|
||||||
|
memory: {
|
||||||
|
total_bytes: number | null
|
||||||
|
free_bytes: number | null
|
||||||
|
available_bytes: number | null
|
||||||
|
buffered_bytes: number | null
|
||||||
|
cached_bytes: number | null
|
||||||
|
swap_total_bytes: number | null
|
||||||
|
swap_free_bytes: number | null
|
||||||
|
}
|
||||||
|
disks: Array<{ mount: string; total_kb: number; used_pct: number }>
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1/health"
|
||||||
|
|
||||||
|
export async function getHealth(arcadia: ArcadiaClient): Promise<OverallHealth> {
|
||||||
|
const res = await arcadia.GET<{ data: OverallHealth } | OverallHealth>(BASE)
|
||||||
|
return unwrap(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServiceHealth(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
service: HealthSubsystem,
|
||||||
|
): Promise<SubsystemHealth> {
|
||||||
|
const res = await arcadia.GET<{ data: SubsystemHealth } | SubsystemHealth>(
|
||||||
|
`${BASE}/${service}`,
|
||||||
|
)
|
||||||
|
return unwrap(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHealthDetailed(arcadia: ArcadiaClient): Promise<DetailedHealth> {
|
||||||
|
const res = await arcadia.GET<{ data: DetailedHealth } | DetailedHealth>(`${BASE}/detailed`)
|
||||||
|
return unwrap(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHostStats(arcadia: ArcadiaClient): Promise<HostStats> {
|
||||||
|
const res = await arcadia.GET<{ data: HostStats } | HostStats>(`${BASE}/host`)
|
||||||
|
return unwrap(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUBSYSTEMS: HealthSubsystem[] = ["api", "db", "workers", "storage"]
|
||||||
|
|
||||||
|
function unwrap<T>(res: { data: T } | T): T {
|
||||||
|
return res && typeof res === "object" && "data" in (res as object)
|
||||||
|
? (res as { data: T }).data
|
||||||
|
: (res as T)
|
||||||
|
}
|
||||||
40
app/lib/arcadia/integrations.ts
Normal file
40
app/lib/arcadia/integrations.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Integration-registry client (operator surface) — thin shim over the shared
|
||||||
|
// `@crema/integration-registry-client` lib, bound to `operator` mode. The lib
|
||||||
|
// owns the types, the HTTP contract, and the display helpers (shared with
|
||||||
|
// arcadia-console's tenant surface); this file just exposes operator-idiomatic
|
||||||
|
// names so the page reads naturally.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import {
|
||||||
|
createIntegrationsApi,
|
||||||
|
type CredentialInput,
|
||||||
|
type IntegrationInput,
|
||||||
|
type ScopeFilter,
|
||||||
|
} from "@crema/integration-registry-client"
|
||||||
|
|
||||||
|
// Re-export the shared types + helpers so callers import from one place.
|
||||||
|
export * from "@crema/integration-registry-client"
|
||||||
|
|
||||||
|
const op = (c: ArcadiaClient) => createIntegrationsApi(c, "operator")
|
||||||
|
|
||||||
|
export const listIntegrations = (c: ArcadiaClient, filter: ScopeFilter = {}) =>
|
||||||
|
op(c).list(filter)
|
||||||
|
export const createIntegration = (c: ArcadiaClient, input: IntegrationInput) =>
|
||||||
|
op(c).create(input)
|
||||||
|
export const updateIntegration = (
|
||||||
|
c: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<IntegrationInput>,
|
||||||
|
) => op(c).update(id, input)
|
||||||
|
export const deleteIntegration = (c: ArcadiaClient, id: string) => op(c).remove(id)
|
||||||
|
export const addCredential = (c: ArcadiaClient, integrationId: string, input: CredentialInput) =>
|
||||||
|
op(c).addCredential(integrationId, input)
|
||||||
|
export const updateCredential = (
|
||||||
|
c: ArcadiaClient,
|
||||||
|
credentialId: string,
|
||||||
|
input: Partial<CredentialInput>,
|
||||||
|
) => op(c).updateCredential(credentialId, input)
|
||||||
|
export const deleteCredential = (c: ArcadiaClient, credentialId: string) =>
|
||||||
|
op(c).deleteCredential(credentialId)
|
||||||
|
export const testIntegration = (c: ArcadiaClient, id: string) => op(c).test(id)
|
||||||
|
export const usageSummary = (c: ArcadiaClient, filter: ScopeFilter = {}) => op(c).usage(filter)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Arcadia invitations API helpers.
|
// Arcadia invitations API helpers.
|
||||||
// Backed by /api/v1/invitations.
|
// Backed by /api/v1/invitations.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface InvitationRole {
|
export interface InvitationRole {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
246
app/lib/arcadia/llm-configs.ts
Normal file
246
app/lib/arcadia/llm-configs.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
// Arcadia LLM configurations API.
|
||||||
|
//
|
||||||
|
// Backed by /api/v1/admin/llm-configurations — server-side persisted
|
||||||
|
// provider/model/secret/cost settings. Replaces the localStorage-driven
|
||||||
|
// settings the admin UI used previously, so configurations and costs
|
||||||
|
// survive across browsers and operators.
|
||||||
|
//
|
||||||
|
// `tenant_id: null` configurations are platform-defaults visible to
|
||||||
|
// every tenant. Names are unique within (tenant, name).
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
export type LlmProvider = "openai" | "anthropic" | "deepseek" | "qwen" | "lmstudio"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reasoning effort. Sent verbatim to OpenAI / DeepSeek (which take
|
||||||
|
* `reasoning_effort` natively). Translated server-side into Anthropic's
|
||||||
|
* thinking block. `off` (or null) skips the field entirely.
|
||||||
|
*/
|
||||||
|
export type ReasoningEffort = "off" | "low" | "medium" | "high" | "max"
|
||||||
|
export const REASONING_EFFORTS: ReasoningEffort[] = [
|
||||||
|
"off",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"max",
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface LlmConfiguration {
|
||||||
|
id: string
|
||||||
|
tenant_id: string | null
|
||||||
|
name: string
|
||||||
|
provider: LlmProvider
|
||||||
|
model: string
|
||||||
|
base_url: string | null
|
||||||
|
secret_name: string | null
|
||||||
|
input_cost_per_million: number | null
|
||||||
|
output_cost_per_million: number | null
|
||||||
|
enabled: boolean
|
||||||
|
reasoning_effort: ReasoningEffort | null
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
inserted_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmConfigurationInput {
|
||||||
|
tenant_id?: string | null
|
||||||
|
name: string
|
||||||
|
provider: LlmProvider
|
||||||
|
model: string
|
||||||
|
base_url?: string | null
|
||||||
|
secret_name?: string | null
|
||||||
|
/** USD per 1M tokens. Omit to auto-fill from the catalog. */
|
||||||
|
input_cost_per_million?: number | null
|
||||||
|
output_cost_per_million?: number | null
|
||||||
|
enabled?: boolean
|
||||||
|
reasoning_effort?: ReasoningEffort | null
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogEntry {
|
||||||
|
provider: LlmProvider
|
||||||
|
model: string
|
||||||
|
input_cost_per_million: number
|
||||||
|
output_cost_per_million: number
|
||||||
|
context_window: number | null
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1/admin/llm-configurations"
|
||||||
|
|
||||||
|
export async function listConfigurations(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
opts: { enabled?: boolean; tenant_id?: string } = {},
|
||||||
|
): Promise<LlmConfiguration[]> {
|
||||||
|
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||||
|
if (opts.enabled != null) params.enabled = String(opts.enabled)
|
||||||
|
if (opts.tenant_id) params.tenant_id = opts.tenant_id
|
||||||
|
const res = await arcadia.GET<{ data: LlmConfiguration[] }>(BASE, { params })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfiguration(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<LlmConfiguration> {
|
||||||
|
const res = await arcadia.GET<{ data: LlmConfiguration }>(`${BASE}/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfiguration(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: LlmConfigurationInput,
|
||||||
|
): Promise<LlmConfiguration> {
|
||||||
|
const res = await arcadia.POST<{ data: LlmConfiguration }>(BASE, {
|
||||||
|
body: { configuration: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfiguration(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<LlmConfigurationInput>,
|
||||||
|
): Promise<LlmConfiguration> {
|
||||||
|
const res = await arcadia.PATCH<{ data: LlmConfiguration }>(`${BASE}/${id}`, {
|
||||||
|
body: { configuration: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConfiguration(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCatalog(arcadia: ArcadiaClient): Promise<CatalogEntry[]> {
|
||||||
|
const res = await arcadia.GET<{ data: CatalogEntry[] }>(`${BASE}/catalog`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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-core — keep in sync.
|
||||||
|
*/
|
||||||
|
export function computeCostCents(
|
||||||
|
config: Pick<LlmConfiguration, "input_cost_per_million" | "output_cost_per_million">,
|
||||||
|
inputTokens: number,
|
||||||
|
outputTokens: number,
|
||||||
|
): number {
|
||||||
|
const inRate = config.input_cost_per_million ?? 0
|
||||||
|
const outRate = config.output_cost_per_million ?? 0
|
||||||
|
const cents = ((inputTokens * inRate + outputTokens * outRate) / 1_000_000) * 100
|
||||||
|
return Math.round(cents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a cost in cents as "$X.XX" or "$0.0XX" for sub-dollar amounts. */
|
||||||
|
export function formatCost(cents: number): string {
|
||||||
|
if (cents === 0) return "$0"
|
||||||
|
if (cents < 100) return `$${(cents / 100).toFixed(2)}`
|
||||||
|
return `$${(cents / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LLM usage summary (cost roll-up)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface LlmUsageSummary {
|
||||||
|
total_requests: number | null
|
||||||
|
total_input_tokens: number | null
|
||||||
|
total_output_tokens: number | null
|
||||||
|
total_tokens: number | null
|
||||||
|
total_cost_cents: number | null
|
||||||
|
avg_latency_ms: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsageSummary(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
opts: { days?: number } = {},
|
||||||
|
): Promise<LlmUsageSummary> {
|
||||||
|
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||||
|
if (opts.days != null) params.days = opts.days
|
||||||
|
const res = await arcadia.GET<{ data: LlmUsageSummary } | LlmUsageSummary>(
|
||||||
|
"/api/v1/ai/llm/usage/summary",
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return "data" in (res as object) ? (res as { data: LlmUsageSummary }).data : (res as LlmUsageSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageByModelRow {
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
requests: number
|
||||||
|
total_tokens: number
|
||||||
|
cost_cents: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsageByModel(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
opts: { days?: number } = {},
|
||||||
|
): Promise<UsageByModelRow[]> {
|
||||||
|
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||||
|
if (opts.days != null) params.days = opts.days
|
||||||
|
const res = await arcadia.GET<{ data: UsageByModelRow[] } | UsageByModelRow[]>(
|
||||||
|
"/api/v1/ai/llm/usage/by-model",
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return "data" in (res as object) ? (res as { data: UsageByModelRow[] }).data : (res as UsageByModelRow[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the spend row matching a given config's (provider, model). */
|
||||||
|
export function findSpend(
|
||||||
|
rows: UsageByModelRow[],
|
||||||
|
config: Pick<LlmConfiguration, "provider" | "model">,
|
||||||
|
): UsageByModelRow | undefined {
|
||||||
|
return rows.find((r) => r.provider === config.provider && r.model === config.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Active reasoning_effort (shared between settings panel and /ai composer)
|
||||||
|
//
|
||||||
|
// Stored under crema.ai.reasoning. Written when the operator stars a config
|
||||||
|
// in the settings panel (so the chip on /ai inherits that config's default
|
||||||
|
// on next mount) and when the operator cycles the THINK chip on /ai (per-
|
||||||
|
// conversation override). Wiped on Clear conversation.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ACTIVE_REASONING_KEY = "crema.ai.reasoning"
|
||||||
|
const ACTIVE_REASONING_EVENT = "crema:ai-reasoning-change"
|
||||||
|
|
||||||
|
export function loadActiveReasoning(): ReasoningEffort {
|
||||||
|
if (typeof window === "undefined") return "off"
|
||||||
|
const v = localStorage.getItem(ACTIVE_REASONING_KEY) as ReasoningEffort | null
|
||||||
|
return v && REASONING_EFFORTS.includes(v) ? v : "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveActiveReasoning(v: ReasoningEffort): void {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
if (v === "off") localStorage.removeItem(ACTIVE_REASONING_KEY)
|
||||||
|
else localStorage.setItem(ACTIVE_REASONING_KEY, v)
|
||||||
|
window.dispatchEvent(new CustomEvent(ACTIVE_REASONING_EVENT, { detail: v }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeActiveReasoning(
|
||||||
|
listener: (v: ReasoningEffort) => void,
|
||||||
|
): () => void {
|
||||||
|
if (typeof window === "undefined") return () => {}
|
||||||
|
const onChange = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<ReasoningEffort>).detail
|
||||||
|
if (detail) listener(detail)
|
||||||
|
else listener(loadActiveReasoning())
|
||||||
|
}
|
||||||
|
// Same-tab via the custom event; cross-tab via the storage event.
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === ACTIVE_REASONING_KEY) listener(loadActiveReasoning())
|
||||||
|
}
|
||||||
|
window.addEventListener(ACTIVE_REASONING_EVENT, onChange)
|
||||||
|
window.addEventListener("storage", onStorage)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(ACTIVE_REASONING_EVENT, onChange)
|
||||||
|
window.removeEventListener("storage", onStorage)
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/lib/arcadia/llm-proxy.ts
Normal file
182
app/lib/arcadia/llm-proxy.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Arcadia LLM proxy client.
|
||||||
|
//
|
||||||
|
// 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-core-client"
|
||||||
|
|
||||||
|
export type LLMProxyProvider =
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "deepseek"
|
||||||
|
| "qwen"
|
||||||
|
| "lmstudio"
|
||||||
|
|
||||||
|
export type LLMProxyErrorCode =
|
||||||
|
| "unauthorized"
|
||||||
|
| "secret_disabled"
|
||||||
|
| "secret_expired"
|
||||||
|
| "secret_consumed"
|
||||||
|
| "ip_not_allowed"
|
||||||
|
| "unknown_provider"
|
||||||
|
| "upstream_unavailable"
|
||||||
|
| "rate_limited"
|
||||||
|
| "unknown"
|
||||||
|
|
||||||
|
export interface LLMProxyChatRequest {
|
||||||
|
provider: LLMProxyProvider
|
||||||
|
/** Required for every provider except `lmstudio`. */
|
||||||
|
secret_name?: string
|
||||||
|
model: string
|
||||||
|
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>
|
||||||
|
stream?: boolean
|
||||||
|
max_tokens?: number
|
||||||
|
temperature?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMProxyChatResponse {
|
||||||
|
id: string
|
||||||
|
object: "chat.completion"
|
||||||
|
created: number
|
||||||
|
model: string
|
||||||
|
choices: Array<{
|
||||||
|
index: number
|
||||||
|
finish_reason: string | null
|
||||||
|
message: { role: "assistant"; content: string; tool_calls: unknown }
|
||||||
|
}>
|
||||||
|
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LLMProxyError extends Error {
|
||||||
|
readonly code: LLMProxyErrorCode
|
||||||
|
readonly status: number
|
||||||
|
readonly retryAfter?: number
|
||||||
|
|
||||||
|
constructor(code: LLMProxyErrorCode, message: string, status: number, retryAfter?: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = "LLMProxyError"
|
||||||
|
this.code = code
|
||||||
|
this.status = status
|
||||||
|
this.retryAfter = retryAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-streaming chat completion via the proxy. The streaming path is owned
|
||||||
|
* by @crema/llm-providers-ui's buildAdapter; use this for probes and
|
||||||
|
* one-shot calls where SSE is overkill.
|
||||||
|
*/
|
||||||
|
export async function chat(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
req: LLMProxyChatRequest,
|
||||||
|
): Promise<LLMProxyChatResponse> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.POST<LLMProxyChatResponse>(
|
||||||
|
"/api/v1/ai/llm/chat",
|
||||||
|
{ body: { ...req, stream: false } },
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
} catch (e) {
|
||||||
|
throw asProxyError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap end-to-end probe for the Settings "Test connection" flow in proxy
|
||||||
|
* mode. Sends a 1-token "ping" and reports whether the proxy is wired,
|
||||||
|
* the secret resolves, and the upstream answered. Intentionally tolerant
|
||||||
|
* of token-budget rejections — those still prove the round-trip works.
|
||||||
|
*/
|
||||||
|
export async function probeProxy(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
opts: { provider: LLMProxyProvider; model: string; secretName?: string },
|
||||||
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const res = await chat(arcadia, {
|
||||||
|
provider: opts.provider,
|
||||||
|
secret_name: opts.secretName,
|
||||||
|
model: opts.model,
|
||||||
|
messages: [{ role: "user", content: "ping" }],
|
||||||
|
max_tokens: 1,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
const used = res.usage?.total_tokens
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `Proxy OK — ${res.model}${used != null ? ` · ${used} tokens` : ""}.`,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LLMProxyError) {
|
||||||
|
return { ok: false, message: friendly(e) }
|
||||||
|
}
|
||||||
|
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asProxyError(e: unknown): LLMProxyError {
|
||||||
|
// ArcadiaClient throws ArcadiaError with a wrapped { error: { code, message } }
|
||||||
|
// body and HTTP status. Best-effort destructure without coupling to the
|
||||||
|
// class shape (it lives in a sibling lib).
|
||||||
|
if (e && typeof e === "object") {
|
||||||
|
const anyE = e as {
|
||||||
|
status?: number
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
body?: { error?: { code?: string; message?: string } }
|
||||||
|
headers?: Headers | Record<string, string>
|
||||||
|
}
|
||||||
|
const status = anyE.status ?? 0
|
||||||
|
const code = (anyE.body?.error?.code ?? anyE.code) as LLMProxyErrorCode | undefined
|
||||||
|
const message = anyE.body?.error?.message ?? anyE.message ?? "Proxy request failed."
|
||||||
|
const retryAfter = readRetryAfter(anyE.headers)
|
||||||
|
return new LLMProxyError(code ?? inferCodeFromStatus(status), message, status, retryAfter)
|
||||||
|
}
|
||||||
|
return new LLMProxyError("unknown", String(e), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCodeFromStatus(status: number): LLMProxyErrorCode {
|
||||||
|
if (status === 401) return "unauthorized"
|
||||||
|
if (status === 403) return "ip_not_allowed"
|
||||||
|
if (status === 404) return "unknown_provider"
|
||||||
|
if (status === 410) return "secret_expired"
|
||||||
|
if (status === 429) return "rate_limited"
|
||||||
|
if (status === 502 || status === 503 || status === 504) return "upstream_unavailable"
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRetryAfter(h: Headers | Record<string, string> | undefined): number | undefined {
|
||||||
|
if (!h) return undefined
|
||||||
|
const raw = h instanceof Headers ? h.get("retry-after") : h["retry-after"] ?? h["Retry-After"]
|
||||||
|
if (!raw) return undefined
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendly(err: LLMProxyError): string {
|
||||||
|
switch (err.code) {
|
||||||
|
case "unauthorized":
|
||||||
|
return "Sign in expired — refresh and try again."
|
||||||
|
case "secret_disabled":
|
||||||
|
return "The vault secret is disabled. Re-enable it under /secrets."
|
||||||
|
case "secret_expired":
|
||||||
|
return "The vault secret has expired. Rotate it under /secrets."
|
||||||
|
case "secret_consumed":
|
||||||
|
return "Read-once secret already used. Rotate it under /secrets."
|
||||||
|
case "ip_not_allowed":
|
||||||
|
return "This client's IP is blocked by the secret's allowlist."
|
||||||
|
case "unknown_provider":
|
||||||
|
return "The proxy doesn't recognise this provider. Check the provider id."
|
||||||
|
case "upstream_unavailable":
|
||||||
|
return "The upstream LLM provider returned an error or timed out."
|
||||||
|
case "rate_limited":
|
||||||
|
return err.retryAfter
|
||||||
|
? `Rate limited. Retry in ${err.retryAfter}s.`
|
||||||
|
: "Rate limited — slow down and try again."
|
||||||
|
default:
|
||||||
|
return err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/lib/arcadia/memberships.ts
Normal file
96
app/lib/arcadia/memberships.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// 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-core-client"
|
||||||
|
|
||||||
|
export type MembershipStatus = "active" | "suspended" | "deactivated" | string
|
||||||
|
|
||||||
|
export interface MembershipUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembershipTenant {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembershipRole {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Membership {
|
||||||
|
id: string
|
||||||
|
tenant_id: string
|
||||||
|
tenant: MembershipTenant | null
|
||||||
|
user_id: string
|
||||||
|
user: MembershipUser | null
|
||||||
|
status: MembershipStatus
|
||||||
|
is_primary: boolean
|
||||||
|
joined_at: string | null
|
||||||
|
last_accessed_at: string | null
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
roles: MembershipRole[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembershipInput {
|
||||||
|
user_id: string
|
||||||
|
status?: MembershipStatus
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
role_ids?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1/admin/memberships"
|
||||||
|
|
||||||
|
export async function listMemberships(arcadia: ArcadiaClient): Promise<Membership[]> {
|
||||||
|
const res = await arcadia.GET<{ data: Membership[] }>(BASE)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMembership(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: MembershipInput,
|
||||||
|
): Promise<Membership> {
|
||||||
|
const res = await arcadia.POST<{ data: Membership }>(BASE, {
|
||||||
|
body: { membership: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMembership(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<MembershipInput>,
|
||||||
|
): Promise<Membership> {
|
||||||
|
const res = await arcadia.PATCH<{ data: Membership }>(`${BASE}/${id}`, {
|
||||||
|
body: { membership: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMembership(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suspendMembership(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<Membership> {
|
||||||
|
const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/suspend`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateMembership(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<Membership> {
|
||||||
|
const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/activate`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
199
app/lib/arcadia/monitoring.ts
Normal file
199
app/lib/arcadia/monitoring.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// Server stats / health helpers.
|
||||||
|
// Wraps /api/v1/admin/monitoring/* + /api/v1/platform/* + a few observability
|
||||||
|
// endpoints used by the monitoring dashboard.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
// --- Rate limits ---------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RateLimit {
|
||||||
|
type: string
|
||||||
|
max_requests: number
|
||||||
|
window_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRateLimits(arcadia: ArcadiaClient): Promise<RateLimit[]> {
|
||||||
|
const res = await arcadia.GET<{ data: { limits: RateLimit[] } }>(
|
||||||
|
"/api/v1/admin/monitoring/rate-limits",
|
||||||
|
)
|
||||||
|
return res.data.limits ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Active sessions ----------------------------------------------------
|
||||||
|
|
||||||
|
export interface ActiveSession {
|
||||||
|
user_id: string
|
||||||
|
email: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
status: string
|
||||||
|
user_type: string | null
|
||||||
|
last_sign_in_at: string
|
||||||
|
tenant_id: string
|
||||||
|
two_factor_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveSessions(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
): Promise<{ sessions: ActiveSession[]; count: number }> {
|
||||||
|
const res = await arcadia.GET<{ data: { sessions: ActiveSession[]; count: number } }>(
|
||||||
|
"/api/v1/admin/monitoring/sessions",
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Background jobs (Oban) ---------------------------------------------
|
||||||
|
|
||||||
|
export type JobState =
|
||||||
|
| "available"
|
||||||
|
| "executing"
|
||||||
|
| "scheduled"
|
||||||
|
| "retryable"
|
||||||
|
| "discarded"
|
||||||
|
| "cancelled"
|
||||||
|
| "completed"
|
||||||
|
|
||||||
|
export interface JobStats {
|
||||||
|
counts: Record<JobState, number>
|
||||||
|
by_queue: Record<string, Partial<Record<JobState, number>>>
|
||||||
|
queues: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObanJob {
|
||||||
|
id: number
|
||||||
|
queue: string
|
||||||
|
state: JobState
|
||||||
|
worker: string
|
||||||
|
attempt: number
|
||||||
|
max_attempts: number
|
||||||
|
inserted_at: string
|
||||||
|
attempted_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
scheduled_at: string | null
|
||||||
|
errors: Array<{ at?: string; attempt?: number; error?: string }> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJobStats(arcadia: ArcadiaClient): Promise<JobStats> {
|
||||||
|
const res = await arcadia.GET<{ data: JobStats }>(
|
||||||
|
"/api/v1/admin/monitoring/jobs/stats",
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentJobs(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
params?: { limit?: number; state?: JobState; queue?: string },
|
||||||
|
): Promise<ObanJob[]> {
|
||||||
|
const res = await arcadia.GET<{ data: { jobs: ObanJob[]; count: number } }>(
|
||||||
|
"/api/v1/admin/monitoring/jobs",
|
||||||
|
{ params: params as Record<string, string | number | undefined> },
|
||||||
|
)
|
||||||
|
return res.data.jobs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryJob(arcadia: ArcadiaClient, id: number): Promise<void> {
|
||||||
|
await arcadia.POST(`/api/v1/admin/monitoring/jobs/${id}/retry`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Platform infrastructure (DigitalOcean) -----------------------------
|
||||||
|
|
||||||
|
/** Provider returns whatever it returns; admin UI surfaces it loosely. */
|
||||||
|
export type InfrastructureSummary = Record<string, unknown>
|
||||||
|
export type Space = Record<string, unknown>
|
||||||
|
|
||||||
|
export async function getInfrastructureSummary(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
): Promise<InfrastructureSummary | null> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.GET<{ data: InfrastructureSummary }>(
|
||||||
|
"/api/v1/platform/infrastructure/summary",
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSpaces(arcadia: ArcadiaClient): Promise<Space[]> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.GET<{ data: Space[] }>(
|
||||||
|
"/api/v1/platform/infrastructure/spaces",
|
||||||
|
)
|
||||||
|
return res.data ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Droplets ------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Droplet {
|
||||||
|
id: number | string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
region?: { slug?: string; name?: string } | string
|
||||||
|
size_slug?: string
|
||||||
|
vcpus?: number
|
||||||
|
memory?: number
|
||||||
|
disk?: number
|
||||||
|
created_at?: string
|
||||||
|
networks?: unknown
|
||||||
|
/** Provider-specific fields surface verbatim. */
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropletMetrics {
|
||||||
|
cpu?: Array<{ time: string; value: number }>
|
||||||
|
memory?: Array<{ time: string; value: number }>
|
||||||
|
disk?: Array<{ time: string; value: number }>
|
||||||
|
bandwidth?: Array<{ time: string; value: number }>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDroplets(arcadia: ArcadiaClient): Promise<Droplet[]> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.GET<{ droplets?: Droplet[]; data?: Droplet[] }>(
|
||||||
|
"/api/v1/platform/droplets",
|
||||||
|
)
|
||||||
|
return res.droplets ?? res.data ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDropletMetrics(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: number | string,
|
||||||
|
): Promise<DropletMetrics | null> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.GET<{ data: DropletMetrics }>(
|
||||||
|
`/api/v1/platform/droplets/${id}/metrics`,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audit stats (already used by /activity, exposed here for the dashboard) ---
|
||||||
|
|
||||||
|
export interface AuditStats {
|
||||||
|
total: number
|
||||||
|
by_action?: Record<string, number>
|
||||||
|
by_severity?: Record<string, number>
|
||||||
|
by_resource_type?: Record<string, number>
|
||||||
|
/** When backend supports it: { period: ISO, total: number }[] */
|
||||||
|
over_time?: Array<{ period: string; total: number }>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditStats(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
params?: { from?: string; to?: string },
|
||||||
|
): Promise<AuditStats> {
|
||||||
|
const res = await arcadia.GET<{ data: AuditStats }>(
|
||||||
|
"/api/v1/observability/audit_stats",
|
||||||
|
{ params: params as Record<string, string | undefined> },
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
162
app/lib/arcadia/networking.ts
Normal file
162
app/lib/arcadia/networking.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// Networking helpers: firewalls, VPCs, domains + DNS records, floating IPs.
|
||||||
|
// Backend: /api/v1/platform/{firewalls,vpcs,domains,floating_ips,...}.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
const BASE = "/api/v1/platform"
|
||||||
|
|
||||||
|
// --- Firewalls ----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Firewall {
|
||||||
|
id: string | number
|
||||||
|
name: string
|
||||||
|
status?: string
|
||||||
|
inbound_rules?: unknown[]
|
||||||
|
outbound_rules?: unknown[]
|
||||||
|
droplet_ids?: Array<string | number>
|
||||||
|
created_at?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFirewalls(arcadia: ArcadiaClient): Promise<Firewall[]> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.GET<{ firewalls?: Firewall[]; data?: Firewall[] }>(
|
||||||
|
`${BASE}/firewalls`,
|
||||||
|
)
|
||||||
|
return res.firewalls ?? res.data ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFirewall(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: Partial<Firewall>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/firewalls`, { body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFirewall(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string | number,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/firewalls/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VPCs ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Vpc {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
region?: string
|
||||||
|
ip_range?: string
|
||||||
|
default?: boolean
|
||||||
|
created_at?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listVpcs(arcadia: ArcadiaClient): Promise<Vpc[]> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.GET<{ vpcs?: Vpc[]; data?: Vpc[] }>(`${BASE}/vpcs`)
|
||||||
|
return res.vpcs ?? res.data ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Domains + DNS records ----------------------------------------------
|
||||||
|
|
||||||
|
export interface Domain {
|
||||||
|
name: string
|
||||||
|
ttl?: number
|
||||||
|
zone_file?: string | null
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DnsRecord {
|
||||||
|
id: string | number
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
data: string
|
||||||
|
priority?: number | null
|
||||||
|
port?: number | null
|
||||||
|
ttl?: number
|
||||||
|
weight?: number | null
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDomains(arcadia: ArcadiaClient): Promise<Domain[]> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.GET<{ domains?: Domain[]; data?: Domain[] }>(`${BASE}/domains`)
|
||||||
|
return res.domains ?? res.data ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDnsRecords(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
domainName: string,
|
||||||
|
): Promise<DnsRecord[]> {
|
||||||
|
const res = await arcadia.GET<{ domain_records?: DnsRecord[]; data?: DnsRecord[] }>(
|
||||||
|
`${BASE}/domains/${encodeURIComponent(domainName)}/records`,
|
||||||
|
)
|
||||||
|
return res.domain_records ?? res.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDnsRecord(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
domainName: string,
|
||||||
|
input: { type: string; name: string; data: string; ttl?: number; priority?: number },
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/domains/${encodeURIComponent(domainName)}/records`, {
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDnsRecord(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
domainName: string,
|
||||||
|
recordId: string | number,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(
|
||||||
|
`${BASE}/domains/${encodeURIComponent(domainName)}/records/${recordId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Floating IPs -------------------------------------------------------
|
||||||
|
|
||||||
|
export interface FloatingIp {
|
||||||
|
ip: string
|
||||||
|
region?: { slug?: string; name?: string } | string
|
||||||
|
droplet?: { id: number | string; name?: string } | null
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFloatingIps(arcadia: ArcadiaClient): Promise<FloatingIp[]> {
|
||||||
|
try {
|
||||||
|
const res = await arcadia.GET<{ floating_ips?: FloatingIp[]; data?: FloatingIp[] }>(
|
||||||
|
`${BASE}/floating_ips`,
|
||||||
|
)
|
||||||
|
return res.floating_ips ?? res.data ?? []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignFloatingIp(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
ip: string,
|
||||||
|
dropletId: number | string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/floating_ips/${ip}/assign`, {
|
||||||
|
body: { droplet_id: dropletId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unassignFloatingIp(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
ip: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return arcadia.POST(`${BASE}/floating_ips/${ip}/unassign`)
|
||||||
|
}
|
||||||
180
app/lib/arcadia/organizations.ts
Normal file
180
app/lib/arcadia/organizations.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// Organizations — end-user workspaces nested under a tenant.
|
||||||
|
// Backend: /api/v1/organizations + /api/v1/admin/organizations.
|
||||||
|
//
|
||||||
|
// Tenant admins (arcadia-admin) bypass per-org membership checks via the
|
||||||
|
// `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-core-client"
|
||||||
|
|
||||||
|
export type OrgStatus = "active" | "frozen" | "pending_deletion" | string
|
||||||
|
export type OnOwnerRemoval =
|
||||||
|
| "delete"
|
||||||
|
| "require_transfer"
|
||||||
|
| "freeze_until_new_owner"
|
||||||
|
export type OrgRole = "owner" | "admin" | "member"
|
||||||
|
export type MembershipStatus = "active" | "suspended" | "invited" | string
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string
|
||||||
|
tenant_id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
status: OrgStatus
|
||||||
|
on_owner_removal: OnOwnerRemoval
|
||||||
|
settings: Record<string, unknown>
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
inserted_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgMembership {
|
||||||
|
id: string
|
||||||
|
organization_id: string
|
||||||
|
user_id: string
|
||||||
|
role: OrgRole
|
||||||
|
status: MembershipStatus
|
||||||
|
joined_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrgInput {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
on_owner_removal?: OnOwnerRemoval
|
||||||
|
settings?: Record<string, unknown>
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrgInput {
|
||||||
|
name?: string
|
||||||
|
status?: OrgStatus
|
||||||
|
on_owner_removal?: OnOwnerRemoval
|
||||||
|
settings?: Record<string, unknown>
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteByEmailInput {
|
||||||
|
email: string
|
||||||
|
role?: OrgRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddRestrictedUserInput {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
role?: OrgRole
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1/organizations"
|
||||||
|
const ADMIN_BASE = "/api/v1/admin/organizations"
|
||||||
|
|
||||||
|
// Tenant-wide list: every org in the current tenant. Admin-only.
|
||||||
|
export async function listAllOrganizations(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
): Promise<Organization[]> {
|
||||||
|
const res = await arcadia.GET<{ data: Organization[] }>(ADMIN_BASE)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-user list: orgs the current user is a member of.
|
||||||
|
export async function listMyOrganizations(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
): Promise<Organization[]> {
|
||||||
|
const res = await arcadia.GET<{ data: Organization[] }>(BASE)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrganization(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: CreateOrgInput,
|
||||||
|
): Promise<Organization> {
|
||||||
|
const res = await arcadia.POST<{ data: Organization }>(BASE, { body: input })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganization(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<Organization> {
|
||||||
|
const res = await arcadia.GET<{ data: Organization }>(`${BASE}/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrganization(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: UpdateOrgInput,
|
||||||
|
): Promise<Organization> {
|
||||||
|
const res = await arcadia.PATCH<{ data: Organization }>(`${BASE}/${id}`, {
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMembers(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
status?: MembershipStatus,
|
||||||
|
): Promise<OrgMembership[]> {
|
||||||
|
const path = status
|
||||||
|
? `${BASE}/${id}/members?status=${encodeURIComponent(status)}`
|
||||||
|
: `${BASE}/${id}/members`
|
||||||
|
const res = await arcadia.GET<{ data: OrgMembership[] }>(path)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteMember(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: InviteByEmailInput,
|
||||||
|
): Promise<{ type: "membership" | "email_invitation"; [k: string]: unknown }> {
|
||||||
|
const res = await arcadia.POST<{
|
||||||
|
data: { type: "membership" | "email_invitation"; [k: string]: unknown }
|
||||||
|
}>(`${BASE}/${id}/members/invite`, { body: input })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addRestrictedMember(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: AddRestrictedUserInput,
|
||||||
|
): Promise<{ user: { id: string; email: string; account_type: string }; membership: OrgMembership }> {
|
||||||
|
const res = await arcadia.POST<{
|
||||||
|
data: { user: { id: string; email: string; account_type: string }; membership: OrgMembership }
|
||||||
|
}>(`${BASE}/${id}/members/add_restricted`, { body: input })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeMemberRole(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
role: "admin" | "member",
|
||||||
|
): Promise<OrgMembership> {
|
||||||
|
const res = await arcadia.PATCH<{ data: OrgMembership }>(
|
||||||
|
`${BASE}/${id}/members/${userId}/role`,
|
||||||
|
{ body: { role } },
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeMember(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/${id}/members/${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferOwnership(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
newOwnerUserId: string,
|
||||||
|
): Promise<OrgMembership> {
|
||||||
|
const res = await arcadia.POST<{ data: OrgMembership }>(
|
||||||
|
`${BASE}/${id}/transfer_ownership`,
|
||||||
|
{ body: { new_owner_user_id: newOwnerUserId } },
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
79
app/lib/arcadia/profiles.ts
Normal file
79
app/lib/arcadia/profiles.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Arcadia profile API. Backed by /api/v1/profile (current user) — handles
|
||||||
|
// avatar wiring (avatar_digital_object_id + variant URLs) and the basic
|
||||||
|
// profile fields. The "profile" here is the per-tenant profile row, not
|
||||||
|
// the auth account.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
tenant_id: string
|
||||||
|
avatar_url: string | null
|
||||||
|
avatar_digital_object_id: string | null
|
||||||
|
/**
|
||||||
|
* Variant URLs keyed by size (e.g. "thumbnail", "medium", "original").
|
||||||
|
* Shape depends on the storage backend; treat as best-effort.
|
||||||
|
*/
|
||||||
|
avatar_urls?: Record<string, string> | null
|
||||||
|
bio?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
location?: string | null
|
||||||
|
timezone?: string | null
|
||||||
|
inserted_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileUpdateInput {
|
||||||
|
avatar_digital_object_id?: string | null
|
||||||
|
bio?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
location?: string | null
|
||||||
|
timezone?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile(arcadia: ArcadiaClient): Promise<Profile> {
|
||||||
|
const res = await arcadia.GET<{ data: Profile } | Profile>("/api/v1/profile")
|
||||||
|
return "data" in (res as object)
|
||||||
|
? (res as { data: Profile }).data
|
||||||
|
: (res as Profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: ProfileUpdateInput,
|
||||||
|
): Promise<Profile> {
|
||||||
|
const res = await arcadia.PATCH<{ data: Profile } | Profile>("/api/v1/profile", {
|
||||||
|
body: { profile: input },
|
||||||
|
})
|
||||||
|
return "data" in (res as object)
|
||||||
|
? (res as { data: Profile }).data
|
||||||
|
: (res as Profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the most appropriate avatar URL from a profile. Backend returns
|
||||||
|
* `avatar_urls = {small, medium, large, original}` keyed by size. The
|
||||||
|
* variants are populated async after image processing completes —
|
||||||
|
* before that, all four are `null` and we fall back to the legacy
|
||||||
|
* `avatar_url` string column (which is also usually null when uploads
|
||||||
|
* use the digital_object pipeline).
|
||||||
|
*
|
||||||
|
* Returns null when nothing is ready; caller should fall back to
|
||||||
|
* fetching the raw content as a blob URL.
|
||||||
|
*/
|
||||||
|
export function pickAvatarUrl(profile: Profile | null | undefined): string | null {
|
||||||
|
if (!profile) return null
|
||||||
|
const variants = profile.avatar_urls
|
||||||
|
if (variants && typeof variants === "object") {
|
||||||
|
return (
|
||||||
|
variants.small ||
|
||||||
|
variants.medium ||
|
||||||
|
variants.large ||
|
||||||
|
variants.original ||
|
||||||
|
profile.avatar_url ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return profile.avatar_url ?? null
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Arcadia roles API helpers.
|
// Arcadia roles API helpers.
|
||||||
// Backed by /api/v1/roles (resources route, except :new and :edit).
|
// 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 {
|
export interface Role {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Scheduled tasks (cron) helpers.
|
// Scheduled tasks (cron) helpers.
|
||||||
// Backend: /api/v1/admin/scheduled-tasks (CRUD + runs/enable/disable/trigger).
|
// 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"
|
export type ScheduledTaskAction = "webhook" | "event"
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// exposed by these endpoints. Tenant-side resolution (returning the value)
|
// exposed by these endpoints. Tenant-side resolution (returning the value)
|
||||||
// goes through a separate runtime endpoint that's not used by the admin UI.
|
// 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 =
|
export type SecretCategory =
|
||||||
| "api_key"
|
| "api_key"
|
||||||
|
|||||||
99
app/lib/arcadia/sso.ts
Normal file
99
app/lib/arcadia/sso.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// SSO / SAML helpers.
|
||||||
|
// Backend: /api/v1/sso/identity-providers (tenant CRUD) + /sessions.
|
||||||
|
// Note: certificates are large and write-only.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
export interface IdentityProvider {
|
||||||
|
id: string
|
||||||
|
tenant_id: string
|
||||||
|
name: string
|
||||||
|
entity_id: string
|
||||||
|
sso_url: string
|
||||||
|
slo_url: string | null
|
||||||
|
name_id_format: string | null
|
||||||
|
attribute_mapping: Record<string, string>
|
||||||
|
sp_entity_id: string | null
|
||||||
|
sign_requests: boolean
|
||||||
|
metadata_url: string | null
|
||||||
|
callback_url: string | null
|
||||||
|
enabled: boolean
|
||||||
|
has_certificate: boolean
|
||||||
|
inserted_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityProviderInput {
|
||||||
|
name: string
|
||||||
|
entity_id: string
|
||||||
|
sso_url: string
|
||||||
|
slo_url?: string | null
|
||||||
|
name_id_format?: string | null
|
||||||
|
attribute_mapping?: Record<string, string>
|
||||||
|
sp_entity_id?: string | null
|
||||||
|
sign_requests?: boolean
|
||||||
|
metadata_url?: string | null
|
||||||
|
callback_url?: string | null
|
||||||
|
enabled?: boolean
|
||||||
|
/** PEM cert from the IdP. Write-only. */
|
||||||
|
certificate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SamlSession {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
idp_id: string
|
||||||
|
name_id: string | null
|
||||||
|
session_index: string | null
|
||||||
|
expires_at: string | null
|
||||||
|
inserted_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1/sso"
|
||||||
|
|
||||||
|
export async function listIdentityProviders(arcadia: ArcadiaClient): Promise<IdentityProvider[]> {
|
||||||
|
const res = await arcadia.GET<{ data: IdentityProvider[] }>(`${BASE}/identity-providers`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIdentityProvider(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: IdentityProviderInput,
|
||||||
|
): Promise<IdentityProvider> {
|
||||||
|
const res = await arcadia.POST<{ data: IdentityProvider }>(
|
||||||
|
`${BASE}/identity-providers`,
|
||||||
|
{ body: { identity_provider: input } },
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIdentityProvider(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<IdentityProviderInput>,
|
||||||
|
): Promise<IdentityProvider> {
|
||||||
|
const res = await arcadia.PATCH<{ data: IdentityProvider }>(
|
||||||
|
`${BASE}/identity-providers/${id}`,
|
||||||
|
{ body: { identity_provider: input } },
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIdentityProvider(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/identity-providers/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSamlSessions(arcadia: ArcadiaClient): Promise<SamlSession[]> {
|
||||||
|
const res = await arcadia.GET<{ data: SamlSession[] }>(`${BASE}/sessions`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySamlSession(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/sessions/${sessionId}`)
|
||||||
|
}
|
||||||
172
app/lib/arcadia/status-page.ts
Normal file
172
app/lib/arcadia/status-page.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// Status page helpers — components, incidents, subscribers.
|
||||||
|
// Backend: /api/v1/admin/status-page/* (admin CRUD).
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
export type ComponentStatus =
|
||||||
|
| "operational"
|
||||||
|
| "degraded_performance"
|
||||||
|
| "partial_outage"
|
||||||
|
| "major_outage"
|
||||||
|
| "maintenance"
|
||||||
|
| string
|
||||||
|
|
||||||
|
export interface StatusComponent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
status: ComponentStatus
|
||||||
|
display_order: number
|
||||||
|
group_name: string | null
|
||||||
|
inserted_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IncidentStatus =
|
||||||
|
| "investigating"
|
||||||
|
| "identified"
|
||||||
|
| "monitoring"
|
||||||
|
| "resolved"
|
||||||
|
| string
|
||||||
|
|
||||||
|
export type IncidentImpact = "none" | "minor" | "major" | "critical" | string
|
||||||
|
|
||||||
|
export interface IncidentUpdate {
|
||||||
|
id: string
|
||||||
|
status: IncidentStatus
|
||||||
|
body: string
|
||||||
|
inserted_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Incident {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: IncidentStatus
|
||||||
|
impact: IncidentImpact
|
||||||
|
resolved_at: string | null
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
updates: IncidentUpdate[]
|
||||||
|
components: StatusComponent[]
|
||||||
|
inserted_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subscriber {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
confirmed_at: string | null
|
||||||
|
inserted_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentInput {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
status?: ComponentStatus
|
||||||
|
display_order?: number
|
||||||
|
group_name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncidentInput {
|
||||||
|
title: string
|
||||||
|
status?: IncidentStatus
|
||||||
|
impact?: IncidentImpact
|
||||||
|
/** IDs of affected components. */
|
||||||
|
component_ids?: string[]
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncidentUpdateInput {
|
||||||
|
status: IncidentStatus
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1/admin/status-page"
|
||||||
|
|
||||||
|
// --- Components ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listComponents(arcadia: ArcadiaClient): Promise<StatusComponent[]> {
|
||||||
|
const res = await arcadia.GET<{ data: StatusComponent[] }>(`${BASE}/components`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createComponent(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: ComponentInput,
|
||||||
|
): Promise<StatusComponent> {
|
||||||
|
const res = await arcadia.POST<{ data: StatusComponent }>(`${BASE}/components`, {
|
||||||
|
body: { component: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateComponent(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<ComponentInput>,
|
||||||
|
): Promise<StatusComponent> {
|
||||||
|
const res = await arcadia.PUT<{ data: StatusComponent }>(`${BASE}/components/${id}`, {
|
||||||
|
body: { component: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComponent(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/components/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Incidents ----------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listIncidents(arcadia: ArcadiaClient): Promise<Incident[]> {
|
||||||
|
const res = await arcadia.GET<{ data: Incident[] }>(`${BASE}/incidents`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIncident(arcadia: ArcadiaClient, id: string): Promise<Incident> {
|
||||||
|
const res = await arcadia.GET<{ data: Incident }>(`${BASE}/incidents/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIncident(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: IncidentInput,
|
||||||
|
): Promise<Incident> {
|
||||||
|
const res = await arcadia.POST<{ data: Incident }>(`${BASE}/incidents`, {
|
||||||
|
body: { incident: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIncident(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<IncidentInput>,
|
||||||
|
): Promise<Incident> {
|
||||||
|
const res = await arcadia.PUT<{ data: Incident }>(`${BASE}/incidents/${id}`, {
|
||||||
|
body: { incident: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveIncident(arcadia: ArcadiaClient, id: string): Promise<Incident> {
|
||||||
|
const res = await arcadia.POST<{ data: Incident }>(`${BASE}/incidents/${id}/resolve`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addIncidentUpdate(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
incidentId: string,
|
||||||
|
input: IncidentUpdateInput,
|
||||||
|
): Promise<IncidentUpdate> {
|
||||||
|
const res = await arcadia.POST<{ data: IncidentUpdate }>(
|
||||||
|
`${BASE}/incidents/${incidentId}/updates`,
|
||||||
|
{ body: { update: input } },
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subscribers --------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listSubscribers(arcadia: ArcadiaClient): Promise<Subscriber[]> {
|
||||||
|
const res = await arcadia.GET<{ data: Subscriber[] }>(`${BASE}/subscribers`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
// generic `arcadia.GET<T>` / `arcadia.POST<T>` / etc. — same pattern as
|
// generic `arcadia.GET<T>` / `arcadia.POST<T>` / etc. — same pattern as
|
||||||
// `tenants.ts`. Switch to `arcadia.typed.*` when the spec gains coverage.
|
// `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 StorageBackend = "s3" | "local" | "gcs"
|
||||||
export type StorageStatus = "active" | "inactive" | "degraded" | "maintenance"
|
export type StorageStatus = "active" | "inactive" | "degraded" | "maintenance"
|
||||||
@@ -149,7 +149,9 @@ export const SECRET_FIELDS: Record<StorageBackend, readonly string[]> = {
|
|||||||
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
|
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||||
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
|
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
|
||||||
gcs: ["bucket", "service_account_json"],
|
gcs: ["bucket", "service_account_json"],
|
||||||
local: ["path"],
|
// Local backend's filesystem root. Backend changeset rejects "path" — must
|
||||||
|
// be `base_path`. Keep this in sync with `Arcadia.Storage.Adapters.Local`.
|
||||||
|
local: ["base_path"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OPTIONAL_FIELDS: Record<StorageBackend, readonly string[]> = {
|
export const OPTIONAL_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
|
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
|
||||||
// and drop these manual types.
|
// and drop these manual types.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, type ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type TenantStatus = "active" | "suspended" | "deactivated" | string
|
export type TenantStatus = "active" | "suspended" | "deactivated" | string
|
||||||
|
|
||||||
@@ -91,3 +91,447 @@ export async function deactivateTenant(arcadia: ArcadiaClient, id: string): Prom
|
|||||||
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`)
|
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProvisionTenantInput {
|
||||||
|
tenant: { name: string; slug: string }
|
||||||
|
admin_user: {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provisionTenant(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: ProvisionTenantInput,
|
||||||
|
): Promise<Tenant> {
|
||||||
|
const res = await arcadia.POST<{ data: Tenant }>("/api/v1/admin/tenants/provision", {
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
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.
|
// Per-user usage + quota helpers.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface UserUsage {
|
export interface UserUsage {
|
||||||
storage_used_bytes: number
|
storage_used_bytes: number
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// describe these operations as typed paths, so we hand-roll types and use
|
// 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.
|
// 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"
|
export type UserStatus = "active" | "inactive" | "suspended"
|
||||||
|
|
||||||
@@ -109,3 +109,41 @@ export async function setUserStatus(
|
|||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
return updateUser(arcadia, id, { status })
|
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.
|
// Outbound webhook helpers.
|
||||||
// Backend: /api/v1/webhooks (CRUD + pause/resume/regenerate-secret/deliveries/stats/test).
|
// 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 WebhookStatus = "active" | "paused" | "disabled"
|
||||||
export type WebhookRetryStrategy = "linear" | "exponential"
|
export type WebhookRetryStrategy = "linear" | "exponential"
|
||||||
|
|||||||
247
app/lib/block-schemas.ts
Normal file
247
app/lib/block-schemas.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// Lazy-fetched schemas for the typed fenced blocks the assistant can emit.
|
||||||
|
// The system prompt only ships a thin index (kind → one-line purpose). Full
|
||||||
|
// JSON schemas + examples live here and are pulled on demand via the
|
||||||
|
// `get_block_schema` tool. Keeps the always-on prompt small and lets new
|
||||||
|
// blocks be added by editing this file alone — no prompt edits required.
|
||||||
|
//
|
||||||
|
// Renderer is in app/components/assistant/message-body.tsx — keep these in
|
||||||
|
// sync (kinds, field names) when adding or changing blocks.
|
||||||
|
|
||||||
|
export type BlockKind =
|
||||||
|
| "kpi"
|
||||||
|
| "table"
|
||||||
|
| "chart-bar"
|
||||||
|
| "chart-line"
|
||||||
|
| "chart-donut"
|
||||||
|
| "chart-spark"
|
||||||
|
| "code"
|
||||||
|
| "diff"
|
||||||
|
| "card"
|
||||||
|
| "flowchart"
|
||||||
|
| "orgchart"
|
||||||
|
| "steps"
|
||||||
|
| "checklist"
|
||||||
|
| "welcome"
|
||||||
|
| "hint"
|
||||||
|
|
||||||
|
export const BLOCK_INDEX: Record<BlockKind, string> = {
|
||||||
|
kpi: "Headline numbers row (2–6 metrics).",
|
||||||
|
table: "Tabular data (≥3 rows or ≥3 columns).",
|
||||||
|
"chart-bar": "Compare ≤8 categories.",
|
||||||
|
"chart-line": "Ordered series / trend over time.",
|
||||||
|
"chart-donut": "Part-to-whole, ≤5 slices.",
|
||||||
|
"chart-spark": "Inline trend, no axes.",
|
||||||
|
code: "Syntax-highlighted snippet (SQL, JSON, YAML, etc).",
|
||||||
|
diff: "Before/after comparison.",
|
||||||
|
card: "Inline pill, stat chip, or callout banner.",
|
||||||
|
flowchart: "Process / decision flow with shaped nodes (start/end/process/decision/io).",
|
||||||
|
orgchart: "Tree of nested entities (org structure, dependency tree, taxonomy).",
|
||||||
|
steps: "Multi-step plan with statuses (queued/running/done/error/skipped).",
|
||||||
|
checklist: "Onboarding checklist with completable tasks (links/CTAs allowed).",
|
||||||
|
welcome: "Hero welcome card with title, description, primary/secondary CTA.",
|
||||||
|
hint: "Tip / lightbulb card with tone (info/success/warning/neutral/primary).",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMAS: Record<BlockKind, string> = {
|
||||||
|
kpi: `\`\`\`kpi
|
||||||
|
{ "items": [
|
||||||
|
{ "label": "Tenants", "value": 42 },
|
||||||
|
{ "label": "Active users", "value": 318, "unit": "/day" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: items[]: { label: string, value: string|number, unit?: string }.
|
||||||
|
Use 2–6 items. Don't repeat the numbers in prose.`,
|
||||||
|
|
||||||
|
table: `\`\`\`table
|
||||||
|
{ "columns": [
|
||||||
|
{ "id": "slug", "header": "Tenant" },
|
||||||
|
{ "id": "users", "header": "Users", "align": "right" },
|
||||||
|
{ "id": "status", "header": "Status" }
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
{ "slug": "acme", "users": 42, "status": "active" },
|
||||||
|
{ "slug": "globex", "users": 18, "status": "suspended" }
|
||||||
|
],
|
||||||
|
"idKey": "slug" }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- columns[]: { id: string, header?: string, align?: "left"|"center"|"right", sortable?: boolean }
|
||||||
|
- rows[]: object keyed by column id.
|
||||||
|
- idKey?: string — column whose value is the row id (defaults to first column).
|
||||||
|
Use for ≥3 rows OR ≥3 columns. Smaller lists → markdown table.`,
|
||||||
|
|
||||||
|
"chart-bar": `\`\`\`chart-bar
|
||||||
|
{ "title": "Users by tenant",
|
||||||
|
"data": [
|
||||||
|
{ "label": "acme", "value": 42 },
|
||||||
|
{ "label": "globex", "value": 18 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||||
|
≤8 categories. For more, use a table.`,
|
||||||
|
|
||||||
|
"chart-line": `\`\`\`chart-line
|
||||||
|
{ "title": "Signups over time",
|
||||||
|
"series": [
|
||||||
|
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: title?: string, series[]: { x: number, y: number }.
|
||||||
|
Use for ordered numeric series ≥3 points. x is treated as a numeric axis.`,
|
||||||
|
|
||||||
|
"chart-donut": `\`\`\`chart-donut
|
||||||
|
{ "title": "Status breakdown",
|
||||||
|
"data": [
|
||||||
|
{ "label": "active", "value": 38 },
|
||||||
|
{ "label": "suspended", "value": 4 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||||
|
≤5 slices. Skip if one slice would be >90%.`,
|
||||||
|
|
||||||
|
"chart-spark": `\`\`\`chart-spark
|
||||||
|
{ "values": [3, 5, 4, 8, 12, 9, 14] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: values: number[], width?, height?, stroke?, fill?.
|
||||||
|
Use inline next to a single number to show its recent trend.`,
|
||||||
|
|
||||||
|
code: `\`\`\`code
|
||||||
|
{ "code": "SELECT count(*) FROM tenants WHERE status='active';",
|
||||||
|
"language": "sql",
|
||||||
|
"title": "Active tenant count",
|
||||||
|
"lineNumbers": false,
|
||||||
|
"highlightLines": [] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: code: string, language?: string, title?: string, lineNumbers?: boolean, highlightLines?: number[].
|
||||||
|
Languages with syntax: js/ts/tsx, python, rust, go, html, css, sql, json, yaml.
|
||||||
|
Prefer this over plain markdown fences when the snippet matters (queries the user might copy, configs, etc.).`,
|
||||||
|
|
||||||
|
diff: `\`\`\`diff
|
||||||
|
{ "oldCode": "max_users: 100\\n",
|
||||||
|
"newCode": "max_users: 250\\n",
|
||||||
|
"language": "yaml",
|
||||||
|
"title": "Tenant quota change",
|
||||||
|
"mode": "unified" }
|
||||||
|
\`\`\`
|
||||||
|
Fields: oldCode: string, newCode: string, language?: string, title?: string, mode?: "unified"|"split".
|
||||||
|
Use for showing exactly what changed in a config, query, or file.`,
|
||||||
|
|
||||||
|
card: `\`\`\`card
|
||||||
|
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "This action is destructive." }
|
||||||
|
\`\`\`
|
||||||
|
Three sub-kinds:
|
||||||
|
- pill: { "kind": "pill", "status": "active"|"suspended"|"deactivated"|other, "label"?: string } — small status badge.
|
||||||
|
- stat: { "kind": "stat", "label": string, "value": string|number } — inline metric chip.
|
||||||
|
- callout: { "kind": "callout", "tone": "info"|"warning"|"danger"|"success", "title"?: string, "body"?: string } — banner.
|
||||||
|
Use sparingly. For multiple metrics use \`kpi\` instead of multiple \`stat\` cards.`,
|
||||||
|
|
||||||
|
flowchart: `\`\`\`flowchart
|
||||||
|
{ "nodes": [
|
||||||
|
{ "id": "a", "type": "start", "label": "Receive request", "x": 60, "y": 20 },
|
||||||
|
{ "id": "b", "type": "process", "label": "Validate token", "x": 60, "y": 100 },
|
||||||
|
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 60, "y": 180 },
|
||||||
|
{ "id": "d", "type": "process", "label": "Process", "x": 220, "y": 180 },
|
||||||
|
{ "id": "e", "type": "end", "label": "Reject", "x": 60, "y": 280 }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "a", "to": "b" },
|
||||||
|
{ "from": "b", "to": "c" },
|
||||||
|
{ "from": "c", "to": "d", "label": "yes" },
|
||||||
|
{ "from": "c", "to": "e", "label": "no" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- nodes[]: { id: string, type: "start"|"end"|"process"|"decision"|"io", label: string, x: number, y: number } — coordinates in pixels (canvas auto-sizes).
|
||||||
|
- edges[]: { from: nodeId, to: nodeId, label?: string }.
|
||||||
|
Use for control flow, workflows, request lifecycles. Keep ≤12 nodes; lay out top-to-bottom or left-to-right with ~80–120px spacing.`,
|
||||||
|
|
||||||
|
orgchart: `\`\`\`orgchart
|
||||||
|
{ "data": {
|
||||||
|
"id": "root", "name": "Platform", "title": "Tenant",
|
||||||
|
"children": [
|
||||||
|
{ "id": "a", "name": "Auth", "title": "Service",
|
||||||
|
"children": [
|
||||||
|
{ "id": "a1", "name": "Sessions", "title": "Module" },
|
||||||
|
{ "id": "a2", "name": "MFA", "title": "Module" }
|
||||||
|
] },
|
||||||
|
{ "id": "b", "name": "Billing", "title": "Service" }
|
||||||
|
] },
|
||||||
|
"horizontal": false }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- data: OrgNode = { id: string, name: string, title?: string, avatar?: string (url), children?: OrgNode[] }
|
||||||
|
- horizontal?: boolean — left-to-right vs top-to-bottom (default).
|
||||||
|
Use for nested hierarchies (org charts, dependency trees, taxonomies). Skip for flat lists.`,
|
||||||
|
|
||||||
|
steps: `\`\`\`steps
|
||||||
|
{ "steps": [
|
||||||
|
{ "id": "1", "title": "List tenants", "status": "done", "detail": "Found 42 tenants" },
|
||||||
|
{ "id": "2", "title": "Filter suspended", "status": "running" },
|
||||||
|
{ "id": "3", "title": "Build report", "status": "queued" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- steps[]: { id: string, title: string, status: "queued"|"planning"|"running"|"waiting"|"done"|"error"|"skipped", detail?: string, substeps?: same-shape[] }
|
||||||
|
Use for: showing a multi-step plan you're about to execute, or a post-hoc trail of what you did. Skip for single-step actions.`,
|
||||||
|
|
||||||
|
checklist: `\`\`\`checklist
|
||||||
|
{ "title": "Get started",
|
||||||
|
"description": "Finish setting up your tenant.",
|
||||||
|
"tasks": [
|
||||||
|
{ "id": "1", "title": "Invite your team", "description": "Add at least one admin.", "completed": true, "estimate": "2 min" },
|
||||||
|
{ "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets", "estimate": "5 min" },
|
||||||
|
{ "id": "3", "title": "Set up SSO", "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- title?: string, description?: string
|
||||||
|
- tasks[]: { id: string, title: string, description?: string, completed?: boolean, optional?: boolean, estimate?: string, href?: string }
|
||||||
|
Use for: actionable setup lists with progress. Each task with an href becomes a click-through link. Toggling is read-only in chat (can't persist completion across turns).`,
|
||||||
|
|
||||||
|
welcome: `\`\`\`welcome
|
||||||
|
{ "title": "Welcome to Arcadia Admin",
|
||||||
|
"description": "Manage tenants, users, and platform settings from one place.",
|
||||||
|
"badge": "v2",
|
||||||
|
"primaryAction": { "label": "Create your first tenant", "href": "/tenants" },
|
||||||
|
"secondaryAction": { "label": "Read the docs", "href": "/library" } }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- title: string (required), description?: string, badge?: string
|
||||||
|
- primaryAction?, secondaryAction?: { label: string, href?: string }
|
||||||
|
Use sparingly — once at the top of a thread that's introducing a feature/product, never as a recurring response.`,
|
||||||
|
|
||||||
|
hint: `\`\`\`hint
|
||||||
|
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- title?: string, body: string
|
||||||
|
- tone?: "info"|"success"|"warning"|"neutral"|"primary" (default "info")
|
||||||
|
- action?: { label: string, href?: string }
|
||||||
|
Use for: discoverability tips, gotchas, "did you know". One per reply.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_KINDS = Object.keys(SCHEMAS) as BlockKind[]
|
||||||
|
|
||||||
|
export function isBlockKind(kind: string): kind is BlockKind {
|
||||||
|
return (ALL_KINDS as string[]).includes(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlockSchema(kind: string): string | null {
|
||||||
|
if (!isBlockKind(kind)) return null
|
||||||
|
return SCHEMAS[kind]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin index suitable for the always-on system prompt. */
|
||||||
|
export function blockIndexForPrompt(): string {
|
||||||
|
const lines = ALL_KINDS.map((k) => ` ${k} — ${BLOCK_INDEX[k]}`)
|
||||||
|
return [
|
||||||
|
"Rich output: when a UI primitive will communicate better than prose, emit a typed fenced ```<kind>\\n<json>\\n``` block. The chat renderer turns it into a @crema/*-ui component inline at that position.",
|
||||||
|
"",
|
||||||
|
"Available kinds:",
|
||||||
|
...lines,
|
||||||
|
"",
|
||||||
|
"Before emitting a block for the FIRST time in a thread, call get_block_schema(kind) to fetch the exact JSON shape and field rules. Once you've seen a schema in this conversation, reuse it from memory.",
|
||||||
|
"Always lead with one short sentence of prose, then the block. Don't repeat block data in prose.",
|
||||||
|
"JSON must be valid (double quotes, no trailing commas). If unsure of the schema, fetch it.",
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
172
app/lib/capabilities.ts
Normal file
172
app/lib/capabilities.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// Capability gating — the contract between roles, nav, and routes.
|
||||||
|
//
|
||||||
|
// A capability is a *thing the user can do in this UI*. The set held by
|
||||||
|
// the current session is computed from their active membership's roles
|
||||||
|
// + the slug of the active tenant (platform-admin gets the platform.*
|
||||||
|
// fleet by default). Sidebar nav filters by it; per-route guards 403
|
||||||
|
// when the user deep-links to one they don't hold.
|
||||||
|
//
|
||||||
|
// The server is the real authority — these checks are UI-shaping, not
|
||||||
|
// security. Don't ever trust the client capability check on its own.
|
||||||
|
|
||||||
|
export type Capability =
|
||||||
|
// tenant.* — held by tenant_admin on the active membership.
|
||||||
|
| "tenant.home"
|
||||||
|
| "tenant.users"
|
||||||
|
| "tenant.invitations"
|
||||||
|
| "tenant.roles"
|
||||||
|
| "tenant.memberships"
|
||||||
|
| "tenant.apps"
|
||||||
|
| "tenant.plan"
|
||||||
|
| "tenant.entitlements"
|
||||||
|
| "tenant.storage"
|
||||||
|
| "tenant.buckets"
|
||||||
|
| "tenant.activity"
|
||||||
|
| "tenant.settings"
|
||||||
|
| "tenant.profile"
|
||||||
|
// platform.* — held by platform_admin on platform-admin.
|
||||||
|
| "platform.tenants"
|
||||||
|
| "platform.organizations"
|
||||||
|
| "platform.networking"
|
||||||
|
| "platform.monitoring"
|
||||||
|
| "platform.status_page"
|
||||||
|
| "platform.scheduled_tasks"
|
||||||
|
| "platform.secrets"
|
||||||
|
| "platform.webhooks"
|
||||||
|
| "platform.announcements"
|
||||||
|
| "platform.sso"
|
||||||
|
| "platform.library"
|
||||||
|
| "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"
|
||||||
|
|
||||||
|
/** Roles arcadia issues that this UI knows about. */
|
||||||
|
export type Role =
|
||||||
|
| "platform_admin"
|
||||||
|
| "tenant_admin"
|
||||||
|
| "member"
|
||||||
|
| (string & {}) // accept unknown roles forward-compat
|
||||||
|
|
||||||
|
const TENANT_ADMIN_CAPS: Capability[] = [
|
||||||
|
"tenant.home",
|
||||||
|
"tenant.users",
|
||||||
|
"tenant.invitations",
|
||||||
|
"tenant.roles",
|
||||||
|
"tenant.memberships",
|
||||||
|
"tenant.apps",
|
||||||
|
"tenant.plan",
|
||||||
|
"tenant.entitlements",
|
||||||
|
"tenant.storage",
|
||||||
|
"tenant.buckets",
|
||||||
|
"tenant.activity",
|
||||||
|
"tenant.settings",
|
||||||
|
"tenant.profile",
|
||||||
|
]
|
||||||
|
|
||||||
|
const PLATFORM_ADMIN_CAPS: Capability[] = [
|
||||||
|
// platform_admin also gets every tenant.* — they're an admin of the
|
||||||
|
// platform-admin tenant, so they manage *its* users, storage, etc.
|
||||||
|
...TENANT_ADMIN_CAPS,
|
||||||
|
"platform.tenants",
|
||||||
|
"platform.organizations",
|
||||||
|
"platform.networking",
|
||||||
|
"platform.monitoring",
|
||||||
|
"platform.status_page",
|
||||||
|
"platform.scheduled_tasks",
|
||||||
|
"platform.secrets",
|
||||||
|
"platform.webhooks",
|
||||||
|
"platform.announcements",
|
||||||
|
"platform.sso",
|
||||||
|
"platform.library",
|
||||||
|
"platform.search",
|
||||||
|
"platform.ai",
|
||||||
|
"platform.integrations",
|
||||||
|
"platform.feature_flags",
|
||||||
|
]
|
||||||
|
|
||||||
|
const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"]
|
||||||
|
|
||||||
|
export function capabilitiesForRoles(roles: readonly string[] | undefined): Set<Capability> {
|
||||||
|
const caps = new Set<Capability>(ALWAYS_CAPS)
|
||||||
|
const has = (r: string) => (roles ?? []).includes(r)
|
||||||
|
if (has("platform_admin")) PLATFORM_ADMIN_CAPS.forEach((c) => caps.add(c))
|
||||||
|
if (has("tenant_admin") || has("admin")) TENANT_ADMIN_CAPS.forEach((c) => caps.add(c))
|
||||||
|
// "member" / other roles get only the always-on set.
|
||||||
|
return caps
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure helper — handy in tests + route loaders. */
|
||||||
|
export function holds(caps: Set<Capability>, cap: Capability): boolean {
|
||||||
|
return caps.has(cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------- Route map ----------------------------
|
||||||
|
//
|
||||||
|
// Every protected route declares which capability it needs. Sidebar nav
|
||||||
|
// and the per-route guard both read this map, so the contract lives in
|
||||||
|
// one place.
|
||||||
|
|
||||||
|
export const ROUTE_CAPABILITY: Record<string, Capability> = {
|
||||||
|
"/": "tenant.home",
|
||||||
|
"/users": "tenant.users",
|
||||||
|
"/memberships": "tenant.memberships",
|
||||||
|
"/storage": "tenant.storage",
|
||||||
|
"/buckets": "tenant.buckets",
|
||||||
|
"/audit-log": "tenant.activity",
|
||||||
|
"/activity": "tenant.activity", // legacy path → redirects to /audit-log
|
||||||
|
"/settings": "tenant.settings",
|
||||||
|
// 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",
|
||||||
|
"/networking": "platform.networking",
|
||||||
|
"/monitoring": "platform.monitoring",
|
||||||
|
"/status-page": "platform.status_page",
|
||||||
|
"/scheduled-tasks": "platform.scheduled_tasks",
|
||||||
|
"/secrets": "platform.secrets",
|
||||||
|
"/webhooks": "platform.webhooks",
|
||||||
|
"/announcements": "platform.announcements",
|
||||||
|
"/sso": "platform.sso",
|
||||||
|
"/library": "platform.library",
|
||||||
|
"/search": "platform.search",
|
||||||
|
"/ai": "platform.ai",
|
||||||
|
"/integrations": "platform.integrations",
|
||||||
|
"/feature-flags": "platform.feature_flags",
|
||||||
|
|
||||||
|
"/assistant": "always.assistant",
|
||||||
|
"/profile": "always.profile",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------- Hooks --------------------------------
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { useSession } from "~/lib/session"
|
||||||
|
|
||||||
|
/** The active session's capability set. Empty when not signed in. */
|
||||||
|
export function useCapabilities(): Set<Capability> {
|
||||||
|
const session = useSession()
|
||||||
|
return useMemo(() => capabilitiesForRoles(session?.roles), [session?.roles])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHasCapability(cap: Capability): boolean {
|
||||||
|
return useCapabilities().has(cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capabilityForPath(pathname: string): Capability | null {
|
||||||
|
// Exact match first.
|
||||||
|
if (ROUTE_CAPABILITY[pathname]) return ROUTE_CAPABILITY[pathname]
|
||||||
|
// Then prefix match — "/users/123" inherits "/users"'s capability.
|
||||||
|
// Walk known keys longest-first so "/scheduled-tasks/x" picks the
|
||||||
|
// right one over "/s".
|
||||||
|
const keys = Object.keys(ROUTE_CAPABILITY).sort((a, b) => b.length - a.length)
|
||||||
|
for (const k of keys) {
|
||||||
|
if (k !== "/" && pathname.startsWith(k + "/")) return ROUTE_CAPABILITY[k]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
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(" — ")
|
||||||
|
}
|
||||||
38
app/lib/gateway.ts
Normal file
38
app/lib/gateway.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Arcadia LLM-gateway client.
|
||||||
|
//
|
||||||
|
// 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-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-core-client"
|
||||||
|
|
||||||
|
const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015"
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = "arcadia_access_token"
|
||||||
|
const REFRESH_TOKEN_KEY = "arcadia_refresh_token"
|
||||||
|
|
||||||
|
let client: ArcadiaClient | null = null
|
||||||
|
|
||||||
|
export function gatewayClient(): ArcadiaClient {
|
||||||
|
if (!client) {
|
||||||
|
client = createArcadiaClient({
|
||||||
|
baseUrl: GATEWAY_URL,
|
||||||
|
getToken: () =>
|
||||||
|
typeof window === "undefined" ? null : sessionStorage.getItem(ACCESS_TOKEN_KEY),
|
||||||
|
onUnauthorized: () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGatewayClient(): ArcadiaClient {
|
||||||
|
return gatewayClient()
|
||||||
|
}
|
||||||
49
app/lib/jwt.ts
Normal file
49
app/lib/jwt.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Tiny JWT helpers — we never *verify* tokens client-side (the server
|
||||||
|
// is the only authority), we just decode the payload to read claims
|
||||||
|
// the UI uses for nav gating + tenant context.
|
||||||
|
|
||||||
|
export type ArcadiaClaims = {
|
||||||
|
sub?: string
|
||||||
|
email?: string
|
||||||
|
tenant_id?: string
|
||||||
|
tenant_slug?: string
|
||||||
|
roles?: string[]
|
||||||
|
available_tenants?: AvailableTenantClaim[]
|
||||||
|
exp?: number
|
||||||
|
iat?: number
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvailableTenantClaim = {
|
||||||
|
id?: string
|
||||||
|
slug?: string
|
||||||
|
name?: string
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlDecode(s: string): string {
|
||||||
|
const pad = "=".repeat((4 - (s.length % 4)) % 4)
|
||||||
|
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/")
|
||||||
|
if (typeof atob === "function") return atob(b64)
|
||||||
|
// Node fallback (SSR / tests)
|
||||||
|
return Buffer.from(b64, "base64").toString("binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJwt(token: string): ArcadiaClaims | null {
|
||||||
|
if (!token) return null
|
||||||
|
const parts = token.split(".")
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
try {
|
||||||
|
const raw = b64urlDecode(parts[1])
|
||||||
|
// Handle UTF-8: atob returns binary string; reconstruct UTF-8.
|
||||||
|
const utf8 =
|
||||||
|
typeof TextDecoder !== "undefined"
|
||||||
|
? new TextDecoder().decode(
|
||||||
|
Uint8Array.from(raw, (c) => c.charCodeAt(0)),
|
||||||
|
)
|
||||||
|
: raw
|
||||||
|
return JSON.parse(utf8) as ArcadiaClaims
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/lib/llm-config-bootstrap.tsx
Normal file
91
app/lib/llm-config-bootstrap.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// One-time bootstrap of the active LLM settings from arcadia.
|
||||||
|
//
|
||||||
|
// On mount (and again whenever the session changes), if the operator has no
|
||||||
|
// active LLM settings in localStorage, fetch the tenant's enabled
|
||||||
|
// configurations and seed the active settings from the preferred row:
|
||||||
|
//
|
||||||
|
// 1. Any row with `metadata.default === true` (operator-marked default).
|
||||||
|
// 2. Otherwise the first enabled row.
|
||||||
|
//
|
||||||
|
// Once active settings exist, this component does nothing — the settings
|
||||||
|
// panel remains the place to switch between configs.
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import {
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
|
type LLMProvidersSettings,
|
||||||
|
type ProviderId,
|
||||||
|
} from "@crema/llm-providers-ui"
|
||||||
|
|
||||||
|
import {
|
||||||
|
listConfigurations,
|
||||||
|
saveActiveReasoning,
|
||||||
|
type LlmConfiguration,
|
||||||
|
} from "~/lib/arcadia/llm-configs"
|
||||||
|
|
||||||
|
const ACTIVE_KEY = "crema.llm-providers.settings"
|
||||||
|
|
||||||
|
function hasActiveSettings(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
return !!localStorage.getItem(ACTIVE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPreferred(configs: LlmConfiguration[]): LlmConfiguration | null {
|
||||||
|
const enabled = configs.filter((c) => c.enabled)
|
||||||
|
if (enabled.length === 0) return null
|
||||||
|
const flagged = enabled.find(
|
||||||
|
(c) => (c.metadata as { default?: boolean } | null)?.default === true,
|
||||||
|
)
|
||||||
|
return flagged ?? enabled[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConfig(c: LlmConfiguration): void {
|
||||||
|
const current = loadSettings()
|
||||||
|
const next: LLMProvidersSettings = {
|
||||||
|
...current,
|
||||||
|
providerId: c.provider as ProviderId,
|
||||||
|
model: c.model,
|
||||||
|
baseURL: c.base_url || undefined,
|
||||||
|
secretName: c.secret_name || undefined,
|
||||||
|
}
|
||||||
|
saveSettings(next)
|
||||||
|
saveActiveReasoning(c.reasoning_effort ?? "off")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LlmConfigBootstrap() {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const tryBootstrap = async () => {
|
||||||
|
if (hasActiveSettings()) return
|
||||||
|
const token =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? sessionStorage.getItem("arcadia_access_token")
|
||||||
|
: null
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const configs = await listConfigurations(arcadia, { enabled: true })
|
||||||
|
if (cancelled) return
|
||||||
|
const pick = pickPreferred(configs)
|
||||||
|
if (pick && !hasActiveSettings()) applyConfig(pick)
|
||||||
|
} catch {
|
||||||
|
// 401 / network — silently skip; will retry on next session change.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void tryBootstrap()
|
||||||
|
|
||||||
|
const onSessionChange = () => void tryBootstrap()
|
||||||
|
window.addEventListener("crema:session-change", onSessionChange)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.removeEventListener("crema:session-change", onSessionChange)
|
||||||
|
}
|
||||||
|
}, [arcadia])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
|
// Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
|
||||||
// this store is for the appbar bell's persistent inbox.
|
// this store is for the appbar bell's persistent inbox.
|
||||||
|
|
||||||
import { useEffect, useSyncExternalStore } from "react"
|
import { useSyncExternalStore } from "react"
|
||||||
|
|
||||||
export type NotificationKind = "info" | "success" | "warning" | "error"
|
export type NotificationKind = "info" | "success" | "warning" | "error"
|
||||||
|
|
||||||
@@ -95,11 +95,27 @@ export function dismissAll() {
|
|||||||
writeToStorage([])
|
writeToStorage([])
|
||||||
}
|
}
|
||||||
|
|
||||||
let cached: AppNotification[] | null = null
|
// Cache keyed on the raw stored string so the snapshot stays referentially
|
||||||
|
// stable — `useSyncExternalStore` requires that getSnapshot return the same
|
||||||
|
// reference until the value genuinely changes. (This used to clear a flag on
|
||||||
|
// every mount without notifying subscribers, the same identity-churn bug that
|
||||||
|
// was fixed in session.ts.)
|
||||||
|
let cached: AppNotification[] = []
|
||||||
|
let cachedRaw: string | null = null
|
||||||
|
let primed = false
|
||||||
|
|
||||||
|
function readRaw(): string | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function subscribe(cb: () => void): () => void {
|
function subscribe(cb: () => void): () => void {
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
cached = null
|
primed = false
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
window.addEventListener(CHANGE_EVENT, onChange)
|
window.addEventListener(CHANGE_EVENT, onChange)
|
||||||
@@ -109,7 +125,12 @@ function subscribe(cb: () => void): () => void {
|
|||||||
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
||||||
}
|
}
|
||||||
function getSnapshot(): AppNotification[] {
|
function getSnapshot(): AppNotification[] {
|
||||||
if (!cached) cached = readFromStorage()
|
const raw = readRaw()
|
||||||
|
if (!primed || raw !== cachedRaw) {
|
||||||
|
cachedRaw = raw
|
||||||
|
cached = readFromStorage()
|
||||||
|
primed = true
|
||||||
|
}
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
function getServerSnapshot(): AppNotification[] {
|
function getServerSnapshot(): AppNotification[] {
|
||||||
@@ -117,39 +138,9 @@ function getServerSnapshot(): AppNotification[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useNotifications(): AppNotification[] {
|
export function useNotifications(): AppNotification[] {
|
||||||
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||||
useEffect(() => {
|
|
||||||
cached = null
|
|
||||||
}, [])
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unreadCount(items: AppNotification[]): number {
|
export function unreadCount(items: AppNotification[]): number {
|
||||||
return items.filter((n) => !n.readAt).length
|
return items.filter((n) => !n.readAt).length
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed a few demo notifications on first load so the bell isn't empty. */
|
|
||||||
export function seedIfEmpty() {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
if (localStorage.getItem(STORAGE_KEY)) return
|
|
||||||
const now = Date.now()
|
|
||||||
const seed: AppNotification[] = [
|
|
||||||
{
|
|
||||||
id: newId(),
|
|
||||||
kind: "info",
|
|
||||||
title: "Welcome",
|
|
||||||
body: "Tag elements with data-action and the assistant can drive them.",
|
|
||||||
href: "/assistant",
|
|
||||||
createdAt: now - 60_000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: newId(),
|
|
||||||
kind: "success",
|
|
||||||
title: "Profile saved",
|
|
||||||
body: "Your display name and avatar are live across the app.",
|
|
||||||
href: "/profile",
|
|
||||||
createdAt: now - 5 * 60_000,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
writeToStorage(seed)
|
|
||||||
}
|
|
||||||
|
|||||||
90
app/lib/profile-bootstrap.tsx
Normal file
90
app/lib/profile-bootstrap.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Fetches the arcadia profile on app boot (and after login) and caches
|
||||||
|
// the resolved avatar URL in localStorage so the appbar's <Avatar> shows
|
||||||
|
// immediately, without waiting for the user to navigate to /profile.
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
import { fetchDigitalObjectAsBlobUrl } from "~/lib/arcadia/digital-objects"
|
||||||
|
import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles"
|
||||||
|
import { loadProfile, saveProfile } from "~/lib/profile"
|
||||||
|
|
||||||
|
export function ProfileBootstrap() {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const tryBootstrap = async () => {
|
||||||
|
const token =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? sessionStorage.getItem("arcadia_access_token")
|
||||||
|
: null
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const p = await getProfile(arcadia)
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const persistentUrl = pickAvatarUrl(p)
|
||||||
|
const current = loadProfile()
|
||||||
|
const cachedIsStaleBlob = current.avatarUrl?.startsWith("blob:") ?? false
|
||||||
|
|
||||||
|
if (persistentUrl) {
|
||||||
|
if (current.avatarUrl !== persistentUrl) {
|
||||||
|
saveProfile({ ...current, avatarUrl: persistentUrl })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No persistent variant yet but the user has an avatar — fetch
|
||||||
|
// the raw bytes as a blob URL. This also covers the "stale blob
|
||||||
|
// URL from previous session" case: replace it with a fresh one.
|
||||||
|
if (p.avatar_digital_object_id) {
|
||||||
|
if (cachedIsStaleBlob) {
|
||||||
|
// Clear the stale URL immediately so the appbar drops back
|
||||||
|
// to initials while we refetch (better than a broken image).
|
||||||
|
saveProfile({ ...current, avatarUrl: "" })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const baseUrl =
|
||||||
|
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ??
|
||||||
|
"http://localhost:4000"
|
||||||
|
const tenantId =
|
||||||
|
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ??
|
||||||
|
"default"
|
||||||
|
const blobUrl = await fetchDigitalObjectAsBlobUrl(
|
||||||
|
baseUrl,
|
||||||
|
p.avatar_digital_object_id,
|
||||||
|
token,
|
||||||
|
tenantId,
|
||||||
|
)
|
||||||
|
if (cancelled) return
|
||||||
|
const fresh = loadProfile()
|
||||||
|
saveProfile({ ...fresh, avatarUrl: blobUrl })
|
||||||
|
} catch {
|
||||||
|
// Best-effort; appbar will show initials until processing completes.
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No avatar at all — clear any stale URL the cache might still hold.
|
||||||
|
if (current.avatarUrl) {
|
||||||
|
saveProfile({ ...current, avatarUrl: "" })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 401 / network — silently skip; will retry on next session change.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void tryBootstrap()
|
||||||
|
|
||||||
|
const onSessionChange = () => void tryBootstrap()
|
||||||
|
window.addEventListener("crema:session-change", onSessionChange)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.removeEventListener("crema:session-change", onSessionChange)
|
||||||
|
}
|
||||||
|
}, [arcadia])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,26 +1,16 @@
|
|||||||
// User profile — name, email, title, bio, signature, default agent.
|
// Local mirror of the resolved avatar URL, so the appbar can render the
|
||||||
// Persisted in localStorage; reactive across tabs.
|
// avatar before the profile fetch resolves on next mount. The real
|
||||||
|
// profile (name, email, bio, phone, location, timezone, avatar) is
|
||||||
|
// server-backed — see ~/lib/arcadia/profiles.ts.
|
||||||
|
|
||||||
import { useEffect, useSyncExternalStore } from "react"
|
import { useEffect, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
export type Profile = {
|
export type Profile = {
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
title: string
|
|
||||||
bio: string
|
|
||||||
signature: string
|
|
||||||
avatarUrl: string
|
avatarUrl: string
|
||||||
defaultAgentId: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PROFILE: Profile = {
|
export const DEFAULT_PROFILE: Profile = {
|
||||||
name: "Signed-in user",
|
|
||||||
email: "user@example.com",
|
|
||||||
title: "",
|
|
||||||
bio: "",
|
|
||||||
signature: "",
|
|
||||||
avatarUrl: "",
|
avatarUrl: "",
|
||||||
defaultAgentId: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "crema.profile"
|
const STORAGE_KEY = "crema.profile"
|
||||||
@@ -33,27 +23,10 @@ function readFromStorage(): Profile {
|
|||||||
if (!raw) return DEFAULT_PROFILE
|
if (!raw) return DEFAULT_PROFILE
|
||||||
const parsed = JSON.parse(raw) as Partial<Profile>
|
const parsed = JSON.parse(raw) as Partial<Profile>
|
||||||
return {
|
return {
|
||||||
name:
|
|
||||||
typeof parsed.name === "string" && parsed.name.trim().length > 0
|
|
||||||
? parsed.name
|
|
||||||
: DEFAULT_PROFILE.name,
|
|
||||||
email:
|
|
||||||
typeof parsed.email === "string" ? parsed.email : DEFAULT_PROFILE.email,
|
|
||||||
title:
|
|
||||||
typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title,
|
|
||||||
bio: typeof parsed.bio === "string" ? parsed.bio : DEFAULT_PROFILE.bio,
|
|
||||||
signature:
|
|
||||||
typeof parsed.signature === "string"
|
|
||||||
? parsed.signature
|
|
||||||
: DEFAULT_PROFILE.signature,
|
|
||||||
avatarUrl:
|
avatarUrl:
|
||||||
typeof parsed.avatarUrl === "string"
|
typeof parsed.avatarUrl === "string"
|
||||||
? parsed.avatarUrl
|
? parsed.avatarUrl
|
||||||
: DEFAULT_PROFILE.avatarUrl,
|
: DEFAULT_PROFILE.avatarUrl,
|
||||||
defaultAgentId:
|
|
||||||
typeof parsed.defaultAgentId === "string"
|
|
||||||
? parsed.defaultAgentId
|
|
||||||
: DEFAULT_PROFILE.defaultAgentId,
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_PROFILE
|
return DEFAULT_PROFILE
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, expect, it, beforeEach } from "vitest"
|
|
||||||
|
|
||||||
import {
|
|
||||||
createResource,
|
|
||||||
deleteResource,
|
|
||||||
listResources,
|
|
||||||
updateResource,
|
|
||||||
} from "./resources"
|
|
||||||
|
|
||||||
describe("resources", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("creates, updates, and deletes", () => {
|
|
||||||
expect(listResources()).toEqual([])
|
|
||||||
const r = createResource({ name: "Test", owner: "Atlas" })
|
|
||||||
expect(r.status).toBe("active")
|
|
||||||
expect(listResources()).toHaveLength(1)
|
|
||||||
|
|
||||||
const updated = updateResource(r.id, { status: "paused" })
|
|
||||||
expect(updated?.status).toBe("paused")
|
|
||||||
expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt)
|
|
||||||
|
|
||||||
deleteResource(r.id)
|
|
||||||
expect(listResources()).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("ignores updates for unknown ids", () => {
|
|
||||||
expect(updateResource("missing", { name: "x" })).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
// Resource store — example domain entity.
|
|
||||||
// Backed by localStorage today, but written so each call is a single function
|
|
||||||
// you can swap with `api.get/post/put/del` once you have a real backend.
|
|
||||||
|
|
||||||
import { useEffect, useSyncExternalStore } from "react"
|
|
||||||
|
|
||||||
export type Resource = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
status: "active" | "paused" | "archived"
|
|
||||||
owner: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = "crema.resources"
|
|
||||||
const CHANGE_EVENT = "crema:resources-change"
|
|
||||||
|
|
||||||
function newId() {
|
|
||||||
return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function readFromStorage(): Resource[] {
|
|
||||||
if (typeof window === "undefined") return []
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!raw) return []
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (!Array.isArray(parsed)) return []
|
|
||||||
return parsed.filter(
|
|
||||||
(r): r is Resource =>
|
|
||||||
r &&
|
|
||||||
typeof r.id === "string" &&
|
|
||||||
typeof r.name === "string" &&
|
|
||||||
["active", "paused", "archived"].includes(r.status) &&
|
|
||||||
typeof r.owner === "string" &&
|
|
||||||
typeof r.createdAt === "number" &&
|
|
||||||
typeof r.updatedAt === "number",
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function write(items: Resource[]) {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
|
||||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
|
||||||
} catch {
|
|
||||||
/* quota */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRUD — these mirror what `api.get/post/put/del` would look like.
|
|
||||||
export function listResources(): Resource[] {
|
|
||||||
return readFromStorage()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createResource(input: {
|
|
||||||
name: string
|
|
||||||
owner: string
|
|
||||||
status?: Resource["status"]
|
|
||||||
}): Resource {
|
|
||||||
const now = Date.now()
|
|
||||||
const r: Resource = {
|
|
||||||
id: newId(),
|
|
||||||
name: input.name,
|
|
||||||
owner: input.owner,
|
|
||||||
status: input.status ?? "active",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
write([r, ...readFromStorage()])
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateResource(
|
|
||||||
id: string,
|
|
||||||
patch: Partial<Omit<Resource, "id" | "createdAt">>,
|
|
||||||
): Resource | null {
|
|
||||||
const items = readFromStorage()
|
|
||||||
let updated: Resource | null = null
|
|
||||||
const next = items.map((r) => {
|
|
||||||
if (r.id !== id) return r
|
|
||||||
updated = { ...r, ...patch, updatedAt: Date.now() }
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
if (updated) write(next)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteResource(id: string) {
|
|
||||||
write(readFromStorage().filter((r) => r.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
let cached: Resource[] | null = null
|
|
||||||
function subscribe(cb: () => void) {
|
|
||||||
const onChange = () => {
|
|
||||||
cached = null
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
window.addEventListener(CHANGE_EVENT, onChange)
|
|
||||||
window.addEventListener("storage", (e) => {
|
|
||||||
if (e.key === STORAGE_KEY) onChange()
|
|
||||||
})
|
|
||||||
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
|
||||||
}
|
|
||||||
function getSnapshot(): Resource[] {
|
|
||||||
if (!cached) cached = readFromStorage()
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
function getServerSnapshot(): Resource[] {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResources(): Resource[] {
|
|
||||||
const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
||||||
useEffect(() => {
|
|
||||||
cached = null
|
|
||||||
}, [])
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seed a few rows on first load so the table isn't empty. */
|
|
||||||
export function seedResourcesIfEmpty() {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
if (localStorage.getItem(STORAGE_KEY)) return
|
|
||||||
const now = Date.now()
|
|
||||||
const seed: Resource[] = [
|
|
||||||
{
|
|
||||||
id: newId(),
|
|
||||||
name: "Acme dashboard",
|
|
||||||
status: "active",
|
|
||||||
owner: "Atlas",
|
|
||||||
createdAt: now - 86_400_000 * 3,
|
|
||||||
updatedAt: now - 3600_000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: newId(),
|
|
||||||
name: "Onboarding pipeline",
|
|
||||||
status: "paused",
|
|
||||||
owner: "Forge",
|
|
||||||
createdAt: now - 86_400_000 * 7,
|
|
||||||
updatedAt: now - 86_400_000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: newId(),
|
|
||||||
name: "Q1 report draft",
|
|
||||||
status: "archived",
|
|
||||||
owner: "Inkwell",
|
|
||||||
createdAt: now - 86_400_000 * 30,
|
|
||||||
updatedAt: now - 86_400_000 * 14,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
write(seed)
|
|
||||||
}
|
|
||||||
113
app/lib/search-admin.ts
Normal file
113
app/lib/search-admin.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Client for the arcadia-search admin sidecar (`/admin/*` on the
|
||||||
|
// search box, default :7801). Used by the Search route to manage
|
||||||
|
// tenants, corpora, and trigger rebuilds.
|
||||||
|
//
|
||||||
|
// Auth: static bearer token from VITE_ARCADIA_SEARCH_ADMIN_TOKEN,
|
||||||
|
// matched constant-time against ADMIN_TOKEN on the sidecar. The token
|
||||||
|
// ships in the client bundle — fine for an internal admin tool on a
|
||||||
|
// trusted network; in production, proxy through arcadia-core.
|
||||||
|
|
||||||
|
const BASE_URL =
|
||||||
|
import.meta.env.VITE_ARCADIA_SEARCH_ADMIN_URL ?? "http://127.0.0.1:7801"
|
||||||
|
const TOKEN = import.meta.env.VITE_ARCADIA_SEARCH_ADMIN_TOKEN ?? ""
|
||||||
|
|
||||||
|
export class SearchAdminError extends Error {
|
||||||
|
status: number
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = "SearchAdminError"
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (TOKEN) headers["Authorization"] = `Bearer ${TOKEN}`
|
||||||
|
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "")
|
||||||
|
throw new SearchAdminError(
|
||||||
|
text || `${res.status} ${res.statusText}`,
|
||||||
|
res.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TenantSummary = { id: string; corpus_count: number }
|
||||||
|
export type CorpusSummary = {
|
||||||
|
tenant: string
|
||||||
|
corpus: string
|
||||||
|
indexed: boolean
|
||||||
|
num_docs: number | null
|
||||||
|
live_path: string | null
|
||||||
|
}
|
||||||
|
export type CorpusDetail = {
|
||||||
|
config: Record<string, unknown>
|
||||||
|
status: CorpusSummary
|
||||||
|
}
|
||||||
|
export type RebuildResult = {
|
||||||
|
tenant: string
|
||||||
|
corpus: string
|
||||||
|
chunk_count: number
|
||||||
|
live_path: string
|
||||||
|
built_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchAdmin = {
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
hasToken: !!TOKEN,
|
||||||
|
listTenants: () =>
|
||||||
|
call<{ tenants: TenantSummary[] }>("GET", "/admin/tenants"),
|
||||||
|
createTenant: (id: string) =>
|
||||||
|
call<TenantSummary>("POST", "/admin/tenants", { id }),
|
||||||
|
deleteTenant: (id: string) =>
|
||||||
|
call<void>("DELETE", `/admin/tenants/${encodeURIComponent(id)}`),
|
||||||
|
listCorpora: (tenant: string) =>
|
||||||
|
call<{ corpora: CorpusSummary[] }>(
|
||||||
|
"GET",
|
||||||
|
`/admin/tenants/${encodeURIComponent(tenant)}/corpora`,
|
||||||
|
),
|
||||||
|
createCorpus: (tenant: string, body: Record<string, unknown>) =>
|
||||||
|
call<CorpusSummary>(
|
||||||
|
"POST",
|
||||||
|
`/admin/tenants/${encodeURIComponent(tenant)}/corpora`,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
getCorpus: (tenant: string, corpus: string) =>
|
||||||
|
call<CorpusDetail>(
|
||||||
|
"GET",
|
||||||
|
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||||
|
),
|
||||||
|
updateCorpus: (
|
||||||
|
tenant: string,
|
||||||
|
corpus: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
) =>
|
||||||
|
call<CorpusSummary>(
|
||||||
|
"PUT",
|
||||||
|
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
deleteCorpus: (tenant: string, corpus: string) =>
|
||||||
|
call<void>(
|
||||||
|
"DELETE",
|
||||||
|
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||||
|
),
|
||||||
|
rebuild: (tenant: string, corpus: string) =>
|
||||||
|
call<RebuildResult>(
|
||||||
|
"POST",
|
||||||
|
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}/rebuild`,
|
||||||
|
),
|
||||||
|
restart: () => call<void>("POST", "/admin/restart"),
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { describe, expect, it, beforeEach } from "vitest"
|
import { describe, expect, it, beforeEach } from "vitest"
|
||||||
|
|
||||||
import { hasSession, loadSession, signIn, signOut } from "./session"
|
import {
|
||||||
|
hasSession,
|
||||||
|
loadSession,
|
||||||
|
persistFromArcadiaLogin,
|
||||||
|
signOut,
|
||||||
|
updateSessionUser,
|
||||||
|
} from "./session"
|
||||||
|
|
||||||
describe("session", () => {
|
describe("session", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("starts unauthenticated", () => {
|
it("starts unauthenticated", () => {
|
||||||
@@ -12,20 +19,31 @@ describe("session", () => {
|
|||||||
expect(hasSession()).toBe(false)
|
expect(hasSession()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("rejects empty credentials", async () => {
|
it("persists from an arcadia login and clears on sign-out", () => {
|
||||||
await expect(signIn("", "")).rejects.toThrow(/required/i)
|
const session = persistFromArcadiaLogin(
|
||||||
await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i)
|
{ access_token: "tok-123", refresh_token: "ref-456" },
|
||||||
expect(hasSession()).toBe(false)
|
{ id: "u1", email: "alice@example.com", full_name: "Alice" },
|
||||||
})
|
)
|
||||||
|
|
||||||
it("creates a session on sign-in and clears on sign-out", async () => {
|
|
||||||
const session = await signIn("alice@example.com", "hunter2")
|
|
||||||
expect(session.email).toBe("alice@example.com")
|
expect(session.email).toBe("alice@example.com")
|
||||||
expect(session.token).toMatch(/^dev-/)
|
expect(session.name).toBe("Alice")
|
||||||
|
expect(session.token).toBe("tok-123")
|
||||||
expect(hasSession()).toBe(true)
|
expect(hasSession()).toBe(true)
|
||||||
|
expect(sessionStorage.getItem("arcadia_access_token")).toBe("tok-123")
|
||||||
|
|
||||||
signOut()
|
signOut()
|
||||||
expect(loadSession()).toBeNull()
|
expect(loadSession()).toBeNull()
|
||||||
expect(hasSession()).toBe(false)
|
expect(sessionStorage.getItem("arcadia_access_token")).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("updates the stored session identity in place", () => {
|
||||||
|
persistFromArcadiaLogin(
|
||||||
|
{ access_token: "tok" },
|
||||||
|
{ id: "u1", email: "a@x.com", full_name: "Alice" },
|
||||||
|
)
|
||||||
|
updateSessionUser({ name: "Alice Smith", email: "alice@x.com" })
|
||||||
|
const s = loadSession()
|
||||||
|
expect(s?.name).toBe("Alice Smith")
|
||||||
|
expect(s?.email).toBe("alice@x.com")
|
||||||
|
expect(s?.token).toBe("tok")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
// Session — minimal auth scaffold backed by localStorage.
|
// Session — minimal auth scaffold backed by localStorage.
|
||||||
// Swap loadSession/signIn/signOut for real calls (cookies + server) when you
|
// Sign-in is owned by `persistFromArcadiaLogin`, which is called by the auth
|
||||||
// wire a backend. The shape here matches what AppShell + useUser expect.
|
// 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 { profileInitials } from "~/lib/profile"
|
||||||
|
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
|
||||||
|
|
||||||
|
export type AvailableTenant = {
|
||||||
|
id: string
|
||||||
|
slug?: string
|
||||||
|
name?: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type Session = {
|
export type Session = {
|
||||||
userId: string
|
userId: string
|
||||||
@@ -13,9 +22,21 @@ export type Session = {
|
|||||||
token: string
|
token: string
|
||||||
// Issued at, ms since epoch.
|
// Issued at, ms since epoch.
|
||||||
issuedAt: number
|
issuedAt: number
|
||||||
|
// Active membership context — derived from the JWT.
|
||||||
|
tenantId?: string
|
||||||
|
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"
|
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"
|
const CHANGE_EVENT = "crema:session-change"
|
||||||
|
|
||||||
function readFromStorage(): Session | null {
|
function readFromStorage(): Session | null {
|
||||||
@@ -40,6 +61,20 @@ function readFromStorage(): Session | null {
|
|||||||
token: parsed.token,
|
token: parsed.token,
|
||||||
issuedAt:
|
issuedAt:
|
||||||
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
||||||
|
tenantId: typeof parsed.tenantId === "string" ? parsed.tenantId : undefined,
|
||||||
|
tenantSlug:
|
||||||
|
typeof parsed.tenantSlug === "string" ? parsed.tenantSlug : undefined,
|
||||||
|
roles: Array.isArray(parsed.roles)
|
||||||
|
? parsed.roles.filter((r): r is string => typeof r === "string")
|
||||||
|
: [],
|
||||||
|
availableTenants: Array.isArray(parsed.availableTenants)
|
||||||
|
? (parsed.availableTenants.filter(
|
||||||
|
(t): t is AvailableTenant =>
|
||||||
|
!!t && typeof (t as AvailableTenant).id === "string",
|
||||||
|
) as AvailableTenant[])
|
||||||
|
: [],
|
||||||
|
impersonatedBy:
|
||||||
|
typeof parsed.impersonatedBy === "string" ? parsed.impersonatedBy : undefined,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -50,35 +85,6 @@ export function loadSession(): Session | null {
|
|||||||
return readFromStorage()
|
return readFromStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock sign-in. Validates only that email + password are non-empty; returns
|
|
||||||
* a fake session. Replace with a real fetch to your auth endpoint.
|
|
||||||
*/
|
|
||||||
export async function signIn(
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
): Promise<Session> {
|
|
||||||
await new Promise((r) => setTimeout(r, 250))
|
|
||||||
if (!email.trim() || !password.trim()) {
|
|
||||||
throw new Error("Email and password are required.")
|
|
||||||
}
|
|
||||||
if (!email.includes("@")) {
|
|
||||||
throw new Error("Enter a valid email address.")
|
|
||||||
}
|
|
||||||
const session: Session = {
|
|
||||||
userId: `u-${Date.now().toString(36)}`,
|
|
||||||
name: email.split("@")[0].replace(/\W/g, " ").trim() || email,
|
|
||||||
email,
|
|
||||||
token: `dev-${Math.random().toString(36).slice(2, 14)}`,
|
|
||||||
issuedAt: Date.now(),
|
|
||||||
}
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
|
||||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
|
||||||
}
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
|
|
||||||
export function signOut() {
|
export function signOut() {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
@@ -100,12 +106,31 @@ export function persistFromArcadiaLogin(
|
|||||||
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
|
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
|
||||||
user?.email ||
|
user?.email ||
|
||||||
"Signed-in user"
|
"Signed-in user"
|
||||||
|
const claims = decodeJwt(tokens.access_token) ?? {}
|
||||||
|
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: Array.isArray(t.roles) ? t.roles : [],
|
||||||
|
}))
|
||||||
|
: []
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
|
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
|
||||||
name,
|
name,
|
||||||
email: user?.email ?? "",
|
email: user?.email ?? "",
|
||||||
token: tokens.access_token,
|
token: tokens.access_token,
|
||||||
issuedAt: Date.now(),
|
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 : [],
|
||||||
|
availableTenants,
|
||||||
}
|
}
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
|
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
|
||||||
@@ -116,17 +141,143 @@ export function persistFromArcadiaLogin(
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Patch the stored session's identity fields without changing the token.
|
||||||
|
* Use after the operator edits their profile so the appbar avatar and
|
||||||
|
* protected-shell greeting reflect the new name/email immediately. */
|
||||||
|
export function updateSessionUser(patch: {
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
}): Session | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const current = readFromStorage()
|
||||||
|
if (!current) return null
|
||||||
|
const next: Session = {
|
||||||
|
...current,
|
||||||
|
name: patch.name?.trim() ? patch.name : current.name,
|
||||||
|
email: patch.email?.trim() ? patch.email : current.email,
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
/** True if a non-expired session is in storage. */
|
/** True if a non-expired session is in storage. */
|
||||||
export function hasSession(): boolean {
|
export function hasSession(): boolean {
|
||||||
return !!readFromStorage()
|
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 cached: Session | null = null
|
||||||
let cacheValid = false
|
let cachedRaw: string | null = null
|
||||||
|
let primed = false
|
||||||
|
|
||||||
|
function readRaw(): string | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function subscribe(cb: () => void): () => void {
|
function subscribe(cb: () => void): () => void {
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
cacheValid = false
|
// Force the next getSnapshot to reparse, then let React re-render.
|
||||||
|
primed = false
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
window.addEventListener(CHANGE_EVENT, onChange)
|
window.addEventListener(CHANGE_EVENT, onChange)
|
||||||
@@ -135,23 +286,25 @@ function subscribe(cb: () => void): () => void {
|
|||||||
})
|
})
|
||||||
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSnapshot(): Session | null {
|
function getSnapshot(): Session | null {
|
||||||
if (!cacheValid) {
|
const raw = readRaw()
|
||||||
|
if (!primed || raw !== cachedRaw) {
|
||||||
|
cachedRaw = raw
|
||||||
|
// readFromStorage re-validates expiry and may clear the token; when it
|
||||||
|
// does, `raw` differs on the next read and we reparse again.
|
||||||
cached = readFromStorage()
|
cached = readFromStorage()
|
||||||
cacheValid = true
|
primed = true
|
||||||
}
|
}
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServerSnapshot(): Session | null {
|
function getServerSnapshot(): Session | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSession(): Session | null {
|
export function useSession(): Session | null {
|
||||||
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||||
useEffect(() => {
|
|
||||||
cacheValid = false
|
|
||||||
}, [])
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionInitials(session: Session | null): string {
|
export function sessionInitials(session: Session | null): string {
|
||||||
|
|||||||
11
app/root.tsx
11
app/root.tsx
@@ -10,9 +10,11 @@ import {
|
|||||||
import type { Route } from "./+types/root"
|
import type { Route } from "./+types/root"
|
||||||
import "./app.css"
|
import "./app.css"
|
||||||
|
|
||||||
import { ToastProvider } from "@crema/notification-ui"
|
import { ToastProvider, Toaster } from "@crema/notification-ui"
|
||||||
import { CommandBusProvider } from "@crema/action-bus"
|
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
|
// CREMA:PROVIDERS-IMPORTS
|
||||||
|
|
||||||
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
|
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
|
||||||
@@ -28,7 +30,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<Links />
|
<Links />
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `(function(){try{var t=localStorage.getItem('crema-theme');if(!t)t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';if(t==='dark')document.documentElement.classList.add('dark');var f=localStorage.getItem('crema-font-scale');if(f&&/^(sm|md|lg|xl)$/.test(f))document.documentElement.dataset.fontScale=f;var b=localStorage.getItem('crema-bg');if(b&&/^(drift|static)$/.test(b)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.bg=b;});}var s=localStorage.getItem('crema-surface');if(s&&/^(snow|stone|sage|slate)$/.test(s)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.surface=s;});}}catch(e){}})();`,
|
__html: `(function(){try{var t=localStorage.getItem('crema-theme');if(!t)t='dark';if(t==='dark')document.documentElement.classList.add('dark');var f=localStorage.getItem('crema-font-scale');if(!f||!/^(sm|md|lg|xl)$/.test(f))f='sm';document.documentElement.dataset.fontScale=f;var b=localStorage.getItem('crema-bg');if(b&&/^(drift|static)$/.test(b)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.bg=b;});}var s=localStorage.getItem('crema-surface');if(s&&/^(snow|stone|sage|slate)$/.test(s)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.surface=s;});}}catch(e){}})();`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
@@ -61,7 +63,10 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CommandBusProvider>
|
<CommandBusProvider>
|
||||||
|
<LlmConfigBootstrap />
|
||||||
|
<ProfileBootstrap />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Toaster />
|
||||||
</CommandBusProvider>
|
</CommandBusProvider>
|
||||||
</ArcadiaProvider>
|
</ArcadiaProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -2,19 +2,34 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
index("routes/home.tsx"),
|
index("routes/home.tsx"),
|
||||||
route("resources", "routes/resources.tsx"),
|
route("audit-log", "routes/activity.tsx"),
|
||||||
route("activity", "routes/activity.tsx"),
|
route("activity", "routes/activity-redirect.tsx"),
|
||||||
route("assistant", "routes/assistant.tsx"),
|
|
||||||
route("ai", "routes/ai.tsx"),
|
route("ai", "routes/ai.tsx"),
|
||||||
route("library", "routes/library.tsx"),
|
|
||||||
route("settings", "routes/settings.tsx"),
|
route("settings", "routes/settings.tsx"),
|
||||||
route("profile", "routes/profile.tsx"),
|
route("profile", "routes/profile.tsx"),
|
||||||
route("login", "routes/login.tsx"),
|
route("login", "routes/login.tsx"),
|
||||||
|
route("login/forgot", "routes/login.forgot.tsx"),
|
||||||
|
route("login/reset", "routes/login.reset.tsx"),
|
||||||
|
route("login/2fa", "routes/login.2fa.tsx"),
|
||||||
|
route("signup", "routes/signup.tsx"),
|
||||||
route("tenants", "routes/tenants.tsx"),
|
route("tenants", "routes/tenants.tsx"),
|
||||||
|
route("tenants/:id", "routes/tenants.$id.tsx"),
|
||||||
route("storage", "routes/storage.tsx"),
|
route("storage", "routes/storage.tsx"),
|
||||||
route("users", "routes/users.tsx"),
|
route("users", "routes/users.tsx"),
|
||||||
route("secrets", "routes/secrets.tsx"),
|
route("secrets", "routes/secrets.tsx"),
|
||||||
route("webhooks", "routes/webhooks.tsx"),
|
route("webhooks", "routes/webhooks.tsx"),
|
||||||
route("scheduled-tasks", "routes/scheduled-tasks.tsx"),
|
route("scheduled-tasks", "routes/scheduled-tasks.tsx"),
|
||||||
|
route("buckets", "routes/buckets.tsx"),
|
||||||
|
route("monitoring", "routes/monitoring.tsx"),
|
||||||
|
route("memberships", "routes/memberships.tsx"),
|
||||||
|
route("organizations", "routes/organizations.tsx"),
|
||||||
|
route("networking", "routes/networking.tsx"),
|
||||||
|
route("sso", "routes/sso.tsx"),
|
||||||
|
route("announcements", "routes/announcements.tsx"),
|
||||||
|
route("status-page", "routes/status-page.tsx"),
|
||||||
|
route("search", "routes/search.tsx"),
|
||||||
|
route("billing", "routes/billing.tsx"),
|
||||||
|
route("feature-flags", "routes/feature-flags.tsx"),
|
||||||
|
route("integrations", "routes/integrations.tsx"),
|
||||||
// CREMA:ROUTES
|
// CREMA:ROUTES
|
||||||
] satisfies RouteConfig
|
] 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,8 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Link } from "react-router"
|
|
||||||
import { Activity, Eye, RefreshCw } from "lucide-react"
|
import { Activity, Eye, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
@@ -14,9 +13,10 @@ import {
|
|||||||
type Column,
|
type Column,
|
||||||
} from "@crema/table-ui"
|
} from "@crema/table-ui"
|
||||||
import { SearchInput } from "@crema/search-ui"
|
import { SearchInput } from "@crema/search-ui"
|
||||||
import { AlertBanner, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
import { EmptyState } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
import { DataState } from "~/components/data-state"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
} from "~/lib/arcadia/audit-logs"
|
} from "~/lib/arcadia/audit-logs"
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
import { useSession } from "~/lib/session"
|
import { useSession } from "~/lib/session"
|
||||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Audit log")
|
export const meta = () => pageTitle("Audit log")
|
||||||
|
|
||||||
@@ -58,7 +58,9 @@ export default function ActivityRoute() {
|
|||||||
|
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
// The raw thrown value. `DataState` turns it into plain language — and a 429
|
||||||
|
// must never be mistaken for "no events match those filters".
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [severityFilter, setSeverityFilter] = useState<"all" | AuditSeverity>("all")
|
const [severityFilter, setSeverityFilter] = useState<"all" | AuditSeverity>("all")
|
||||||
const [resourceFilter, setResourceFilter] = useState("")
|
const [resourceFilter, setResourceFilter] = useState("")
|
||||||
@@ -79,7 +81,7 @@ export default function ActivityRoute() {
|
|||||||
})
|
})
|
||||||
setLogs(list)
|
setLogs(list)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load audit logs.")
|
setError(err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -188,7 +190,7 @@ export default function ActivityRoute() {
|
|||||||
}),
|
}),
|
||||||
[logs],
|
[logs],
|
||||||
)
|
)
|
||||||
useRegisterAdminContext("audit_log", summary)
|
useRegisterContext("audit_log", summary)
|
||||||
|
|
||||||
const table = useTable<AuditLog>({
|
const table = useTable<AuditLog>({
|
||||||
data: logs,
|
data: logs,
|
||||||
@@ -201,29 +203,9 @@ export default function ActivityRoute() {
|
|||||||
table.setSearch(search)
|
table.setSearch(search)
|
||||||
}, [search, table])
|
}, [search, table])
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Audit log">
|
<AppShell>
|
||||||
<div className="p-8">
|
<div className="flex flex-col gap-4">
|
||||||
<Card className="max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Sign in required</CardTitle>
|
|
||||||
<CardDescription>The audit log requires an admin session.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/login?next=/activity">Sign in</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</AppShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell title="Audit log">
|
|
||||||
<div className="flex flex-col gap-4 p-6">
|
|
||||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Audit log</h1>
|
||||||
@@ -244,12 +226,6 @@ export default function ActivityRoute() {
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
|
||||||
{error}
|
|
||||||
</AlertBanner>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end">
|
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
@@ -321,16 +297,21 @@ export default function ActivityRoute() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="relative p-0">
|
<CardContent className="relative p-0">
|
||||||
<LoadingOverlay active={loading && logs.length === 0} label="Loading audit log…" />
|
<DataState
|
||||||
{table.total === 0 && !loading ? (
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
isEmpty={table.total === 0}
|
||||||
|
onRetry={refresh}
|
||||||
|
loadingLabel="Loading audit log…"
|
||||||
|
empty={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Activity className="size-6" />}
|
icon={<Activity className="size-6" />}
|
||||||
title="No events match those filters."
|
title="No events match those filters."
|
||||||
description="Loosen the filter set or wait for new platform activity."
|
description="Loosen the filter set or wait for new platform activity."
|
||||||
className="py-12"
|
className="py-12"
|
||||||
/>
|
/>
|
||||||
) : (
|
}
|
||||||
<>
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={table.pageRows}
|
rows={table.pageRows}
|
||||||
@@ -347,8 +328,7 @@ export default function ActivityRoute() {
|
|||||||
onPageChange={table.setPage}
|
onPageChange={table.setPage}
|
||||||
onPageSizeChange={table.setPageSize}
|
onPageSizeChange={table.setPageSize}
|
||||||
/>
|
/>
|
||||||
</>
|
</DataState>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,3 +407,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
|
|||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
826
app/routes/announcements.tsx
Normal file
826
app/routes/announcements.tsx
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Megaphone,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import { useToast } from "@crema/notification-ui"
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
BadgeCell,
|
||||||
|
DataTable,
|
||||||
|
DateCell,
|
||||||
|
Pagination,
|
||||||
|
useTable,
|
||||||
|
type ActionItem,
|
||||||
|
type BadgeTone,
|
||||||
|
type Column,
|
||||||
|
} from "@crema/table-ui"
|
||||||
|
import { SearchInput } from "@crema/search-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 {
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import { Switch } from "~/components/ui/switch"
|
||||||
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
createAnnouncement,
|
||||||
|
deleteAnnouncement,
|
||||||
|
listAnnouncements,
|
||||||
|
updateAnnouncement,
|
||||||
|
type Announcement,
|
||||||
|
type AnnouncementInput,
|
||||||
|
type AnnouncementType,
|
||||||
|
} from "~/lib/arcadia/announcements"
|
||||||
|
import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { useSession } from "~/lib/session"
|
||||||
|
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Announcements")
|
||||||
|
|
||||||
|
const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"]
|
||||||
|
|
||||||
|
const KIND_OPTIONS: { value: AnnouncementType; hint: string }[] = [
|
||||||
|
{ value: "info", hint: "Neutral update" },
|
||||||
|
{ value: "warning", hint: "Degraded service or heads-up" },
|
||||||
|
{ value: "maintenance", hint: "Scheduled work" },
|
||||||
|
{ value: "incident", hint: "Active outage" },
|
||||||
|
{ value: "feature", hint: "Something new shipped" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function typeToAlertVariant(
|
||||||
|
t: AnnouncementType,
|
||||||
|
): "info" | "success" | "warning" | "error" | "neutral" {
|
||||||
|
if (t === "incident") return "error"
|
||||||
|
if (t === "warning" || t === "maintenance") return "warning"
|
||||||
|
if (t === "feature") return "success"
|
||||||
|
return "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishButtonLabel(opts: {
|
||||||
|
isEdit: boolean
|
||||||
|
active: boolean
|
||||||
|
audience: "platform" | "tenant"
|
||||||
|
tenantId: string
|
||||||
|
tenants: Tenant[]
|
||||||
|
}): string {
|
||||||
|
if (opts.isEdit) return "Save changes"
|
||||||
|
if (!opts.active) return "Save draft"
|
||||||
|
if (opts.audience === "tenant") {
|
||||||
|
const name = opts.tenants.find((t) => t.id === opts.tenantId)?.name
|
||||||
|
return name ? `Publish to ${name}` : "Publish to tenant"
|
||||||
|
}
|
||||||
|
return "Publish to all users"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Editor =
|
||||||
|
| { kind: "create" }
|
||||||
|
| { kind: "edit"; announcement: Announcement }
|
||||||
|
| null
|
||||||
|
|
||||||
|
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)
|
||||||
|
// 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)
|
||||||
|
const [refreshedAt, setRefreshedAt] = useState<number | null>(null)
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
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)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [arcadia])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshedAt == null) return
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), 30_000)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [refreshedAt])
|
||||||
|
|
||||||
|
const lastRefreshedLabel = useMemo(() => {
|
||||||
|
if (refreshedAt == null) return null
|
||||||
|
const seconds = Math.max(1, Math.round((now - refreshedAt) / 1000))
|
||||||
|
if (seconds < 60) return `${seconds}s ago`
|
||||||
|
const minutes = Math.round(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
return `${Math.round(minutes / 60)}h ago`
|
||||||
|
}, [refreshedAt, now])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) refresh()
|
||||||
|
}, [session, refresh])
|
||||||
|
|
||||||
|
const columns = useMemo<Column<Announcement>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "title",
|
||||||
|
header: "Title",
|
||||||
|
accessor: "title",
|
||||||
|
sortable: true,
|
||||||
|
cell: (a) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{a.title}</span>
|
||||||
|
{a.body ? (
|
||||||
|
<span className="line-clamp-1 text-xs text-muted-foreground">{a.body}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
header: "Type",
|
||||||
|
accessor: "announcement_type",
|
||||||
|
sortable: true,
|
||||||
|
cell: (a) => <BadgeCell label={a.announcement_type} tone={typeTone(a.announcement_type)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scope",
|
||||||
|
header: "Audience",
|
||||||
|
cell: (a) => {
|
||||||
|
if (!a.tenant_id) return <Badge>All apps</Badge>
|
||||||
|
const t = tenants.find((x) => x.id === a.tenant_id)
|
||||||
|
return <Badge variant="secondary">{t?.slug ?? "Single tenant"}</Badge>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "active",
|
||||||
|
header: "Active",
|
||||||
|
accessor: "active",
|
||||||
|
sortable: true,
|
||||||
|
cell: (a) => (
|
||||||
|
<BadgeCell label={a.active ? "live" : "off"} tone={a.active ? "success" : "default"} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "window",
|
||||||
|
header: "Window",
|
||||||
|
cell: (a) => (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{a.starts_at ? new Date(a.starts_at).toLocaleDateString() : "—"}
|
||||||
|
{" → "}
|
||||||
|
{a.ends_at ? new Date(a.ends_at).toLocaleDateString() : "∞"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated",
|
||||||
|
header: "Updated",
|
||||||
|
accessor: "updated_at",
|
||||||
|
sortable: true,
|
||||||
|
cell: (a) => <DateCell value={a.updated_at} format="short" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
align: "right",
|
||||||
|
cell: (a) => {
|
||||||
|
const items: ActionItem[] = [
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
dataAction: `announcement-${a.id}-edit`,
|
||||||
|
onSelect: () => setEditor({ kind: "edit", announcement: a }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "toggle",
|
||||||
|
label: a.active ? "Deactivate" : "Activate",
|
||||||
|
dataAction: `announcement-${a.id}-toggle`,
|
||||||
|
onSelect: async () => {
|
||||||
|
try {
|
||||||
|
await updateAnnouncement(arcadia, a.id, { active: !a.active })
|
||||||
|
await refresh()
|
||||||
|
toast.success(
|
||||||
|
a.active ? `Deactivated "${a.title}"` : `Activated "${a.title}"`,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
errorMessage(
|
||||||
|
err,
|
||||||
|
`${a.active ? "deactivate" : "activate"} "${a.title}"`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
destructive: true,
|
||||||
|
dataAction: `announcement-${a.id}-delete`,
|
||||||
|
onSelect: () => setPendingDelete(a),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return <ActionsCell items={items} triggerDataAction={`announcement-${a.id}-actions`} />
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[arcadia, refresh, tenants, toast],
|
||||||
|
)
|
||||||
|
|
||||||
|
const summary = useMemo(
|
||||||
|
() => ({
|
||||||
|
total: items.length,
|
||||||
|
active: items.filter((a) => a.active).length,
|
||||||
|
byType: countBy(items, (a) => a.announcement_type),
|
||||||
|
}),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
useRegisterContext("announcements", summary)
|
||||||
|
|
||||||
|
const table = useTable<Announcement>({
|
||||||
|
data: items,
|
||||||
|
columns,
|
||||||
|
getRowId: (a) => a.id,
|
||||||
|
initialPageSize: 25,
|
||||||
|
initialSearch: search,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
table.setSearch(search)
|
||||||
|
}, [search, table])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-[26px] font-[620] leading-[1.1] tracking-[-0.02em]">
|
||||||
|
Announcements
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1.5 max-w-[56ch] text-[13.5px] leading-[1.5] text-muted-foreground">
|
||||||
|
Banners that appear at the top of every Sky AI app. Use them for maintenance
|
||||||
|
windows, incidents, or new features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
{lastRefreshedLabel ? (
|
||||||
|
<span
|
||||||
|
className="text-xs tabular-nums text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
title={`Last refreshed ${lastRefreshedLabel}`}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Updated </span>
|
||||||
|
{lastRefreshedLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="Refresh announcements"
|
||||||
|
data-action="announcements-refresh"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditor({ kind: "create" })}
|
||||||
|
data-action="announcements-create"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New announcement
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-3">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
placeholder="Search by title, body, or type"
|
||||||
|
data-action="announcements-search"
|
||||||
|
className="max-w-sm flex-1"
|
||||||
|
/>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||||
|
{search && table.total !== items.length
|
||||||
|
? `${table.total} of ${items.length}`
|
||||||
|
: `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="relative p-0">
|
||||||
|
<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%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={table.page}
|
||||||
|
pageSize={table.pageSize}
|
||||||
|
total={table.total}
|
||||||
|
onPageChange={table.setPage}
|
||||||
|
onPageSizeChange={table.setPageSize}
|
||||||
|
/>
|
||||||
|
</DataState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDelete !== null}
|
||||||
|
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||||
|
title="Delete announcement?"
|
||||||
|
description={pendingDelete ? `${pendingDelete.title} will be removed for all users.` : ""}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingDelete) return
|
||||||
|
const title = pendingDelete.title
|
||||||
|
try {
|
||||||
|
await deleteAnnouncement(arcadia, pendingDelete.id)
|
||||||
|
setPendingDelete(null)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`Deleted "${title}"`)
|
||||||
|
} catch (err) {
|
||||||
|
setPendingDelete(null)
|
||||||
|
toast.error(errorMessage(err, `delete "${title}"`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnnouncementEditorDialog
|
||||||
|
state={editor}
|
||||||
|
tenants={tenants}
|
||||||
|
onClose={() => setEditor(null)}
|
||||||
|
onSaved={async (msg) => {
|
||||||
|
setEditor(null)
|
||||||
|
await refresh()
|
||||||
|
toast.success(msg)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeTone(t: AnnouncementType): BadgeTone {
|
||||||
|
if (t === "incident") return "danger"
|
||||||
|
if (t === "warning" || t === "maintenance") return "warning"
|
||||||
|
if (t === "feature") return "success"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
|
||||||
|
return arr.reduce<Record<string, number>>((acc, x) => {
|
||||||
|
const k = key(x)
|
||||||
|
acc[k] = (acc[k] ?? 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnnouncementEditorDialog({
|
||||||
|
state,
|
||||||
|
tenants,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
state: Editor
|
||||||
|
tenants: Tenant[]
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (msg: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const open = state !== null
|
||||||
|
const isEdit = state?.kind === "edit"
|
||||||
|
const initial = isEdit ? state.announcement : null
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [body, setBody] = useState("")
|
||||||
|
const [type, setType] = useState<AnnouncementType>("info")
|
||||||
|
const [audience, setAudience] = useState<"platform" | "tenant">("platform")
|
||||||
|
const [tenantId, setTenantId] = useState<string>("")
|
||||||
|
const [actionLabel, setActionLabel] = useState("")
|
||||||
|
const [actionUrl, setActionUrl] = useState("")
|
||||||
|
const [startsAt, setStartsAt] = useState("")
|
||||||
|
const [endsAt, setEndsAt] = useState("")
|
||||||
|
const [dismissible, setDismissible] = useState(true)
|
||||||
|
const [active, setActive] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
// 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)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (initial) {
|
||||||
|
setTitle(initial.title)
|
||||||
|
setBody(initial.body ?? "")
|
||||||
|
setType(initial.announcement_type)
|
||||||
|
setAudience(initial.tenant_id ? "tenant" : "platform")
|
||||||
|
setTenantId(initial.tenant_id ?? "")
|
||||||
|
setActionLabel(initial.action_label ?? "")
|
||||||
|
setActionUrl(initial.action_url ?? "")
|
||||||
|
setStartsAt(initial.starts_at ? initial.starts_at.slice(0, 16) : "")
|
||||||
|
setEndsAt(initial.ends_at ? initial.ends_at.slice(0, 16) : "")
|
||||||
|
setDismissible(initial.dismissible)
|
||||||
|
setActive(initial.active)
|
||||||
|
} else {
|
||||||
|
setTitle("")
|
||||||
|
setBody("")
|
||||||
|
setType("info")
|
||||||
|
setAudience("platform")
|
||||||
|
setTenantId("")
|
||||||
|
setActionLabel("")
|
||||||
|
setActionUrl("")
|
||||||
|
setStartsAt("")
|
||||||
|
setEndsAt("")
|
||||||
|
setDismissible(true)
|
||||||
|
setActive(true)
|
||||||
|
}
|
||||||
|
}, [open, initial])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setLocalError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const input: AnnouncementInput = {
|
||||||
|
title,
|
||||||
|
body: body || undefined,
|
||||||
|
announcement_type: type,
|
||||||
|
audience,
|
||||||
|
action_label: actionLabel || null,
|
||||||
|
action_url: actionUrl || null,
|
||||||
|
starts_at: startsAt ? new Date(startsAt).toISOString() : null,
|
||||||
|
ends_at: endsAt ? new Date(endsAt).toISOString() : null,
|
||||||
|
dismissible,
|
||||||
|
active,
|
||||||
|
tenant_id: audience === "tenant" ? tenantId || null : null,
|
||||||
|
}
|
||||||
|
if (isEdit && initial) {
|
||||||
|
await updateAnnouncement(arcadia, initial.id, input)
|
||||||
|
await onSaved(`Updated "${title}"`)
|
||||||
|
} else {
|
||||||
|
await createAnnouncement(arcadia, input)
|
||||||
|
await onSaved(
|
||||||
|
active ? `Published "${title}"` : `Saved draft "${title}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Edit announcement" : "New announcement"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
A banner shows at the top of every Sky AI app. It's visible when it's switched on
|
||||||
|
and today falls inside its date range.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Live preview — what users will see. Updates as the form is edited so
|
||||||
|
the operator never has to imagine the output or publish blind. */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
Preview
|
||||||
|
</Label>
|
||||||
|
<div className="rounded-md border bg-muted/30 p-3">
|
||||||
|
<AlertBanner
|
||||||
|
variant={typeToAlertVariant(type)}
|
||||||
|
title={title || "Your banner title appears here"}
|
||||||
|
dismissible={dismissible}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
action={
|
||||||
|
actionLabel && actionUrl ? (
|
||||||
|
<Button size="xs" variant="outline" type="button" tabIndex={-1}>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{body || (
|
||||||
|
<span className="italic opacity-60">Body text appears here.</span>
|
||||||
|
)}
|
||||||
|
</AlertBanner>
|
||||||
|
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||||
|
{audience === "tenant"
|
||||||
|
? `Visible to users of ${
|
||||||
|
tenants.find((t) => t.id === tenantId)?.name ?? "the selected tenant"
|
||||||
|
} only.`
|
||||||
|
: "Visible to everyone across every Sky AI app."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<Input
|
||||||
|
id="ann-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
data-action="announcement-form-title"
|
||||||
|
placeholder="Scheduled maintenance Sunday 2am AEST"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ann-body">Body</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ann-body"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
data-action="announcement-form-body"
|
||||||
|
placeholder="Expect ~10 minutes of downtime while we ship the new tenant switcher."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Kind</Label>
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger data-action="announcement-form-type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{KIND_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium capitalize">{opt.value}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{opt.hint}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Who sees this</Label>
|
||||||
|
<Select value={audience} onValueChange={(v) => setAudience(v as "platform" | "tenant")}>
|
||||||
|
<SelectTrigger data-action="announcement-form-audience">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="platform">Everyone</SelectItem>
|
||||||
|
<SelectItem value="tenant">Just one tenant</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{audience === "tenant" ? (
|
||||||
|
<div className="col-span-2 flex flex-col gap-1.5">
|
||||||
|
<Label>Which tenant</Label>
|
||||||
|
<Select value={tenantId} onValueChange={setTenantId}>
|
||||||
|
<SelectTrigger data-action="announcement-form-tenant">
|
||||||
|
<SelectValue placeholder="Pick a tenant" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name} ({t.slug})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ann-starts">Starts</Label>
|
||||||
|
<Input
|
||||||
|
id="ann-starts"
|
||||||
|
type="datetime-local"
|
||||||
|
value={startsAt}
|
||||||
|
onChange={(e) => setStartsAt(e.target.value)}
|
||||||
|
data-action="announcement-form-starts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ann-ends">Ends</Label>
|
||||||
|
<Input
|
||||||
|
id="ann-ends"
|
||||||
|
type="datetime-local"
|
||||||
|
value={endsAt}
|
||||||
|
onChange={(e) => setEndsAt(e.target.value)}
|
||||||
|
data-action="announcement-form-ends"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional link group — heading clarifies these two are paired. */}
|
||||||
|
<div className="col-span-2 flex flex-col gap-2 rounded-md border border-dashed p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<Label className="text-sm">Add a link</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">Optional</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ann-action-label" className="text-xs text-muted-foreground">
|
||||||
|
Button text
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ann-action-label"
|
||||||
|
value={actionLabel}
|
||||||
|
onChange={(e) => setActionLabel(e.target.value)}
|
||||||
|
placeholder="Read more"
|
||||||
|
data-action="announcement-form-action-label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ann-action-url" className="text-xs text-muted-foreground">
|
||||||
|
Where it goes
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="ann-action-url"
|
||||||
|
value={actionUrl}
|
||||||
|
onChange={(e) => setActionUrl(e.target.value)}
|
||||||
|
placeholder="/changelog/v2"
|
||||||
|
data-action="announcement-form-action-url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End-user behavior toggle, not publish state — kept with content fields. */}
|
||||||
|
<div className="col-span-2 flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Label className="text-sm">Let users dismiss</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Adds an × users can click to hide the banner.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={dismissible}
|
||||||
|
onCheckedChange={setDismissible}
|
||||||
|
data-action="announcement-form-dismissible"
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
htmlFor="ann-active"
|
||||||
|
className="flex items-center gap-2 text-xs text-muted-foreground sm:mr-auto"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id="ann-active"
|
||||||
|
checked={active}
|
||||||
|
onCheckedChange={setActive}
|
||||||
|
data-action="announcement-form-active"
|
||||||
|
/>
|
||||||
|
<span>{active ? "Switched on" : "Switched off (draft)"}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={saving || !title.trim() || (audience === "tenant" && !tenantId)}
|
||||||
|
data-action="announcement-form-save"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<RefreshCw className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
)}
|
||||||
|
{publishButtonLabel({ isEdit, active, audience, tenantId, tenants })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
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"
|
||||||
1533
app/routes/buckets.tsx
Normal file
1533
app/routes/buckets.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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"
|
||||||
@@ -1,7 +1,23 @@
|
|||||||
import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Link } from "react-router"
|
import { Link } from "react-router"
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
CircleAlert,
|
||||||
|
HeartPulse,
|
||||||
|
RefreshCw,
|
||||||
|
Users as UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import { AlertBanner } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
import { PageHeader } from "~/components/layout/page-header"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import { Skeleton } from "~/components/ui/skeleton"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -9,88 +25,428 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card"
|
} from "~/components/ui/card"
|
||||||
|
import { listAuditLogs, type AuditLog } from "~/lib/arcadia/audit-logs"
|
||||||
|
import {
|
||||||
|
getHealth,
|
||||||
|
SUBSYSTEMS,
|
||||||
|
type HealthStatus,
|
||||||
|
type HealthSubsystem,
|
||||||
|
type OverallHealth,
|
||||||
|
} from "~/lib/arcadia/health"
|
||||||
|
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 { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { errorMessage } from "~/lib/errors"
|
||||||
|
import { useSession } from "~/lib/session"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Overview")
|
export const meta = () => pageTitle("Overview")
|
||||||
|
|
||||||
const tiles = [
|
interface DashboardData {
|
||||||
{
|
tenants: Tenant[]
|
||||||
to: "/assistant",
|
users: User[]
|
||||||
icon: Sparkles,
|
audit: AuditLog[]
|
||||||
title: "Assistant",
|
health: OverallHealth | null
|
||||||
body: "AI-first surface — chat, suggestions, and full UI control.",
|
}
|
||||||
accent: true,
|
|
||||||
},
|
const EMPTY: DashboardData = { tenants: [], users: [], audit: [], health: null }
|
||||||
{
|
|
||||||
to: "/resources",
|
|
||||||
icon: Boxes,
|
|
||||||
title: "Resources",
|
|
||||||
body: "Traditional list + detail surface for managed entities.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/activity",
|
|
||||||
icon: Activity,
|
|
||||||
title: "Activity",
|
|
||||||
body: "Event stream and audit log.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/library",
|
|
||||||
icon: BookOpen,
|
|
||||||
title: "Library",
|
|
||||||
body: "Saved items, templates, reusable artifacts.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function HomeRoute() {
|
export default function HomeRoute() {
|
||||||
|
const session = useSession()
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
|
||||||
|
const [data, setData] = useState<DashboardData>(EMPTY)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
|
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
const [tenants, users, audit, health] = await Promise.all([
|
||||||
|
listTenants(arcadia).catch((err) => {
|
||||||
|
throw err
|
||||||
|
}),
|
||||||
|
listUsers(arcadia),
|
||||||
|
listAuditLogs(arcadia, { limit: 10 }),
|
||||||
|
getHealth(arcadia).catch(() => null),
|
||||||
|
]).catch((err) => {
|
||||||
|
setError(err)
|
||||||
|
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
|
||||||
|
})
|
||||||
|
setData({ tenants, users, audit, health })
|
||||||
|
setRefreshedAt(new Date())
|
||||||
|
setLoading(false)
|
||||||
|
}, [arcadia])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) refresh()
|
||||||
|
}, [session, refresh])
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const activeTenants = data.tenants.filter((t) => t.status === "active").length
|
||||||
|
const activeUsers = data.users.filter((u) => u.status === "active").length
|
||||||
|
const errorEvents = data.audit.filter(
|
||||||
|
(a) => a.severity === "error" || a.severity === "critical",
|
||||||
|
).length
|
||||||
|
return {
|
||||||
|
tenants: { total: data.tenants.length, active: activeTenants },
|
||||||
|
users: { total: data.users.length, active: activeUsers },
|
||||||
|
audit: { recent: data.audit.length, errors: errorEvents },
|
||||||
|
health: deriveOverallStatus(data.health),
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
useRegisterContext("overview", stats)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Overview">
|
<AppShell>
|
||||||
<Card>
|
<PageHeader
|
||||||
<CardHeader>
|
title="Overview"
|
||||||
<CardTitle>Welcome</CardTitle>
|
description={
|
||||||
|
<>
|
||||||
|
Live snapshot of the platform — tenants, users, recent activity, and health.
|
||||||
|
{refreshedAt ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Refreshed {refreshedAt.toLocaleTimeString()}.
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
data-action="overview-refresh"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={loading ? "size-4 animate-spin" : "size-4"} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||||
|
{errorMessage(error, "load the overview")}
|
||||||
|
</AlertBanner>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatTile
|
||||||
|
to="/tenants"
|
||||||
|
dataAction="overview-tile-tenants"
|
||||||
|
icon={Building2}
|
||||||
|
label="Tenants"
|
||||||
|
value={stats.tenants.total}
|
||||||
|
sub={`${stats.tenants.active} active`}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
to="/users"
|
||||||
|
dataAction="overview-tile-users"
|
||||||
|
icon={UsersIcon}
|
||||||
|
label="Users"
|
||||||
|
value={stats.users.total}
|
||||||
|
sub={`${stats.users.active} active`}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
to="/activity"
|
||||||
|
dataAction="overview-tile-activity"
|
||||||
|
icon={Activity}
|
||||||
|
label="Recent events"
|
||||||
|
value={stats.audit.recent}
|
||||||
|
sub={
|
||||||
|
stats.audit.errors > 0
|
||||||
|
? `${stats.audit.errors} error${stats.audit.errors === 1 ? "" : "s"}`
|
||||||
|
: "no errors"
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
tone={stats.audit.errors > 0 ? "warning" : "default"}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
to="/monitoring"
|
||||||
|
dataAction="overview-tile-health"
|
||||||
|
icon={HeartPulse}
|
||||||
|
label="Platform health"
|
||||||
|
value={statusLabel(stats.health)}
|
||||||
|
sub={data.health ? `as of ${new Date(data.health.checked_at).toLocaleTimeString()}` : "unreachable"}
|
||||||
|
loading={loading}
|
||||||
|
tone={statusTone(stats.health)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="flex-row items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Recent activity</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
A hybrid traditional + AI-first scaffold. Use the rail to navigate;
|
Latest audit events across the platform.
|
||||||
the Assistant can drive the UI on your behalf — try{" "}
|
|
||||||
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
⌘⇧P
|
|
||||||
</kbd>{" "}
|
|
||||||
for the script runner.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/activity"
|
||||||
|
data-action="overview-activity-all"
|
||||||
|
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RecentActivity logs={data.audit} loading={loading} />
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<Card>
|
||||||
{tiles.map((t) => {
|
|
||||||
const Icon = t.icon
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={t.to}
|
|
||||||
to={t.to}
|
|
||||||
data-action={`home-tile-${t.title.toLowerCase()}`}
|
|
||||||
className="group block"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className={[
|
|
||||||
"h-full transition-colors",
|
|
||||||
t.accent
|
|
||||||
? "border-primary/30 bg-primary/5 hover:border-primary/50"
|
|
||||||
: "hover:border-foreground/20",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
<CardTitle>Subsystems</CardTitle>
|
||||||
<Icon className="size-5" />
|
<CardDescription>
|
||||||
</div>
|
Live probe of each platform subsystem.
|
||||||
<CardTitle className="flex items-center gap-2">
|
</CardDescription>
|
||||||
{t.title}
|
|
||||||
<ArrowRight className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{t.body}</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SubsystemList health={data.health} loading={loading} />
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatTile({
|
||||||
|
to,
|
||||||
|
dataAction,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
loading,
|
||||||
|
tone = "default",
|
||||||
|
}: {
|
||||||
|
to: string
|
||||||
|
dataAction: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
label: string
|
||||||
|
value: number | string
|
||||||
|
sub: string
|
||||||
|
loading: boolean
|
||||||
|
tone?: "default" | "warning" | "error" | "ok"
|
||||||
|
}) {
|
||||||
|
const accent =
|
||||||
|
tone === "error"
|
||||||
|
? "border-destructive/40 bg-destructive/5"
|
||||||
|
: tone === "warning"
|
||||||
|
? "border-amber-500/40 bg-amber-500/5"
|
||||||
|
: tone === "ok"
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/5"
|
||||||
|
: ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
data-action={dataAction}
|
||||||
|
className="group block focus:outline-none"
|
||||||
|
>
|
||||||
|
<Card className={`h-full transition-colors hover:border-foreground/20 ${accent}`}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-1">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-9 w-20" />
|
||||||
|
) : (
|
||||||
|
<span className="text-3xl font-semibold tabular-nums">{value}</span>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">{sub}</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentActivity({ logs, loading }: { logs: AuditLog[]; loading: boolean }) {
|
||||||
|
if (loading && logs.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="py-4 text-sm text-muted-foreground">
|
||||||
|
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (logs.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="py-4 text-sm text-muted-foreground">No recent events.</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col divide-y">
|
||||||
|
{logs.slice(0, 8).map((l) => (
|
||||||
|
<li
|
||||||
|
key={l.id}
|
||||||
|
className="flex items-start justify-between gap-3 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
{l.action}
|
||||||
|
</code>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{l.resource_type}
|
||||||
|
{l.resource_id ? ` · ${l.resource_id.slice(0, 8)}…` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{l.user?.email ?? "system"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||||
|
<SeverityDot severity={l.severity} />
|
||||||
|
<time
|
||||||
|
className="text-[11px] text-muted-foreground"
|
||||||
|
dateTime={l.inserted_at}
|
||||||
|
>
|
||||||
|
{timeAgo(l.inserted_at)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubsystemList({
|
||||||
|
health,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
health: OverallHealth | null
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
if (loading && !health) {
|
||||||
|
return (
|
||||||
|
<p className="py-4 text-sm text-muted-foreground">
|
||||||
|
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Probing…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!health) {
|
||||||
|
return (
|
||||||
|
<p className="py-4 text-sm text-muted-foreground">
|
||||||
|
Health endpoint unreachable.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col divide-y">
|
||||||
|
{SUBSYSTEMS.map((sys) => {
|
||||||
|
const sub = health.subsystems[sys]
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={sys}
|
||||||
|
className="flex items-center justify-between gap-3 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium capitalize">{labelFor(sys)}</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={sub?.status ?? "unconfigured"} />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{sub?.message ?? statusLabel(sub?.status ?? "unconfigured")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityDot({ severity }: { severity: string }) {
|
||||||
|
const tone =
|
||||||
|
severity === "critical" || severity === "error"
|
||||||
|
? "bg-destructive"
|
||||||
|
: severity === "warning"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-emerald-500"
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={severity}
|
||||||
|
title={severity}
|
||||||
|
className={`size-2 rounded-full ${tone}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: HealthStatus }) {
|
||||||
|
if (status === "ok")
|
||||||
|
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />
|
||||||
|
if (status === "degraded")
|
||||||
|
return <AlertTriangle className="size-4 text-amber-500" aria-label="degraded" />
|
||||||
|
if (status === "error")
|
||||||
|
return <CircleAlert className="size-4 text-destructive" aria-label="error" />
|
||||||
|
return <CircleAlert className="size-4 text-muted-foreground" aria-label="unconfigured" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(sys: HealthSubsystem): string {
|
||||||
|
if (sys === "api") return "API"
|
||||||
|
if (sys === "db") return "Database"
|
||||||
|
return sys
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: HealthStatus | string): string {
|
||||||
|
if (status === "ok") return "Healthy"
|
||||||
|
if (status === "degraded") return "Degraded"
|
||||||
|
if (status === "error") return "Down"
|
||||||
|
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"
|
||||||
|
if (status === "error") return "error"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const t = new Date(iso).getTime()
|
||||||
|
if (Number.isNaN(t)) return ""
|
||||||
|
const diff = Date.now() - t
|
||||||
|
const sec = Math.round(diff / 1000)
|
||||||
|
if (sec < 60) return `${sec}s ago`
|
||||||
|
const min = Math.round(sec / 60)
|
||||||
|
if (min < 60) return `${min}m ago`
|
||||||
|
const hr = Math.round(min / 60)
|
||||||
|
if (hr < 24) return `${hr}h ago`
|
||||||
|
const d = Math.round(hr / 24)
|
||||||
|
return `${d}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
|
|||||||
634
app/routes/integrations.tsx
Normal file
634
app/routes/integrations.tsx
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
// Integrations (operator) — platform/pooled external-API arrangements across
|
||||||
|
// every scope, backed by the integration registry on arcadia-llm-gateway
|
||||||
|
// (`/api/v1/integrations*`). The operator manages pooled credentials and
|
||||||
|
// inspects cross-tenant usage metadata; secrets are write-only.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
FlaskConical,
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
Plug,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { ArcadiaError } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import { Switch } from "~/components/ui/switch"
|
||||||
|
import { useGatewayClient } from "~/lib/gateway"
|
||||||
|
import {
|
||||||
|
addCredential,
|
||||||
|
createIntegration,
|
||||||
|
credentialHealth,
|
||||||
|
deleteIntegration,
|
||||||
|
formatUsd,
|
||||||
|
listIntegrations,
|
||||||
|
testIntegration,
|
||||||
|
updateIntegration,
|
||||||
|
usageSummary,
|
||||||
|
type AuthKind,
|
||||||
|
type Integration,
|
||||||
|
type Scope,
|
||||||
|
type UsageEntry,
|
||||||
|
} from "~/lib/arcadia/integrations"
|
||||||
|
|
||||||
|
const AUTH_KINDS: AuthKind[] = ["bearer_static", "api_key_header", "basic", "oauth2"]
|
||||||
|
const SCOPES: Scope[] = ["platform", "tenant", "app", "user", "agent"]
|
||||||
|
const SCOPE_FILTERS: Array<Scope | "all"> = ["all", ...SCOPES]
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
scope: Scope
|
||||||
|
scope_id: string
|
||||||
|
provider: string
|
||||||
|
capability: string
|
||||||
|
display_name: string
|
||||||
|
unit: string
|
||||||
|
price_usd: string
|
||||||
|
monthly_budget_usd: string
|
||||||
|
secret_name: string
|
||||||
|
auth_kind: AuthKind
|
||||||
|
secret: string
|
||||||
|
pooled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: Form = {
|
||||||
|
scope: "platform",
|
||||||
|
scope_id: "",
|
||||||
|
provider: "",
|
||||||
|
capability: "",
|
||||||
|
display_name: "",
|
||||||
|
unit: "call",
|
||||||
|
price_usd: "",
|
||||||
|
monthly_budget_usd: "",
|
||||||
|
secret_name: "",
|
||||||
|
auth_kind: "bearer_static",
|
||||||
|
secret: "",
|
||||||
|
pooled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntegrationsRoute() {
|
||||||
|
const gw = useGatewayClient()
|
||||||
|
const [items, setItems] = useState<Integration[]>([])
|
||||||
|
const [usage, setUsage] = useState<UsageEntry[]>([])
|
||||||
|
const [scopeFilter, setScopeFilter] = useState<Scope | "all">("all")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState<Integration | "new" | null>(null)
|
||||||
|
const [tests, setTests] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
const filter = scopeFilter === "all" ? {} : { scope: scopeFilter }
|
||||||
|
try {
|
||||||
|
const [list, use] = await Promise.all([
|
||||||
|
listIntegrations(gw, filter),
|
||||||
|
usageSummary(gw, filter).catch(() => [] as UsageEntry[]),
|
||||||
|
])
|
||||||
|
setItems(list)
|
||||||
|
setUsage(use)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load integrations.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [gw, scopeFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const usageById = useMemo(
|
||||||
|
() => new Map(usage.map((u) => [u.integration_id, u] as const)),
|
||||||
|
[usage],
|
||||||
|
)
|
||||||
|
|
||||||
|
const runTest = useCallback(
|
||||||
|
async (it: Integration) => {
|
||||||
|
setTests((t) => ({ ...t, [it.id]: { ok: true, message: "Testing…" } }))
|
||||||
|
try {
|
||||||
|
const verdict = await testIntegration(gw, it.id)
|
||||||
|
const remaining = verdict.policy?.remaining_budget_usd
|
||||||
|
setTests((t) => ({
|
||||||
|
...t,
|
||||||
|
[it.id]: {
|
||||||
|
ok: true,
|
||||||
|
message:
|
||||||
|
verdict.status === "ok"
|
||||||
|
? `OK — within budget & rate${remaining ? ` (${formatUsd(remaining)} left)` : ""}`
|
||||||
|
: verdict.status,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof ArcadiaError
|
||||||
|
? e.status === 409
|
||||||
|
? "Credential expired — rotate it"
|
||||||
|
: e.status === 429
|
||||||
|
? "Over budget / rate limit"
|
||||||
|
: e.status === 404
|
||||||
|
? "No credential to test"
|
||||||
|
: e.message
|
||||||
|
: "Test failed"
|
||||||
|
setTests((t) => ({ ...t, [it.id]: { ok: false, message: msg } }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gw],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleEnabled = useCallback(
|
||||||
|
async (it: Integration, enabled: boolean) => {
|
||||||
|
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled } : x)))
|
||||||
|
try {
|
||||||
|
await updateIntegration(gw, it.id, { enabled })
|
||||||
|
} catch {
|
||||||
|
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled: !enabled } : x)))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gw],
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
async (it: Integration) => {
|
||||||
|
if (!window.confirm(`Delete ${it.display_name || it.provider} and its credentials?`)) return
|
||||||
|
await deleteIntegration(gw, it.id)
|
||||||
|
await refresh()
|
||||||
|
},
|
||||||
|
[gw, refresh],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Plug className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Integrations</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Platform & pooled external-API credentials across every scope.
|
||||||
|
Keys are stored encrypted and never shown; usage is metadata only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={scopeFilter} onValueChange={(v) => setScopeFilter((v as Scope | "all") ?? "all")}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCOPE_FILTERS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s === "all" ? "All scopes" : s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => setEditing("new")}>
|
||||||
|
<Plus className="size-4" /> Add integration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">Couldn’t load integrations</CardTitle>
|
||||||
|
<CardDescription>{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>No integrations in this scope</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Register a platform/pooled arrangement — a shared key the platform
|
||||||
|
meters and bills to tenants who opt in.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={() => setEditing("new")}>
|
||||||
|
<Plus className="size-4" /> Add integration
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{items.map((it) => {
|
||||||
|
const u = usageById.get(it.id)
|
||||||
|
const test = tests[it.id]
|
||||||
|
return (
|
||||||
|
<Card key={it.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{it.display_name || it.provider}
|
||||||
|
<Badge>{it.scope}</Badge>
|
||||||
|
{it.scope_id ? (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{it.scope_id}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{it.capability ? (
|
||||||
|
<Badge variant="secondary">{it.capability}</Badge>
|
||||||
|
) : null}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{it.provider}
|
||||||
|
{it.cost_model?.price_usd
|
||||||
|
? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}`
|
||||||
|
: ""}
|
||||||
|
{it.constraints?.monthly_budget_usd
|
||||||
|
? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo`
|
||||||
|
: ""}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`en-${it.id}`} className="text-xs text-muted-foreground">
|
||||||
|
{it.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id={`en-${it.id}`}
|
||||||
|
checked={it.enabled}
|
||||||
|
onCheckedChange={(v) => toggleEnabled(it, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{it.credentials.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No credential set.</p>
|
||||||
|
) : (
|
||||||
|
it.credentials.map((cred) => {
|
||||||
|
const health = credentialHealth(cred)
|
||||||
|
return (
|
||||||
|
<div key={cred.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<KeyRound className="size-4 text-muted-foreground" />
|
||||||
|
<span className="font-mono">{cred.secret_name}</span>
|
||||||
|
<Badge variant="outline">{cred.source}</Badge>
|
||||||
|
<HealthBadge health={health} />
|
||||||
|
{cred.expires_at ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
expires {new Date(cred.expires_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{test ? (
|
||||||
|
<p
|
||||||
|
className={`text-sm ${test.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}
|
||||||
|
>
|
||||||
|
{test.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => runTest(it)}>
|
||||||
|
<FlaskConical className="size-4" /> Test
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditing(it)}>
|
||||||
|
<Pencil className="size-4" /> Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => remove(it)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<IntegrationDialog
|
||||||
|
mode={editing === "new" ? "new" : "edit"}
|
||||||
|
initial={editing === "new" ? null : editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditing(null)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthBadge({ health }: { health: ReturnType<typeof credentialHealth> }) {
|
||||||
|
if (health === "ok")
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<CheckCircle2 className="size-3" /> healthy
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
const label = health === "missing" ? "no secret" : health
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertTriangle className="size-3" /> {label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationDialog({
|
||||||
|
mode,
|
||||||
|
initial,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
mode: "new" | "edit"
|
||||||
|
initial: Integration | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
const gw = useGatewayClient()
|
||||||
|
const [form, setForm] = useState<Form>(() =>
|
||||||
|
initial
|
||||||
|
? {
|
||||||
|
...emptyForm,
|
||||||
|
scope: initial.scope,
|
||||||
|
scope_id: initial.scope_id ?? "",
|
||||||
|
provider: initial.provider,
|
||||||
|
capability: initial.capability ?? "",
|
||||||
|
display_name: initial.display_name ?? "",
|
||||||
|
unit: initial.cost_model?.unit ?? "call",
|
||||||
|
price_usd: initial.cost_model?.price_usd?.toString() ?? "",
|
||||||
|
monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "",
|
||||||
|
}
|
||||||
|
: emptyForm,
|
||||||
|
)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
const set = (patch: Partial<Form>) => setForm((f) => ({ ...f, ...patch }))
|
||||||
|
|
||||||
|
const needsScopeId = form.scope !== "platform"
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
const cost_model = form.price_usd
|
||||||
|
? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" }
|
||||||
|
: undefined
|
||||||
|
const constraints = form.monthly_budget_usd
|
||||||
|
? { monthly_budget_usd: form.monthly_budget_usd }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (mode === "edit" && initial) {
|
||||||
|
await updateIntegration(gw, initial.id, {
|
||||||
|
provider: form.provider.trim(),
|
||||||
|
capability: form.capability.trim() || undefined,
|
||||||
|
display_name: form.display_name.trim() || undefined,
|
||||||
|
cost_model,
|
||||||
|
constraints,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const created = await createIntegration(gw, {
|
||||||
|
scope: form.scope,
|
||||||
|
scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined,
|
||||||
|
provider: form.provider.trim(),
|
||||||
|
capability: form.capability.trim() || undefined,
|
||||||
|
display_name: form.display_name.trim() || undefined,
|
||||||
|
cost_model,
|
||||||
|
constraints,
|
||||||
|
})
|
||||||
|
if (form.secret_name.trim() && form.secret.trim()) {
|
||||||
|
await addCredential(gw, created.id, {
|
||||||
|
secret_name: form.secret_name.trim(),
|
||||||
|
auth_kind: form.auth_kind,
|
||||||
|
secret: form.secret,
|
||||||
|
source: form.pooled ? "pooled" : "byo",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : "Save failed.")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => (!o ? onClose() : undefined)}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === "new" ? "Add integration" : "Edit integration"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Register an external-API arrangement. Platform scope = a pooled key
|
||||||
|
the platform meters and bills.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
{mode === "new" ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Scope">
|
||||||
|
<Select value={form.scope} onValueChange={(v) => set({ scope: (v as Scope) ?? "platform" })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCOPES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Scope ID" hint={needsScopeId ? "tenant/app/user/agent id" : "n/a for platform"}>
|
||||||
|
<Input
|
||||||
|
value={form.scope_id}
|
||||||
|
onChange={(e) => set({ scope_id: e.target.value })}
|
||||||
|
disabled={!needsScopeId}
|
||||||
|
placeholder={needsScopeId ? "acme" : "—"}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field label="Provider" hint="e.g. tavily, google_maps, duffel">
|
||||||
|
<Input
|
||||||
|
value={form.provider}
|
||||||
|
onChange={(e) => set({ provider: e.target.value })}
|
||||||
|
placeholder="tavily"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Capability (optional)" hint="e.g. web_search, geocode">
|
||||||
|
<Input
|
||||||
|
value={form.capability}
|
||||||
|
onChange={(e) => set({ capability: e.target.value })}
|
||||||
|
placeholder="web_search"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Display name (optional)">
|
||||||
|
<Input
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={(e) => set({ display_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Price (USD)" hint="per unit, for metering">
|
||||||
|
<Input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.price_usd}
|
||||||
|
onChange={(e) => set({ price_usd: e.target.value })}
|
||||||
|
placeholder="0.01"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Unit">
|
||||||
|
<Select value={form.unit} onValueChange={(v) => set({ unit: v ?? "call" })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="call">call</SelectItem>
|
||||||
|
<SelectItem value="search">search</SelectItem>
|
||||||
|
<SelectItem value="1k_tokens">1k_tokens</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Monthly budget (USD, optional)" hint="resolve is refused past this">
|
||||||
|
<Input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.monthly_budget_usd}
|
||||||
|
onChange={(e) => set({ monthly_budget_usd: e.target.value })}
|
||||||
|
placeholder="500"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{mode === "new" ? (
|
||||||
|
<div className="space-y-3 rounded-lg border p-3">
|
||||||
|
<p className="text-sm font-medium">Credential (optional)</p>
|
||||||
|
<Field label="Secret name" hint="the stable handle tools resolve by">
|
||||||
|
<Input
|
||||||
|
value={form.secret_name}
|
||||||
|
onChange={(e) => set({ secret_name: e.target.value })}
|
||||||
|
placeholder="tavily_default"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Auth kind">
|
||||||
|
<Select
|
||||||
|
value={form.auth_kind}
|
||||||
|
onValueChange={(v) => set({ auth_kind: (v as AuthKind) ?? "bearer_static" })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AUTH_KINDS.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{k}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Source">
|
||||||
|
<div className="flex h-9 items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="pooled"
|
||||||
|
checked={form.pooled}
|
||||||
|
onCheckedChange={(v) => set({ pooled: v })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="pooled" className="text-sm">
|
||||||
|
{form.pooled ? "pooled (billed)" : "BYO key"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Secret value" hint="stored encrypted, never shown again">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.secret}
|
||||||
|
onChange={(e) => set({ secret: e.target.value })}
|
||||||
|
placeholder="sk-…"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={saving || !form.provider.trim()}>
|
||||||
|
{saving ? "Saving…" : mode === "new" ? "Create" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||||
|
</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 title="Library">
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
61
app/routes/login.2fa.tsx
Normal file
61
app/routes/login.2fa.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useNavigate, useSearchParams } from "react-router"
|
||||||
|
|
||||||
|
import { TwoFactorChallengeForm } from "@crema/arcadia-auth-ui"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { persistFromArcadiaLogin } from "~/lib/session"
|
||||||
|
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Two-factor verification")
|
||||||
|
|
||||||
|
export default function TwoFactorRoute() {
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const challenge = params.get("challenge") ?? ""
|
||||||
|
const next = params.get("next") || "/"
|
||||||
|
const [mode, setMode] = useState<"totp" | "recovery">("totp")
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<div
|
||||||
|
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<h1 className="text-base font-semibold">Challenge missing</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
This page is only reachable after a sign-in attempt. Start over.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||||
|
data-action="2fa-back-to-login"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<TwoFactorChallengeForm
|
||||||
|
brand={<AuthBrand />}
|
||||||
|
challenge={challenge}
|
||||||
|
mode={mode}
|
||||||
|
onUseRecoveryCode={mode === "totp" ? () => setMode("recovery") : undefined}
|
||||||
|
onBack={
|
||||||
|
mode === "recovery"
|
||||||
|
? () => setMode("totp")
|
||||||
|
: () => navigate("/login")
|
||||||
|
}
|
||||||
|
onSuccess={({ tokens, user }) => {
|
||||||
|
persistFromArcadiaLogin(tokens, user)
|
||||||
|
navigate(next, { replace: true })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
app/routes/login.forgot.tsx
Normal file
50
app/routes/login.forgot.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useNavigate } from "react-router"
|
||||||
|
import { CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { PasswordResetRequestForm } from "@crema/arcadia-auth-ui"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Reset password")
|
||||||
|
|
||||||
|
export default function ForgotPasswordRoute() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [sentTo, setSentTo] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (sentTo) {
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<div
|
||||||
|
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="size-8 text-emerald-500" />
|
||||||
|
<h1 className="text-base font-semibold">Check your email</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
If an account exists for <strong>{sentTo}</strong>, we've sent a link
|
||||||
|
to reset your password.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||||
|
data-action="forgot-back-to-login"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<PasswordResetRequestForm
|
||||||
|
brand={<AuthBrand />}
|
||||||
|
onBack={() => navigate("/login")}
|
||||||
|
onSuccess={(email) => setSentTo(email)}
|
||||||
|
/>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
app/routes/login.reset.tsx
Normal file
47
app/routes/login.reset.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useNavigate, useSearchParams } from "react-router"
|
||||||
|
|
||||||
|
import { PasswordResetConfirmForm } from "@crema/arcadia-auth-ui"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Set new password")
|
||||||
|
|
||||||
|
export default function ResetPasswordRoute() {
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const token = params.get("token") ?? ""
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<div
|
||||||
|
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<h1 className="text-base font-semibold">Reset link invalid</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No token in the URL. Request a fresh password reset email.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/login/forgot")}
|
||||||
|
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||||
|
data-action="reset-request-new"
|
||||||
|
>
|
||||||
|
Request a new link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<PasswordResetConfirmForm
|
||||||
|
brand={<AuthBrand />}
|
||||||
|
token={token}
|
||||||
|
onSuccess={() => navigate("/login?reset=ok", { replace: true })}
|
||||||
|
/>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { LoginForm } from "@crema/arcadia-auth-ui"
|
|||||||
import { useBrand } from "~/lib/identity"
|
import { useBrand } from "~/lib/identity"
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
import { useSession, persistFromArcadiaLogin } from "~/lib/session"
|
import { useSession, persistFromArcadiaLogin } from "~/lib/session"
|
||||||
|
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Sign in")
|
export const meta = () => pageTitle("Sign in")
|
||||||
|
|
||||||
@@ -13,45 +14,40 @@ export default function LoginRoute() {
|
|||||||
const [params] = useSearchParams()
|
const [params] = useSearchParams()
|
||||||
const session = useSession()
|
const session = useSession()
|
||||||
const brand = useBrand()
|
const brand = useBrand()
|
||||||
const BrandIcon = brand.icon
|
|
||||||
|
|
||||||
const next = params.get("next") || "/"
|
const next = params.get("next") || "/"
|
||||||
|
|
||||||
// Already signed in? Bounce.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session) navigate(next, { replace: true })
|
if (session) navigate(next, { replace: true })
|
||||||
}, [session, next, navigate])
|
}, [session, next, navigate])
|
||||||
|
|
||||||
|
// This is an operator console: don't advertise dev seed credentials or a
|
||||||
|
// self-serve "Sign up" path in production. Both are dev conveniences.
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AuthShell>
|
||||||
className="relative isolate flex min-h-svh items-center justify-center p-4"
|
|
||||||
style={{ background: "var(--background)" }}
|
|
||||||
>
|
|
||||||
<LoginForm
|
<LoginForm
|
||||||
brand={
|
brand={<AuthBrand />}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="flex size-8 items-center justify-center rounded-lg"
|
|
||||||
style={{ background: "var(--primary)", color: "var(--primary-foreground)" }}
|
|
||||||
>
|
|
||||||
<BrandIcon className="size-4" />
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold">{brand.name}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
heading={`Sign in to ${brand.name}`}
|
heading={`Sign in to ${brand.name}`}
|
||||||
subhead="Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
|
subhead={
|
||||||
|
isDev
|
||||||
|
? "Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
|
||||||
|
: "Use your arcadia credentials."
|
||||||
|
}
|
||||||
onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
|
onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
|
||||||
if (twoFactorRequired && twoFactorChallenge) {
|
if (twoFactorRequired && twoFactorChallenge) {
|
||||||
navigate(`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`)
|
navigate(
|
||||||
|
`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
persistFromArcadiaLogin(tokens, user)
|
persistFromArcadiaLogin(tokens, user)
|
||||||
navigate(next, { replace: true })
|
navigate(next, { replace: true })
|
||||||
}}
|
}}
|
||||||
onForgotPassword={() => navigate("/login/forgot")}
|
onForgotPassword={() => navigate("/login/forgot")}
|
||||||
onSignup={() => navigate("/signup")}
|
onSignup={isDev ? () => navigate("/signup") : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
649
app/routes/memberships.tsx
Normal file
649
app/routes/memberships.tsx
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Network,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import { useToast } from "@crema/notification-ui"
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
BadgeCell,
|
||||||
|
DataTable,
|
||||||
|
DateCell,
|
||||||
|
Pagination,
|
||||||
|
useTable,
|
||||||
|
type ActionItem,
|
||||||
|
type BadgeTone,
|
||||||
|
type Column,
|
||||||
|
} from "@crema/table-ui"
|
||||||
|
import { SearchInput } from "@crema/search-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, CardHeader } from "~/components/ui/card"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import {
|
||||||
|
activateMembership,
|
||||||
|
createMembership,
|
||||||
|
deleteMembership,
|
||||||
|
listMemberships,
|
||||||
|
suspendMembership,
|
||||||
|
updateMembership,
|
||||||
|
type Membership,
|
||||||
|
type MembershipStatus,
|
||||||
|
} from "~/lib/arcadia/memberships"
|
||||||
|
import { listUsers, type User } from "~/lib/arcadia/users"
|
||||||
|
import { listRoles, type Role } from "~/lib/arcadia/roles"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { useSession } from "~/lib/session"
|
||||||
|
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Memberships")
|
||||||
|
|
||||||
|
type Editor =
|
||||||
|
| { kind: "create" }
|
||||||
|
| { 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)
|
||||||
|
// 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)
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<Membership | null>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
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[]),
|
||||||
|
listRoles(arcadia).catch(() => [] as Role[]),
|
||||||
|
])
|
||||||
|
setMemberships(m)
|
||||||
|
setUsers(u)
|
||||||
|
setRoles(r)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [arcadia])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) refresh()
|
||||||
|
}, [session, refresh])
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() =>
|
||||||
|
statusFilter === "all"
|
||||||
|
? memberships
|
||||||
|
: memberships.filter((m) => m.status === statusFilter),
|
||||||
|
[memberships, statusFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<Column<Membership>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
header: "User",
|
||||||
|
accessor: (m) => m.user?.email ?? m.user_id,
|
||||||
|
sortable: true,
|
||||||
|
cell: (m) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{m.user?.email ?? "—"}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{m.user?.first_name || m.user?.last_name
|
||||||
|
? `${m.user?.first_name ?? ""} ${m.user?.last_name ?? ""}`.trim()
|
||||||
|
: m.user_id.slice(0, 8) + "…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant",
|
||||||
|
header: "Tenant",
|
||||||
|
accessor: (m) => m.tenant?.name ?? "",
|
||||||
|
sortable: true,
|
||||||
|
cell: (m) =>
|
||||||
|
m.tenant ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{m.tenant.name}</span>
|
||||||
|
<code className="rounded bg-muted px-1 font-mono text-[10px] text-muted-foreground">
|
||||||
|
{m.tenant.slug}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
accessor: "status",
|
||||||
|
sortable: true,
|
||||||
|
cell: (m) => <BadgeCell label={m.status} tone={statusTone(m.status)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "primary",
|
||||||
|
header: "Primary",
|
||||||
|
accessor: "is_primary",
|
||||||
|
sortable: true,
|
||||||
|
cell: (m) =>
|
||||||
|
m.is_primary ? (
|
||||||
|
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "roles",
|
||||||
|
header: "Roles",
|
||||||
|
cell: (m) =>
|
||||||
|
m.roles.length === 0 ? (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{m.roles.map((r) => (
|
||||||
|
<Badge key={r.id} variant="secondary" className="font-mono text-xs">
|
||||||
|
{r.slug}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "joined",
|
||||||
|
header: "Joined",
|
||||||
|
accessor: "joined_at",
|
||||||
|
sortable: true,
|
||||||
|
cell: (m) =>
|
||||||
|
m.joined_at ? (
|
||||||
|
<DateCell value={m.joined_at} format="short" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
align: "right",
|
||||||
|
cell: (m) => {
|
||||||
|
const who = memberLabel(m)
|
||||||
|
const items: ActionItem[] = [
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
dataAction: `membership-${m.id}-edit`,
|
||||||
|
onSelect: () => setEditor({ kind: "edit", membership: m }),
|
||||||
|
},
|
||||||
|
m.status === "active"
|
||||||
|
? {
|
||||||
|
id: "suspend",
|
||||||
|
label: "Suspend",
|
||||||
|
icon: <Pause className="size-4" />,
|
||||||
|
dataAction: `membership-${m.id}-suspend`,
|
||||||
|
onSelect: async () => {
|
||||||
|
try {
|
||||||
|
await suspendMembership(arcadia, m.id)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`Suspended ${who}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err, `suspend ${who}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: "activate",
|
||||||
|
label: "Activate",
|
||||||
|
icon: <Play className="size-4" />,
|
||||||
|
dataAction: `membership-${m.id}-activate`,
|
||||||
|
onSelect: async () => {
|
||||||
|
try {
|
||||||
|
await activateMembership(arcadia, m.id)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`Activated ${who}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err, `activate ${who}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: "Remove",
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
destructive: true,
|
||||||
|
dataAction: `membership-${m.id}-delete`,
|
||||||
|
onSelect: () => setPendingDelete(m),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<ActionsCell items={items} triggerDataAction={`membership-${m.id}-actions`} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[arcadia, refresh, toast],
|
||||||
|
)
|
||||||
|
|
||||||
|
const summary = useMemo(
|
||||||
|
() => ({
|
||||||
|
total: memberships.length,
|
||||||
|
byStatus: countBy(memberships, (m) => m.status),
|
||||||
|
uniqueTenants: new Set(memberships.map((m) => m.tenant_id)).size,
|
||||||
|
uniqueUsers: new Set(memberships.map((m) => m.user_id)).size,
|
||||||
|
}),
|
||||||
|
[memberships],
|
||||||
|
)
|
||||||
|
useRegisterContext("memberships", summary)
|
||||||
|
|
||||||
|
const table = useTable<Membership>({
|
||||||
|
data: filtered,
|
||||||
|
columns,
|
||||||
|
getRowId: (m) => m.id,
|
||||||
|
initialPageSize: 25,
|
||||||
|
initialSearch: search,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
table.setSearch(search)
|
||||||
|
}, [search, table])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Memberships</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Who belongs to which tenant. A user can have memberships in multiple tenants;
|
||||||
|
one is marked primary.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
data-action="memberships-refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditor({ kind: "create" })}
|
||||||
|
data-action="memberships-create"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-3">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
placeholder="Search by user, tenant, or role"
|
||||||
|
data-action="memberships-search"
|
||||||
|
className="max-w-sm flex-1"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) => setStatusFilter(v as typeof statusFilter)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40" data-action="memberships-status-filter">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="suspended">Suspended</SelectItem>
|
||||||
|
<SelectItem value="deactivated">Deactivated</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{table.total} of {memberships.length}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="relative p-0">
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={table.page}
|
||||||
|
pageSize={table.pageSize}
|
||||||
|
total={table.total}
|
||||||
|
onPageChange={table.setPage}
|
||||||
|
onPageSizeChange={table.setPageSize}
|
||||||
|
/>
|
||||||
|
</DataState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDelete !== null}
|
||||||
|
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||||
|
title="Remove membership?"
|
||||||
|
description={
|
||||||
|
pendingDelete
|
||||||
|
? `${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)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`Removed ${who} from ${where}`)
|
||||||
|
} catch (err) {
|
||||||
|
setPendingDelete(null)
|
||||||
|
toast.error(errorMessage(err, `remove ${who} from ${where}`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MembershipEditorDialog
|
||||||
|
state={editor}
|
||||||
|
users={users}
|
||||||
|
roles={roles}
|
||||||
|
existingUserIds={new Set(memberships.map((m) => m.user_id))}
|
||||||
|
onClose={() => setEditor(null)}
|
||||||
|
onSaved={async (message) => {
|
||||||
|
setEditor(null)
|
||||||
|
await refresh()
|
||||||
|
toast.success(message)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(s: MembershipStatus): BadgeTone {
|
||||||
|
if (s === "active") return "success"
|
||||||
|
if (s === "suspended") return "warning"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
function MembershipEditorDialog({
|
||||||
|
state,
|
||||||
|
users,
|
||||||
|
roles,
|
||||||
|
existingUserIds,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
state: Editor
|
||||||
|
users: User[]
|
||||||
|
roles: Role[]
|
||||||
|
existingUserIds: Set<string>
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (message: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const open = state !== null
|
||||||
|
const isEdit = state?.kind === "edit"
|
||||||
|
const initial = isEdit ? state.membership : null
|
||||||
|
|
||||||
|
const [userId, setUserId] = useState("")
|
||||||
|
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)
|
||||||
|
setSelectedRoles(new Set(initial.roles.map((r) => r.id)))
|
||||||
|
} else {
|
||||||
|
setUserId("")
|
||||||
|
setStatus("active")
|
||||||
|
setSelectedRoles(new Set())
|
||||||
|
}
|
||||||
|
}, [open, initial])
|
||||||
|
|
||||||
|
const eligibleUsers = useMemo(
|
||||||
|
() => (isEdit ? users : users.filter((u) => !existingUserIds.has(u.id))),
|
||||||
|
[users, existingUserIds, isEdit],
|
||||||
|
)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const input = {
|
||||||
|
user_id: userId,
|
||||||
|
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(`Saved ${who}'s membership`)
|
||||||
|
} else {
|
||||||
|
await createMembership(arcadia, input)
|
||||||
|
await onSaved(`Added ${who}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Form state survives so the operator can fix and resubmit.
|
||||||
|
setError(err)
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "Edit membership" : "Add member"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? "Update status and role assignments."
|
||||||
|
: "Pick a user and assign roles within the current tenant."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>User</Label>
|
||||||
|
<Select value={userId} onValueChange={setUserId} disabled={isEdit}>
|
||||||
|
<SelectTrigger data-action="membership-form-user">
|
||||||
|
<SelectValue placeholder="Pick a user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{eligibleUsers.length === 0 ? (
|
||||||
|
<SelectItem value="__none" disabled>
|
||||||
|
No eligible users
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
eligibleUsers.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.email}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setStatus(v as MembershipStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger data-action="membership-form-status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="suspended">Suspended</SelectItem>
|
||||||
|
<SelectItem value="deactivated">Deactivated</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Roles</Label>
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No roles defined. Create some on the Users tab.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1.5 rounded-md border p-2">
|
||||||
|
{roles.map((r) => {
|
||||||
|
const active = selectedRoles.has(r.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRoles((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(r.id)) next.delete(r.id)
|
||||||
|
else next.add(r.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
data-action={`membership-form-role-${r.slug}`}
|
||||||
|
className={[
|
||||||
|
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border text-muted-foreground hover:bg-accent",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<DialogError
|
||||||
|
error={error}
|
||||||
|
context={isEdit ? "save the membership" : "add the member"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
data-action="membership-form-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={saving || !userId}
|
||||||
|
data-action="membership-form-save"
|
||||||
|
>
|
||||||
|
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
{isEdit ? "Save" : "Add"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
|
||||||
|
return arr.reduce<Record<string, number>>((acc, x) => {
|
||||||
|
const k = key(x)
|
||||||
|
acc[k] = (acc[k] ?? 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
1230
app/routes/monitoring.tsx
Normal file
1230
app/routes/monitoring.tsx
Normal file
File diff suppressed because it is too large
Load Diff
876
app/routes/networking.tsx
Normal file
876
app/routes/networking.tsx
Normal file
@@ -0,0 +1,876 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Globe,
|
||||||
|
Network,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
Wifi,
|
||||||
|
} 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 { 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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
assignFloatingIp,
|
||||||
|
createDnsRecord,
|
||||||
|
deleteDnsRecord,
|
||||||
|
deleteFirewall,
|
||||||
|
listDnsRecords,
|
||||||
|
listDomains,
|
||||||
|
listFirewalls,
|
||||||
|
listFloatingIps,
|
||||||
|
listVpcs,
|
||||||
|
unassignFloatingIp,
|
||||||
|
type DnsRecord,
|
||||||
|
type Domain,
|
||||||
|
type Firewall,
|
||||||
|
type FloatingIp,
|
||||||
|
type Vpc,
|
||||||
|
} from "~/lib/arcadia/networking"
|
||||||
|
import { listDroplets, type Droplet } from "~/lib/arcadia/monitoring"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { useSession } from "~/lib/session"
|
||||||
|
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Networking")
|
||||||
|
|
||||||
|
const DNS_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA"]
|
||||||
|
|
||||||
|
export default function NetworkingRoute() {
|
||||||
|
const session = useSession()
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
|
||||||
|
const [firewalls, setFirewalls] = useState<Firewall[]>([])
|
||||||
|
const [vpcs, setVpcs] = useState<Vpc[]>([])
|
||||||
|
const [domains, setDomains] = useState<Domain[]>([])
|
||||||
|
const [floatingIps, setFloatingIps] = useState<FloatingIp[]>([])
|
||||||
|
const [droplets, setDroplets] = useState<Droplet[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
// 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 () => {
|
||||||
|
setLoading(true)
|
||||||
|
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(() => {
|
||||||
|
if (session) refresh()
|
||||||
|
}, [session, refresh])
|
||||||
|
|
||||||
|
useRegisterContext("networking", {
|
||||||
|
firewalls: firewalls.length,
|
||||||
|
vpcs: vpcs.length,
|
||||||
|
domains: domains.length,
|
||||||
|
floating_ips: floatingIps.length,
|
||||||
|
droplets: droplets.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Networking</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Firewalls, VPCs, DNS, and floating IPs on the platform's underlying provider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
data-action="networking-refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Tabs defaultValue="firewalls">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="firewalls" data-action="networking-tab-firewalls">
|
||||||
|
Firewalls ({firewalls.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="vpcs" data-action="networking-tab-vpcs">
|
||||||
|
VPCs ({vpcs.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="domains" data-action="networking-tab-domains">
|
||||||
|
DNS ({domains.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="floating-ips" data-action="networking-tab-floating-ips">
|
||||||
|
Floating IPs ({floatingIps.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="firewalls" className="pt-4">
|
||||||
|
<FirewallsPanel
|
||||||
|
firewalls={firewalls}
|
||||||
|
loading={loading}
|
||||||
|
error={firewallsError}
|
||||||
|
onChanged={refresh}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="vpcs" className="pt-4">
|
||||||
|
<VpcsPanel
|
||||||
|
vpcs={vpcs}
|
||||||
|
loading={loading}
|
||||||
|
error={vpcsError}
|
||||||
|
onRetry={refresh}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="domains" className="pt-4">
|
||||||
|
<DomainsPanel
|
||||||
|
domains={domains}
|
||||||
|
loading={loading}
|
||||||
|
error={domainsError}
|
||||||
|
onChanged={refresh}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="floating-ips" className="pt-4">
|
||||||
|
<FloatingIpsPanel
|
||||||
|
ips={floatingIps}
|
||||||
|
droplets={droplets}
|
||||||
|
loading={loading}
|
||||||
|
error={floatingIpsError}
|
||||||
|
onChanged={refresh}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Firewalls panel ---------------------------------------------------
|
||||||
|
|
||||||
|
function FirewallsPanel({
|
||||||
|
firewalls,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
firewalls: Firewall[]
|
||||||
|
loading: boolean
|
||||||
|
error: unknown
|
||||||
|
onChanged: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const toast = useToast()
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<Firewall | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
<Shield className="size-4 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">{f.name}</CardTitle>
|
||||||
|
{f.status ? <Badge variant="secondary">{f.status}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPendingDelete(f)}
|
||||||
|
data-action={`firewall-${f.id}-delete`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
|
Inbound rules: {f.inbound_rules?.length ?? 0} · Outbound rules:{" "}
|
||||||
|
{f.outbound_rules?.length ?? 0} · Droplets attached:{" "}
|
||||||
|
{f.droplet_ids?.length ?? 0}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DataState>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDelete !== null}
|
||||||
|
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||||
|
title="Delete firewall?"
|
||||||
|
description={
|
||||||
|
pendingDelete
|
||||||
|
? `${pendingDelete.name} will be removed. Attached droplets lose this rule set.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingDelete) return
|
||||||
|
const name = pendingDelete.name
|
||||||
|
try {
|
||||||
|
await deleteFirewall(arcadia, pendingDelete.id)
|
||||||
|
setPendingDelete(null)
|
||||||
|
await onChanged()
|
||||||
|
toast.success(`Deleted firewall ${name}`)
|
||||||
|
} catch (err) {
|
||||||
|
setPendingDelete(null)
|
||||||
|
toast.error(errorMessage(err, `delete firewall ${name}`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VPCs panel --------------------------------------------------------
|
||||||
|
|
||||||
|
function VpcsPanel({
|
||||||
|
vpcs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
vpcs: Vpc[]
|
||||||
|
loading: boolean
|
||||||
|
error: unknown
|
||||||
|
onRetry: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DataState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
isEmpty={vpcs.length === 0}
|
||||||
|
onRetry={onRetry}
|
||||||
|
loadingLabel="Loading VPCs…"
|
||||||
|
empty={
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<EmptyState
|
||||||
|
icon={<Network className="size-6" />}
|
||||||
|
title="No VPCs."
|
||||||
|
description="Read-only view; create VPCs on your provider directly."
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Domains + DNS records panel ---------------------------------------
|
||||||
|
|
||||||
|
function DomainsPanel({
|
||||||
|
domains,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
domains: Domain[]
|
||||||
|
loading: boolean
|
||||||
|
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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedName && domains.length > 0) setSelectedName(domains[0].name)
|
||||||
|
}, [domains, selectedName])
|
||||||
|
|
||||||
|
const loadRecords = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
if (!name) {
|
||||||
|
setRecords([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRecordsError(null)
|
||||||
|
setLoadingRecords(true)
|
||||||
|
try {
|
||||||
|
setRecords(await listDnsRecords(arcadia, name))
|
||||||
|
} catch (err) {
|
||||||
|
setRecords([])
|
||||||
|
setRecordsError(err)
|
||||||
|
} finally {
|
||||||
|
setLoadingRecords(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[arcadia],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecords(selectedName)
|
||||||
|
}, [selectedName, loadRecords])
|
||||||
|
|
||||||
|
if (loading || error || domains.length === 0) {
|
||||||
|
return (
|
||||||
|
<DataState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
isEmpty={domains.length === 0}
|
||||||
|
onRetry={onChanged}
|
||||||
|
loadingLabel="Loading domains…"
|
||||||
|
empty={
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<EmptyState
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dns-domain" className="text-xs">
|
||||||
|
Domain
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedName} onValueChange={setSelectedName}>
|
||||||
|
<SelectTrigger id="dns-domain" className="w-64" data-action="dns-domain-select">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map((d) => (
|
||||||
|
<SelectItem key={d.name} value={d.name}>
|
||||||
|
{d.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadRecords(selectedName)}
|
||||||
|
disabled={loadingRecords}
|
||||||
|
data-action="dns-refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${loadingRecords ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
disabled={!selectedName}
|
||||||
|
data-action="dns-create"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New record
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
{r.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono text-xs">{r.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
{r.data}
|
||||||
|
</code>
|
||||||
|
{r.ttl ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground">TTL {r.ttl}s</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPendingDelete(r)}
|
||||||
|
data-action={`dns-record-${r.id}-delete`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DataState>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<DnsCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
domainName={selectedName}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreated={async (label) => {
|
||||||
|
setCreateOpen(false)
|
||||||
|
await loadRecords(selectedName)
|
||||||
|
await onChanged()
|
||||||
|
toast.success(`Created ${label}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDelete !== null}
|
||||||
|
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||||
|
title="Delete DNS record?"
|
||||||
|
description={
|
||||||
|
pendingDelete
|
||||||
|
? `${pendingDelete.type} ${pendingDelete.name} → ${pendingDelete.data}. This is destructive and may break traffic.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingDelete) return
|
||||||
|
const label = `${pendingDelete.type} ${pendingDelete.name}`
|
||||||
|
try {
|
||||||
|
await deleteDnsRecord(arcadia, selectedName, pendingDelete.id)
|
||||||
|
setPendingDelete(null)
|
||||||
|
await loadRecords(selectedName)
|
||||||
|
toast.success(`Deleted ${label}`)
|
||||||
|
} catch (err) {
|
||||||
|
setPendingDelete(null)
|
||||||
|
toast.error(errorMessage(err, `delete ${label}`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DnsCreateDialog({
|
||||||
|
open,
|
||||||
|
domainName,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
domainName: string
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: (label: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const [type, setType] = useState("A")
|
||||||
|
const [name, setName] = useState("@")
|
||||||
|
const [data, setData] = useState("")
|
||||||
|
const [ttl, setTtl] = useState("3600")
|
||||||
|
const [priority, setPriority] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setType("A")
|
||||||
|
setName("@")
|
||||||
|
setData("")
|
||||||
|
setTtl("3600")
|
||||||
|
setPriority("")
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await createDnsRecord(arcadia, domainName, {
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
ttl: ttl ? Number(ttl) : undefined,
|
||||||
|
priority: priority ? Number(priority) : undefined,
|
||||||
|
})
|
||||||
|
await onCreated(`${type} ${name} → ${data}`)
|
||||||
|
} catch (err) {
|
||||||
|
// A rejected record (bad target, duplicate name) is fixable right here.
|
||||||
|
setError(err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New DNS record</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
On <code className="font-mono">{domainName}</code>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger data-action="dns-form-type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DNS_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dns-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="dns-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="@ or sub"
|
||||||
|
data-action="dns-form-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dns-data">Data</Label>
|
||||||
|
<Input
|
||||||
|
id="dns-data"
|
||||||
|
value={data}
|
||||||
|
onChange={(e) => setData(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
type === "A"
|
||||||
|
? "1.2.3.4"
|
||||||
|
: type === "CNAME"
|
||||||
|
? "target.example.com."
|
||||||
|
: type === "TXT"
|
||||||
|
? '"verification=..."'
|
||||||
|
: "value"
|
||||||
|
}
|
||||||
|
className="font-mono"
|
||||||
|
data-action="dns-form-data"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dns-ttl">TTL (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
id="dns-ttl"
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
value={ttl}
|
||||||
|
onChange={(e) => setTtl(e.target.value)}
|
||||||
|
data-action="dns-form-ttl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{type === "MX" || type === "SRV" ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dns-priority">Priority</Label>
|
||||||
|
<Input
|
||||||
|
id="dns-priority"
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
data-action="dns-form-priority"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <DialogError error={error} context="create the record" /> : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<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">
|
||||||
|
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Floating IPs panel ------------------------------------------------
|
||||||
|
|
||||||
|
function FloatingIpsPanel({
|
||||||
|
ips,
|
||||||
|
droplets,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
ips: FloatingIp[]
|
||||||
|
droplets: Droplet[]
|
||||||
|
loading: boolean
|
||||||
|
error: unknown
|
||||||
|
onChanged: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const toast = useToast()
|
||||||
|
const [assigning, setAssigning] = useState<{ ip: string; dropletId: string } | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<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 =
|
||||||
|
typeof ip.region === "string" ? ip.region : ip.region?.slug ?? "—"
|
||||||
|
return (
|
||||||
|
<li key={ip.ip} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Wifi className="size-4 text-muted-foreground" />
|
||||||
|
<code className="font-mono text-sm">{ip.ip}</code>
|
||||||
|
<span className="text-xs text-muted-foreground">{region}</span>
|
||||||
|
{ip.droplet ? (
|
||||||
|
<Badge variant="secondary">→ {ip.droplet.name ?? ip.droplet.id}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge>unassigned</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{ip.droplet ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await unassignFloatingIp(arcadia, ip.ip)
|
||||||
|
await onChanged()
|
||||||
|
toast.success(`Unassigned ${ip.ip}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err, `unassign ${ip.ip}`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-action={`fip-${ip.ip}-unassign`}
|
||||||
|
>
|
||||||
|
Unassign
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
value={assigning?.ip === ip.ip ? assigning.dropletId : ""}
|
||||||
|
onValueChange={(v) => setAssigning({ ip: ip.ip, dropletId: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-8 w-44"
|
||||||
|
data-action={`fip-${ip.ip}-droplet-select`}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Pick droplet" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{droplets.length === 0 ? (
|
||||||
|
<SelectItem value="__none" disabled>
|
||||||
|
No droplets
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
droplets.map((d) => (
|
||||||
|
<SelectItem key={String(d.id)} value={String(d.id)}>
|
||||||
|
{d.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
!assigning || assigning.ip !== ip.ip || !assigning.dropletId
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
await onChanged()
|
||||||
|
toast.success(`Assigned ${ip.ip} to ${dropletName}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
errorMessage(err, `assign ${ip.ip} to ${dropletName}`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-action={`fip-${ip.ip}-assign`}
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</DataState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
944
app/routes/organizations.tsx
Normal file
944
app/routes/organizations.tsx
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
Building,
|
||||||
|
Crown,
|
||||||
|
Mail,
|
||||||
|
RefreshCw,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Trash2,
|
||||||
|
UserCog,
|
||||||
|
UserPlus,
|
||||||
|
Users as UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import { useToast } from "@crema/notification-ui"
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
BadgeCell,
|
||||||
|
DataTable,
|
||||||
|
DateCell,
|
||||||
|
Pagination,
|
||||||
|
useTable,
|
||||||
|
type ActionItem,
|
||||||
|
type BadgeTone,
|
||||||
|
type Column,
|
||||||
|
} from "@crema/table-ui"
|
||||||
|
import { SearchInput } from "@crema/search-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, 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import {
|
||||||
|
addRestrictedMember,
|
||||||
|
changeMemberRole,
|
||||||
|
inviteMember,
|
||||||
|
listAllOrganizations,
|
||||||
|
listMembers,
|
||||||
|
removeMember,
|
||||||
|
transferOwnership,
|
||||||
|
updateOrganization,
|
||||||
|
type OnOwnerRemoval,
|
||||||
|
type OrgMembership,
|
||||||
|
type OrgRole,
|
||||||
|
type OrgStatus,
|
||||||
|
type Organization,
|
||||||
|
} from "~/lib/arcadia/organizations"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { useSession } from "~/lib/session"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Organizations")
|
||||||
|
|
||||||
|
type MembersDialogState = { org: Organization } | null
|
||||||
|
type SettingsDialogState = { org: Organization } | null
|
||||||
|
|
||||||
|
const ON_OWNER_REMOVAL_LABEL: Record<OnOwnerRemoval, string> = {
|
||||||
|
delete: "Delete org",
|
||||||
|
require_transfer: "Require transfer",
|
||||||
|
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)
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
const [membersDialog, setMembersDialog] = useState<MembersDialogState>(null)
|
||||||
|
const [settingsDialog, setSettingsDialog] = useState<SettingsDialogState>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setOrgs(await listAllOrganizations(arcadia))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [arcadia])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) refresh()
|
||||||
|
}, [session, refresh])
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => (statusFilter === "all" ? orgs : orgs.filter((o) => o.status === statusFilter)),
|
||||||
|
[orgs, statusFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useMemo<Column<Organization>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "Organization",
|
||||||
|
accessor: "name",
|
||||||
|
sortable: true,
|
||||||
|
cell: (o) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{o.name}</span>
|
||||||
|
<code className="rounded bg-muted px-1 font-mono text-[10px] text-muted-foreground">
|
||||||
|
{o.slug}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
accessor: "status",
|
||||||
|
sortable: true,
|
||||||
|
cell: (o) => <BadgeCell label={o.status} tone={statusTone(o.status)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "on_owner_removal",
|
||||||
|
header: "Owner-removal policy",
|
||||||
|
accessor: "on_owner_removal",
|
||||||
|
sortable: true,
|
||||||
|
cell: (o) => (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{ON_OWNER_REMOVAL_LABEL[o.on_owner_removal] ?? o.on_owner_removal}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated_at",
|
||||||
|
header: "Updated",
|
||||||
|
accessor: "updated_at",
|
||||||
|
sortable: true,
|
||||||
|
cell: (o) => <DateCell value={o.updated_at} format="short" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
align: "right",
|
||||||
|
cell: (o) => {
|
||||||
|
const items: ActionItem[] = [
|
||||||
|
{
|
||||||
|
id: "members",
|
||||||
|
label: "Manage members",
|
||||||
|
icon: <UsersIcon className="size-4" />,
|
||||||
|
dataAction: `org-${o.id}-members`,
|
||||||
|
onSelect: () => setMembersDialog({ org: o }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings",
|
||||||
|
label: "Settings",
|
||||||
|
icon: <SettingsIcon className="size-4" />,
|
||||||
|
dataAction: `org-${o.id}-settings`,
|
||||||
|
onSelect: () => setSettingsDialog({ org: o }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return <ActionsCell items={items} triggerDataAction={`org-${o.id}-actions`} />
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useTable<Organization>({
|
||||||
|
data: filtered,
|
||||||
|
columns,
|
||||||
|
getRowId: (o) => o.id,
|
||||||
|
initialPageSize: 25,
|
||||||
|
initialSearch: search,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
table.setSearch(search)
|
||||||
|
}, [search, table])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Organizations</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
End-user workspaces inside this tenant. Each one is owned by a regular user; admins
|
||||||
|
here can manage members, change ownership policy, or freeze a workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
data-action="organizations-refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-3">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
placeholder="Search by name or slug"
|
||||||
|
data-action="organizations-search"
|
||||||
|
className="max-w-sm flex-1"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) => setStatusFilter(v as typeof statusFilter)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44" data-action="organizations-status-filter">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="frozen">Frozen</SelectItem>
|
||||||
|
<SelectItem value="pending_deletion">Pending deletion</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{table.total} of {orgs.length}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="relative p-0">
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
<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)} />
|
||||||
|
<SettingsDialog
|
||||||
|
state={settingsDialog}
|
||||||
|
onClose={() => setSettingsDialog(null)}
|
||||||
|
onSaved={async (message) => {
|
||||||
|
setSettingsDialog(null)
|
||||||
|
await refresh()
|
||||||
|
toast.success(message)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(s: OrgStatus): BadgeTone {
|
||||||
|
if (s === "active") return "success"
|
||||||
|
if (s === "frozen") return "warning"
|
||||||
|
if (s === "pending_deletion") return "danger"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleBadgeVariant(r: OrgRole): "default" | "secondary" | "destructive" | "outline" {
|
||||||
|
if (r === "owner") return "default"
|
||||||
|
if (r === "admin") return "secondary"
|
||||||
|
return "outline"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Members dialog
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type InvitePane = "none" | "invite_existing" | "add_restricted"
|
||||||
|
|
||||||
|
function MembersDialog({
|
||||||
|
state,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
state: MembersDialogState
|
||||||
|
onClose: () => 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) {
|
||||||
|
setError(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [arcadia, org])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPane("none")
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}, [open, refresh])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{org ? `Members — ${org.name}` : "Members"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{org
|
||||||
|
? `Manage who can act inside ${org.name}. The owner can be changed via transfer; one active owner at a time.`
|
||||||
|
: ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{pane === "none" ? (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPane("invite_existing")}
|
||||||
|
data-action={`org-${org?.id}-invite-existing`}
|
||||||
|
>
|
||||||
|
<Mail className="size-4" />
|
||||||
|
Invite by email
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPane("add_restricted")}
|
||||||
|
data-action={`org-${org?.id}-add-restricted`}
|
||||||
|
>
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
Add restricted user
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : pane === "invite_existing" ? (
|
||||||
|
<InviteByEmailForm
|
||||||
|
orgId={org!.id}
|
||||||
|
onCancel={() => setPane("none")}
|
||||||
|
onSaved={async (message) => {
|
||||||
|
setPane("none")
|
||||||
|
await refresh()
|
||||||
|
toast.success(message)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AddRestrictedForm
|
||||||
|
orgId={org!.id}
|
||||||
|
onCancel={() => setPane("none")}
|
||||||
|
onSaved={async (message) => {
|
||||||
|
setPane("none")
|
||||||
|
await refresh()
|
||||||
|
toast.success(message)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-medium">User</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Role</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Status</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Joined</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((m) => (
|
||||||
|
<tr key={m.id} className="border-t border-border">
|
||||||
|
<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>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant="secondary">{m.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
{m.joined_at ? new Date(m.joined_at).toLocaleDateString() : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<MemberRowActions
|
||||||
|
member={m}
|
||||||
|
orgId={org!.id}
|
||||||
|
onTransfer={() => setTransferTarget(m)}
|
||||||
|
onRemove={() => setPendingRemove(m)}
|
||||||
|
onRoleChanged={refresh}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</DataState>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} data-action={`org-${org?.id}-members-close`}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingRemove !== null}
|
||||||
|
onOpenChange={(o) => !o && setPendingRemove(null)}
|
||||||
|
title="Remove member?"
|
||||||
|
description={
|
||||||
|
pendingRemove
|
||||||
|
? pendingRemove.role === "owner"
|
||||||
|
? `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)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`Removed ${who} from ${org.name}`)
|
||||||
|
} catch (err) {
|
||||||
|
setPendingRemove(null)
|
||||||
|
toast.error(errorMessage(err, `remove ${who} from ${org.name}`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={transferTarget !== null}
|
||||||
|
onOpenChange={(o) => !o && setTransferTarget(null)}
|
||||||
|
title="Transfer ownership?"
|
||||||
|
description={
|
||||||
|
transferTarget
|
||||||
|
? `${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)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`${org.name} is now owned by ${who}`)
|
||||||
|
} catch (err) {
|
||||||
|
setTransferTarget(null)
|
||||||
|
toast.error(errorMessage(err, `transfer ${org.name} to ${who}`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberRowActions({
|
||||||
|
member,
|
||||||
|
orgId,
|
||||||
|
onTransfer,
|
||||||
|
onRemove,
|
||||||
|
onRoleChanged,
|
||||||
|
}: {
|
||||||
|
member: OrgMembership
|
||||||
|
orgId: string
|
||||||
|
onTransfer: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
onRoleChanged: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const items: ActionItem[] = []
|
||||||
|
|
||||||
|
if (member.role !== "owner") {
|
||||||
|
items.push({
|
||||||
|
id: "promote-admin",
|
||||||
|
label: member.role === "admin" ? "Demote to member" : "Promote to admin",
|
||||||
|
icon: <UserCog className="size-4" />,
|
||||||
|
dataAction: `org-${orgId}-member-${member.id}-role`,
|
||||||
|
onSelect: async () => {
|
||||||
|
const next = member.role === "admin" ? "member" : "admin"
|
||||||
|
try {
|
||||||
|
await changeMemberRole(arcadia, orgId, member.user_id, next)
|
||||||
|
await onRoleChanged()
|
||||||
|
toast.success(`${memberLabel(member)} is now ${next === "admin" ? "an admin" : "a member"}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err, `change ${memberLabel(member)} to ${next}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
id: "transfer",
|
||||||
|
label: "Transfer ownership to this user",
|
||||||
|
icon: <Crown className="size-4" />,
|
||||||
|
dataAction: `org-${orgId}-member-${member.id}-transfer`,
|
||||||
|
onSelect: onTransfer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: "remove",
|
||||||
|
label: "Remove",
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
destructive: true,
|
||||||
|
dataAction: `org-${orgId}-member-${member.id}-remove`,
|
||||||
|
onSelect: onRemove,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionsCell items={items} triggerDataAction={`org-${orgId}-member-${member.id}-actions`} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invite-by-email and add-restricted forms
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function InviteByEmailForm({
|
||||||
|
orgId,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
orgId: string
|
||||||
|
onCancel: () => 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">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[1fr_140px_auto_auto]">
|
||||||
|
<Input
|
||||||
|
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 data-action={`org-${orgId}-invite-role`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
data-action={`org-${orgId}-invite-cancel`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!email || saving}
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddRestrictedForm({
|
||||||
|
orgId,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
orgId: string
|
||||||
|
onCancel: () => void
|
||||||
|
onSaved: (message: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [firstName, setFirstName] = useState("")
|
||||||
|
const [lastName, setLastName] = useState("")
|
||||||
|
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)}
|
||||||
|
data-action={`org-${orgId}-restricted-email`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor="r-password">Initial password</Label>
|
||||||
|
<Input
|
||||||
|
id="r-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
data-action={`org-${orgId}-restricted-password`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor="r-first">First name</Label>
|
||||||
|
<Input
|
||||||
|
id="r-first"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
data-action={`org-${orgId}-restricted-first-name`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor="r-last">Last name</Label>
|
||||||
|
<Input
|
||||||
|
id="r-last"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
data-action={`org-${orgId}-restricted-last-name`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor="r-role">Role</Label>
|
||||||
|
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
|
||||||
|
<SelectTrigger id="r-role" data-action={`org-${orgId}-restricted-role`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<DialogError error={error} context="add the restricted user" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
data-action={`org-${orgId}-restricted-cancel`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!email || !password || !firstName || !lastName || saving}
|
||||||
|
onClick={submit}
|
||||||
|
data-action={`org-${orgId}-restricted-submit`}
|
||||||
|
>
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Restricted users exist only inside this org — they can never act in personal mode and have
|
||||||
|
no plan of their own.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Settings dialog
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function SettingsDialog({
|
||||||
|
state,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
state: SettingsDialogState
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (message: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const open = state !== null
|
||||||
|
const org = state?.org
|
||||||
|
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
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>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{org ? `Settings — ${org.name}` : "Settings"}</DialogTitle>
|
||||||
|
<DialogDescription>Change name, status, or owner-removal policy.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<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)}
|
||||||
|
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" data-action="org-settings-status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="frozen">Frozen</SelectItem>
|
||||||
|
<SelectItem value="pending_deletion">Pending deletion</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor="o-policy">Owner-removal policy</Label>
|
||||||
|
<Select
|
||||||
|
value={onOwnerRemoval}
|
||||||
|
onValueChange={(v) => setOnOwnerRemoval(v as OnOwnerRemoval)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="o-policy" data-action="org-settings-policy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="require_transfer">Require transfer (safest)</SelectItem>
|
||||||
|
<SelectItem value="freeze_until_new_owner">Freeze until new owner</SelectItem>
|
||||||
|
<SelectItem value="delete">Delete on owner removal</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Decides what happens when the owner's membership is removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <DialogError error={error} context="save the organization" /> : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
data-action="org-settings-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<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,8 +1,13 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { Check, Trash2 } from "lucide-react"
|
import { Check, RefreshCw, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import { AlertBanner } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
import { CopyId } from "~/components/copy-id"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -11,253 +16,443 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card"
|
} from "~/components/ui/card"
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "~/components/ui/dropdown-menu"
|
|
||||||
import { Input } from "~/components/ui/input"
|
import { Input } from "~/components/ui/input"
|
||||||
import { Textarea } from "~/components/ui/textarea"
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
import { useAgents } from "~/lib/agents"
|
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
import {
|
import {
|
||||||
DEFAULT_PROFILE,
|
|
||||||
profileInitials,
|
profileInitials,
|
||||||
resetProfile,
|
|
||||||
saveProfile,
|
saveProfile,
|
||||||
useProfile,
|
useProfile,
|
||||||
type Profile,
|
type Profile,
|
||||||
} from "~/lib/profile"
|
} from "~/lib/profile"
|
||||||
|
import { getUser, updateUser, type User } from "~/lib/arcadia/users"
|
||||||
|
import {
|
||||||
|
fetchDigitalObjectAsBlobUrl,
|
||||||
|
uploadFile,
|
||||||
|
} from "~/lib/arcadia/digital-objects"
|
||||||
|
import {
|
||||||
|
getProfile,
|
||||||
|
updateProfile as updateArcadiaProfile,
|
||||||
|
pickAvatarUrl,
|
||||||
|
type Profile as ArcadiaProfile,
|
||||||
|
} from "~/lib/arcadia/profiles"
|
||||||
|
import { updateSessionUser, useSession } from "~/lib/session"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Profile")
|
export const meta = () => pageTitle("Profile")
|
||||||
|
|
||||||
export default function ProfileRoute() {
|
interface AccountDraft {
|
||||||
const profile = useProfile()
|
first_name: string
|
||||||
const agents = useAgents()
|
last_name: string
|
||||||
const [draft, setDraft] = useState<Profile>(profile)
|
email: string
|
||||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
}
|
||||||
|
|
||||||
|
export default function ProfileRoute() {
|
||||||
|
const session = useSession()
|
||||||
|
const arcadia = useArcadiaClient()
|
||||||
|
const profile = useProfile()
|
||||||
|
|
||||||
|
// Mirror of the resolved avatar URL — kept in localStorage so the
|
||||||
|
// <AvatarImage> in the appbar can render before the profile fetch
|
||||||
|
// resolves on next mount.
|
||||||
|
const [prefs, setPrefs] = useState<Profile>(profile)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(profile)
|
setPrefs(profile)
|
||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
const dirty = JSON.stringify(draft) !== JSON.stringify(profile)
|
// Arcadia account.
|
||||||
const initials = profileInitials(draft.name || DEFAULT_PROFILE.name)
|
const [account, setAccount] = useState<User | null>(null)
|
||||||
|
const [accountDraft, setAccountDraft] = useState<AccountDraft>({
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
})
|
||||||
|
const [accountLoading, setAccountLoading] = useState(true)
|
||||||
|
const [accountSaving, setAccountSaving] = useState(false)
|
||||||
|
const [accountSavedAt, setAccountSavedAt] = useState<number | null>(null)
|
||||||
|
const [accountError, setAccountError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Server-side profile (avatar lives here — `prefs.avatarUrl` mirrors
|
||||||
|
// the resolved URL so the existing <AvatarImage> bindings keep working).
|
||||||
|
const [arcadiaProfile, setArcadiaProfile] = useState<ArcadiaProfile | null>(null)
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||||
|
const [avatarError, setAvatarError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Public-profile editable fields, server-backed via PATCH /api/v1/profile.
|
||||||
|
const [profileDraft, setProfileDraft] = useState<{
|
||||||
|
bio: string
|
||||||
|
phone: string
|
||||||
|
location: string
|
||||||
|
timezone: string
|
||||||
|
}>({ bio: "", phone: "", location: "", timezone: "" })
|
||||||
|
const [profileSaving, setProfileSaving] = useState(false)
|
||||||
|
const [profileSavedAt, setProfileSavedAt] = useState<number | null>(null)
|
||||||
|
const [profileError, setProfileError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const profileDirty =
|
||||||
|
!!arcadiaProfile &&
|
||||||
|
(profileDraft.bio !== (arcadiaProfile.bio ?? "") ||
|
||||||
|
profileDraft.phone !== (arcadiaProfile.phone ?? "") ||
|
||||||
|
profileDraft.location !== (arcadiaProfile.location ?? "") ||
|
||||||
|
profileDraft.timezone !== (arcadiaProfile.timezone ?? ""))
|
||||||
|
|
||||||
|
const loadAccount = useCallback(async () => {
|
||||||
|
if (!session) return
|
||||||
|
setAccountLoading(true)
|
||||||
|
setAccountError(null)
|
||||||
|
try {
|
||||||
|
const [u, p] = await Promise.all([
|
||||||
|
getUser(arcadia, session.userId),
|
||||||
|
getProfile(arcadia).catch(() => null),
|
||||||
|
])
|
||||||
|
setAccount(u)
|
||||||
|
setAccountDraft({
|
||||||
|
first_name: u.first_name ?? "",
|
||||||
|
last_name: u.last_name ?? "",
|
||||||
|
email: u.email,
|
||||||
|
})
|
||||||
|
if (p) {
|
||||||
|
setArcadiaProfile(p)
|
||||||
|
setProfileDraft({
|
||||||
|
bio: p.bio ?? "",
|
||||||
|
phone: p.phone ?? "",
|
||||||
|
location: p.location ?? "",
|
||||||
|
timezone: p.timezone ?? "",
|
||||||
|
})
|
||||||
|
const url = pickAvatarUrl(p)
|
||||||
|
if (url) {
|
||||||
|
// Persist into localStorage so the appbar's useProfile() picks
|
||||||
|
// it up on next render — without this, the appbar avatar reverts
|
||||||
|
// to initials on every fresh browser session until the user
|
||||||
|
// re-uploads.
|
||||||
|
setPrefs((d) => {
|
||||||
|
const next = { ...d, avatarUrl: url }
|
||||||
|
saveProfile(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setAccountError(
|
||||||
|
err instanceof ArcadiaError ? err.message : "Failed to load account.",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAccountLoading(false)
|
||||||
|
}
|
||||||
|
}, [arcadia, session])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAccount()
|
||||||
|
}, [loadAccount])
|
||||||
|
|
||||||
|
const accountDirty =
|
||||||
|
!!account &&
|
||||||
|
(accountDraft.first_name !== (account.first_name ?? "") ||
|
||||||
|
accountDraft.last_name !== (account.last_name ?? "") ||
|
||||||
|
accountDraft.email !== account.email)
|
||||||
|
|
||||||
|
const saveAccount = async () => {
|
||||||
|
if (!account) return
|
||||||
|
setAccountSaving(true)
|
||||||
|
setAccountError(null)
|
||||||
|
try {
|
||||||
|
const updated = await updateUser(arcadia, account.id, {
|
||||||
|
first_name: accountDraft.first_name || null,
|
||||||
|
last_name: accountDraft.last_name || null,
|
||||||
|
email: accountDraft.email,
|
||||||
|
})
|
||||||
|
setAccount(updated)
|
||||||
|
updateSessionUser({ name: updated.full_name, email: updated.email })
|
||||||
|
setAccountSavedAt(Date.now())
|
||||||
|
} catch (err) {
|
||||||
|
setAccountError(
|
||||||
|
err instanceof ArcadiaError ? err.message : "Save failed.",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAccountSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveArcadiaProfile = async () => {
|
||||||
|
setProfileSaving(true)
|
||||||
|
setProfileError(null)
|
||||||
|
try {
|
||||||
|
const updated = await updateArcadiaProfile(arcadia, {
|
||||||
|
bio: profileDraft.bio || null,
|
||||||
|
phone: profileDraft.phone || null,
|
||||||
|
location: profileDraft.location || null,
|
||||||
|
timezone: profileDraft.timezone || null,
|
||||||
|
})
|
||||||
|
setArcadiaProfile(updated)
|
||||||
|
setProfileDraft({
|
||||||
|
bio: updated.bio ?? "",
|
||||||
|
phone: updated.phone ?? "",
|
||||||
|
location: updated.location ?? "",
|
||||||
|
timezone: updated.timezone ?? "",
|
||||||
|
})
|
||||||
|
setProfileSavedAt(Date.now())
|
||||||
|
} catch (err) {
|
||||||
|
setProfileError(
|
||||||
|
err instanceof ArcadiaError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Save failed.",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setProfileSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local prefs handlers.
|
||||||
|
const initials = profileInitials(
|
||||||
|
[accountDraft.first_name, accountDraft.last_name].filter(Boolean).join(" ") ||
|
||||||
|
account?.full_name ||
|
||||||
|
session?.name ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPickAvatar = async (file: File | null) => {
|
||||||
|
setAvatarError(null)
|
||||||
|
|
||||||
const onPickAvatar = (file: File | null) => {
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setDraft((d) => ({ ...d, avatarUrl: "" }))
|
// Clear: detach the digital object on the server, then drop the
|
||||||
|
// local cache. Keep the local cache cleared even if the server call
|
||||||
|
// fails so the UI reflects the user's intent.
|
||||||
|
setPrefs((d) => ({ ...d, avatarUrl: "" }))
|
||||||
|
try {
|
||||||
|
const updated = await updateArcadiaProfile(arcadia, {
|
||||||
|
avatar_digital_object_id: null,
|
||||||
|
})
|
||||||
|
setArcadiaProfile(updated)
|
||||||
|
savePrefsLocal({ ...prefs, avatarUrl: "" })
|
||||||
|
} catch (err) {
|
||||||
|
setAvatarError(
|
||||||
|
err instanceof Error ? err.message : "Failed to clear avatar.",
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
if (!file.type.startsWith("image/")) {
|
||||||
const result = reader.result
|
setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).")
|
||||||
if (typeof result === "string")
|
return
|
||||||
setDraft((d) => ({ ...d, avatarUrl: result }))
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
// 8MB hard cap client-side; arcadia will enforce its own quota too.
|
||||||
|
if (file.size > 8 * 1024 * 1024) {
|
||||||
|
setAvatarError("Avatar is too large (max 8MB).")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = () => {
|
setAvatarUploading(true)
|
||||||
saveProfile(draft)
|
try {
|
||||||
setSavedAt(Date.now())
|
const obj = await uploadFile(arcadia, file, { tags: ["avatar"] })
|
||||||
|
const updated = await updateArcadiaProfile(arcadia, {
|
||||||
|
avatar_digital_object_id: obj.id,
|
||||||
|
})
|
||||||
|
setArcadiaProfile(updated)
|
||||||
|
const persistentUrl = pickAvatarUrl(updated)
|
||||||
|
if (persistentUrl) {
|
||||||
|
// Variant pipeline already finished — persist to localStorage.
|
||||||
|
const next = { ...prefs, avatarUrl: persistentUrl }
|
||||||
|
setPrefs(next)
|
||||||
|
savePrefsLocal(next)
|
||||||
|
} else {
|
||||||
|
// Variants aren't ready yet (image-processing is async). Fetch
|
||||||
|
// the raw object as a blob URL for immediate in-memory render.
|
||||||
|
// Don't persist to localStorage — blob URLs don't survive a
|
||||||
|
// reload, and ProfileBootstrap will pick up the persistent URL
|
||||||
|
// on next mount once processing completes.
|
||||||
|
const token =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? sessionStorage.getItem("arcadia_access_token")
|
||||||
|
: null
|
||||||
|
if (token) {
|
||||||
|
const baseUrl =
|
||||||
|
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ??
|
||||||
|
"http://localhost:4000"
|
||||||
|
const tenantId =
|
||||||
|
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ??
|
||||||
|
"default"
|
||||||
|
try {
|
||||||
|
const blobUrl = await fetchDigitalObjectAsBlobUrl(
|
||||||
|
baseUrl,
|
||||||
|
obj.id,
|
||||||
|
token,
|
||||||
|
tenantId,
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.info("[avatar] blob URL ready:", blobUrl)
|
||||||
|
// Persist the blob URL so the appbar's useProfile() picks it
|
||||||
|
// up via the storage event. Blob URLs don't survive a reload,
|
||||||
|
// but ProfileBootstrap will refresh on next mount.
|
||||||
|
const next = { ...prefs, avatarUrl: blobUrl }
|
||||||
|
setPrefs(next)
|
||||||
|
savePrefsLocal(next)
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[avatar] blob fetch failed:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setAvatarError(
|
||||||
|
err instanceof Error ? err.message : "Avatar upload failed.",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAvatarUploading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultAgent =
|
// Mirror the avatar URL into localStorage so it survives reloads.
|
||||||
agents.find((a) => a.id === draft.defaultAgentId) ?? null
|
const savePrefsLocal = (next: Profile) => {
|
||||||
|
saveProfile(next)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Profile">
|
<AppShell>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>You</CardTitle>
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
Account
|
||||||
|
{account?.email_verified ? (
|
||||||
|
<Badge variant="default">Verified</Badge>
|
||||||
|
) : account ? (
|
||||||
|
<Badge variant="secondary">Unverified</Badge>
|
||||||
|
) : null}
|
||||||
|
{account?.status && account.status !== "active" ? (
|
||||||
|
<Badge variant="destructive">{account.status}</Badge>
|
||||||
|
) : null}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Personal info shown across the app — appbar avatar, signatures, and
|
Your arcadia identity. Changes are saved to the platform and reflected
|
||||||
anywhere the assistant references you.
|
anywhere your name or email appears.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-6">
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
{accountError ? (
|
||||||
|
<AlertBanner
|
||||||
|
variant="error"
|
||||||
|
dismissible
|
||||||
|
onDismiss={() => setAccountError(null)}
|
||||||
|
>
|
||||||
|
{accountError}
|
||||||
|
</AlertBanner>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<Avatar className="size-20 ring-2 ring-primary/30">
|
<Avatar className="size-20 ring-2 ring-primary/30">
|
||||||
{draft.avatarUrl ? (
|
{prefs.avatarUrl ? (
|
||||||
<AvatarImage src={draft.avatarUrl} alt={draft.name} />
|
<AvatarImage
|
||||||
|
key={prefs.avatarUrl}
|
||||||
|
src={prefs.avatarUrl}
|
||||||
|
alt={accountDraft.email}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
|
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
|
||||||
{initials}
|
{initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1 text-sm">
|
||||||
<label className="inline-flex w-fit cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground">
|
<span className="font-medium">
|
||||||
<input
|
{account?.full_name || accountDraft.email || "—"}
|
||||||
data-action="profile-avatar-upload"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
|
|
||||||
/>
|
|
||||||
Upload avatar
|
|
||||||
</label>
|
|
||||||
{draft.avatarUrl && (
|
|
||||||
<Button
|
|
||||||
data-action="profile-avatar-remove"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPickAvatar(null)}
|
|
||||||
className="w-fit text-muted-foreground"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" /> Remove
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
PNG, JPG, or SVG. Stored locally as a data URL.
|
|
||||||
</span>
|
</span>
|
||||||
|
{account ? (
|
||||||
|
<>
|
||||||
|
<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{" "}
|
||||||
|
{account.last_sign_in_at
|
||||||
|
? new Date(account.last_sign_in_at).toLocaleString()
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field label="Name">
|
<Field label="First name">
|
||||||
<Input
|
<Input
|
||||||
data-action="profile-name"
|
data-action="profile-first-name"
|
||||||
value={draft.name}
|
value={accountDraft.first_name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft((d) => ({ ...d, name: e.target.value }))
|
setAccountDraft((d) => ({ ...d, first_name: e.target.value }))
|
||||||
}
|
}
|
||||||
autoComplete="name"
|
autoComplete="given-name"
|
||||||
|
disabled={accountLoading || accountSaving}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Email">
|
<Field label="Last name">
|
||||||
|
<Input
|
||||||
|
data-action="profile-last-name"
|
||||||
|
value={accountDraft.last_name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAccountDraft((d) => ({ ...d, last_name: e.target.value }))
|
||||||
|
}
|
||||||
|
autoComplete="family-name"
|
||||||
|
disabled={accountLoading || accountSaving}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
hint="Updating your email may require re-verification."
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
data-action="profile-email"
|
data-action="profile-email"
|
||||||
type="email"
|
type="email"
|
||||||
value={draft.email}
|
value={accountDraft.email}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft((d) => ({ ...d, email: e.target.value }))
|
setAccountDraft((d) => ({ ...d, email: e.target.value }))
|
||||||
}
|
}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
|
disabled={accountLoading || accountSaving}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Title" hint="Your role at work.">
|
|
||||||
<Input
|
|
||||||
data-action="profile-title"
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({ ...d, title: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="e.g. Product designer"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label="Default agent"
|
|
||||||
hint="Used as the active persona on first load."
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
data-action="profile-default-agent"
|
|
||||||
className="inline-flex h-9 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{defaultAgent ? (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">{defaultAgent.name}</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{" "}
|
|
||||||
— {defaultAgent.role}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Use first available"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-64">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
setDraft((d) => ({ ...d, defaultAgentId: "" }))
|
|
||||||
}
|
|
||||||
data-state={!draft.defaultAgentId ? "checked" : undefined}
|
|
||||||
>
|
|
||||||
First available
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{agents.map((a) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={a.id}
|
|
||||||
onClick={() =>
|
|
||||||
setDraft((d) => ({ ...d, defaultAgentId: a.id }))
|
|
||||||
}
|
|
||||||
data-state={
|
|
||||||
draft.defaultAgentId === a.id ? "checked" : undefined
|
|
||||||
}
|
|
||||||
className="flex flex-col items-start"
|
|
||||||
>
|
|
||||||
<span className="font-medium">{a.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{a.role}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
|
||||||
label="Bio"
|
|
||||||
hint="A short blurb the assistant can reference (e.g. 'I work mostly in TypeScript')."
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
data-action="profile-bio"
|
|
||||||
value={draft.bio}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({ ...d, bio: e.target.value }))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Tell the assistant about you."
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label="Signature"
|
|
||||||
hint="Appended automatically when you ask the assistant to draft an email or note."
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
data-action="profile-signature"
|
|
||||||
value={draft.signature}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({ ...d, signature: e.target.value }))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
placeholder={`Cheers,\n${draft.name || "Your name"}`}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
data-action="profile-save"
|
data-action="profile-account-save"
|
||||||
onClick={save}
|
onClick={saveAccount}
|
||||||
disabled={!dirty}
|
disabled={!accountDirty || accountSaving || accountLoading}
|
||||||
>
|
>
|
||||||
Save
|
{accountSaving ? (
|
||||||
|
<RefreshCw className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Save account
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-action="profile-revert"
|
data-action="profile-account-revert"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setDraft(profile)}
|
onClick={() => {
|
||||||
disabled={!dirty}
|
if (!account) return
|
||||||
|
setAccountDraft({
|
||||||
|
first_name: account.first_name ?? "",
|
||||||
|
last_name: account.last_name ?? "",
|
||||||
|
email: account.email,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={!accountDirty || accountSaving}
|
||||||
>
|
>
|
||||||
Revert
|
Revert
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-action="profile-reset"
|
data-action="profile-account-refresh"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={loadAccount}
|
||||||
resetProfile()
|
disabled={accountLoading}
|
||||||
setSavedAt(Date.now())
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Reset to defaults
|
<RefreshCw
|
||||||
|
className={accountLoading ? "size-4 animate-spin" : "size-4"}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{savedAt && !dirty && (
|
{accountSavedAt && !accountDirty && (
|
||||||
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
||||||
<Check className="size-4" /> Saved.
|
<Check className="size-4" /> Saved.
|
||||||
</span>
|
</span>
|
||||||
@@ -265,6 +460,149 @@ export default function ProfileRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Public profile fields stored on arcadia. Visible to other members
|
||||||
|
of this tenant.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{profileError ? (
|
||||||
|
<AlertBanner variant="error">{profileError}</AlertBanner>
|
||||||
|
) : null}
|
||||||
|
<Field
|
||||||
|
label="Bio"
|
||||||
|
hint="A short blurb about you."
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
data-action="profile-bio"
|
||||||
|
value={profileDraft.bio}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfileDraft((d) => ({ ...d, bio: e.target.value }))
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Tell others what you work on."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="Phone">
|
||||||
|
<Input
|
||||||
|
data-action="profile-phone"
|
||||||
|
value={profileDraft.phone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfileDraft((d) => ({ ...d, phone: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="+61 …"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Location">
|
||||||
|
<Input
|
||||||
|
data-action="profile-location"
|
||||||
|
value={profileDraft.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfileDraft((d) => ({ ...d, location: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Melbourne, AU"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Timezone"
|
||||||
|
hint="IANA name (e.g. Australia/Melbourne)."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
data-action="profile-timezone"
|
||||||
|
value={profileDraft.timezone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfileDraft((d) => ({ ...d, timezone: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Australia/Melbourne"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
data-action="profile-arcadia-save"
|
||||||
|
onClick={saveArcadiaProfile}
|
||||||
|
disabled={!profileDirty || profileSaving}
|
||||||
|
>
|
||||||
|
{profileSaving ? "Saving…" : "Save profile"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-action="profile-arcadia-revert"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (!arcadiaProfile) return
|
||||||
|
setProfileDraft({
|
||||||
|
bio: arcadiaProfile.bio ?? "",
|
||||||
|
phone: arcadiaProfile.phone ?? "",
|
||||||
|
location: arcadiaProfile.location ?? "",
|
||||||
|
timezone: arcadiaProfile.timezone ?? "",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={!profileDirty || profileSaving}
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</Button>
|
||||||
|
{profileSavedAt && !profileDirty ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
||||||
|
<Check className="size-4" /> Saved.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Avatar</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Uploads land in your tenant's storage backend.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
{avatarError ? (
|
||||||
|
<AlertBanner variant="error">{avatarError}</AlertBanner>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
aria-disabled={avatarUploading}
|
||||||
|
className={[
|
||||||
|
"inline-flex w-fit items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm",
|
||||||
|
avatarUploading
|
||||||
|
? "cursor-not-allowed opacity-60"
|
||||||
|
: "cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
data-action="profile-avatar-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={avatarUploading}
|
||||||
|
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{avatarUploading ? "Uploading…" : "Upload avatar"}
|
||||||
|
</label>
|
||||||
|
{prefs.avatarUrl && !avatarUploading && (
|
||||||
|
<Button
|
||||||
|
data-action="profile-avatar-remove"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPickAvatar(null)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" /> Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
PNG, JPG, GIF, or WebP. Max 8MB.
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -286,3 +624,5 @@ function Field({
|
|||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { Plus, Search, Trash2 } 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 {
|
|
||||||
createResource,
|
|
||||||
deleteResource,
|
|
||||||
seedResourcesIfEmpty,
|
|
||||||
updateResource,
|
|
||||||
useResources,
|
|
||||||
type Resource,
|
|
||||||
} from "~/lib/resources"
|
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
|
||||||
|
|
||||||
export const meta = () => pageTitle("Resources")
|
|
||||||
|
|
||||||
const statuses: Resource["status"][] = ["active", "paused", "archived"]
|
|
||||||
|
|
||||||
export default function ResourcesRoute() {
|
|
||||||
const items = useResources()
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [draftName, setDraftName] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
seedResourcesIfEmpty()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
return q
|
|
||||||
? items.filter(
|
|
||||||
(r) =>
|
|
||||||
r.name.toLowerCase().includes(q) ||
|
|
||||||
r.owner.toLowerCase().includes(q) ||
|
|
||||||
r.status.includes(q),
|
|
||||||
)
|
|
||||||
: items
|
|
||||||
}, [items, query])
|
|
||||||
|
|
||||||
const create = () => {
|
|
||||||
const name = draftName.trim()
|
|
||||||
if (!name) return
|
|
||||||
createResource({ name, owner: "You" })
|
|
||||||
setDraftName("")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell title="Resources">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Resources</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Example domain entity. CRUD goes through{" "}
|
|
||||||
<code className="font-mono text-xs">~/lib/resources.ts</code> —
|
|
||||||
swap that file's calls for{" "}
|
|
||||||
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
|
|
||||||
from <code className="font-mono text-xs">~/lib/api.ts</code> when
|
|
||||||
you have a backend.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="relative flex-1 min-w-48">
|
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
data-action="resources-search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search name, owner, status…"
|
|
||||||
className="pl-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
data-action="resources-new-name"
|
|
||||||
value={draftName}
|
|
||||||
onChange={(e) => setDraftName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") create()
|
|
||||||
}}
|
|
||||||
placeholder="New resource name…"
|
|
||||||
className="max-w-64"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-action="resources-create"
|
|
||||||
onClick={create}
|
|
||||||
disabled={!draftName.trim()}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" /> Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border bg-card/40">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Owner</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Updated</th>
|
|
||||||
<th className="w-10 px-3 py-2"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
className="px-3 py-8 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
{items.length === 0
|
|
||||||
? "No resources yet — add one above."
|
|
||||||
: "No matches."}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filtered.map((r) => (
|
|
||||||
<tr
|
|
||||||
key={r.id}
|
|
||||||
className="border-t transition-colors hover:bg-accent/30"
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 font-medium">{r.name}</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
|
||||||
{r.owner}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<select
|
|
||||||
data-action={`resources-status-${r.id}`}
|
|
||||||
value={r.status}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateResource(r.id, {
|
|
||||||
status: e.target.value as Resource["status"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
|
|
||||||
>
|
|
||||||
{statuses.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{s}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
|
|
||||||
{new Date(r.updatedAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right">
|
|
||||||
<Button
|
|
||||||
data-action={`resources-delete-${r.id}`}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Delete"
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(`Delete "${r.name}"?`))
|
|
||||||
deleteResource(r.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{items.length} total · {filtered.length} shown
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AppShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Link } from "react-router"
|
|
||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -13,7 +12,8 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import { useToast } from "@crema/notification-ui"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
@@ -26,9 +26,11 @@ import {
|
|||||||
type Column,
|
type Column,
|
||||||
} from "@crema/table-ui"
|
} from "@crema/table-ui"
|
||||||
import { SearchInput } from "@crema/search-ui"
|
import { SearchInput } from "@crema/search-ui"
|
||||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
import { DataState, DialogError } from "~/components/data-state"
|
||||||
|
import { errorMessage } from "~/lib/errors"
|
||||||
import { Badge } from "~/components/ui/badge"
|
import { Badge } from "~/components/ui/badge"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -73,7 +75,7 @@ import {
|
|||||||
} from "~/lib/arcadia/scheduled-tasks"
|
} from "~/lib/arcadia/scheduled-tasks"
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
import { useSession } from "~/lib/session"
|
import { useSession } from "~/lib/session"
|
||||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Scheduled tasks")
|
export const meta = () => pageTitle("Scheduled tasks")
|
||||||
|
|
||||||
@@ -85,11 +87,13 @@ type EditorState =
|
|||||||
export default function ScheduledTasksRoute() {
|
export default function ScheduledTasksRoute() {
|
||||||
const session = useSession()
|
const session = useSession()
|
||||||
const arcadia = useArcadiaClient()
|
const arcadia = useArcadiaClient()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<ScheduledTask[]>([])
|
const [tasks, setTasks] = useState<ScheduledTask[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
// Raw thrown value — the task list's own error. The run-history dialog keeps
|
||||||
const [info, setInfo] = useState<string | null>(null)
|
// its own, so a failing run log never blanks the task table.
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [editor, setEditor] = useState<EditorState>(null)
|
const [editor, setEditor] = useState<EditorState>(null)
|
||||||
const [pendingDelete, setPendingDelete] = useState<ScheduledTask | null>(null)
|
const [pendingDelete, setPendingDelete] = useState<ScheduledTask | null>(null)
|
||||||
@@ -101,7 +105,7 @@ export default function ScheduledTasksRoute() {
|
|||||||
try {
|
try {
|
||||||
setTasks(await listScheduledTasks(arcadia))
|
setTasks(await listScheduledTasks(arcadia))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load scheduled tasks.")
|
setError(err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -200,15 +204,14 @@ export default function ScheduledTasksRoute() {
|
|||||||
setEditor,
|
setEditor,
|
||||||
setPendingDelete,
|
setPendingDelete,
|
||||||
setRunsFor,
|
setRunsFor,
|
||||||
setError,
|
toast,
|
||||||
setInfo,
|
|
||||||
})}
|
})}
|
||||||
triggerDataAction={`task-${t.id}-actions`}
|
triggerDataAction={`task-${t.id}-actions`}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[arcadia, refresh],
|
[arcadia, refresh, toast],
|
||||||
)
|
)
|
||||||
|
|
||||||
const summary = useMemo(
|
const summary = useMemo(
|
||||||
@@ -228,7 +231,7 @@ export default function ScheduledTasksRoute() {
|
|||||||
}),
|
}),
|
||||||
[tasks],
|
[tasks],
|
||||||
)
|
)
|
||||||
useRegisterAdminContext("scheduled_tasks", summary)
|
useRegisterContext("scheduled_tasks", summary)
|
||||||
|
|
||||||
const table = useTable<ScheduledTask>({
|
const table = useTable<ScheduledTask>({
|
||||||
data: tasks,
|
data: tasks,
|
||||||
@@ -241,31 +244,9 @@ export default function ScheduledTasksRoute() {
|
|||||||
table.setSearch(search)
|
table.setSearch(search)
|
||||||
}, [search, table])
|
}, [search, table])
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Scheduled tasks">
|
<AppShell>
|
||||||
<div className="p-8">
|
<div className="flex flex-col gap-4">
|
||||||
<Card className="max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Sign in required</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Scheduled task administration requires an admin session.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/login?next=/scheduled-tasks">Sign in</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</AppShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell title="Scheduled tasks">
|
|
||||||
<div className="flex flex-col gap-4 p-6">
|
|
||||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Scheduled tasks</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Scheduled tasks</h1>
|
||||||
@@ -296,17 +277,6 @@ export default function ScheduledTasksRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
|
||||||
{error}
|
|
||||||
</AlertBanner>
|
|
||||||
) : null}
|
|
||||||
{info ? (
|
|
||||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
|
||||||
{info}
|
|
||||||
</AlertBanner>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center gap-3">
|
<CardHeader className="flex flex-row items-center gap-3">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
@@ -322,8 +292,13 @@ export default function ScheduledTasksRoute() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="relative p-0">
|
<CardContent className="relative p-0">
|
||||||
<LoadingOverlay active={loading && tasks.length === 0} label="Loading tasks…" />
|
<DataState
|
||||||
{table.total === 0 && !loading ? (
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
isEmpty={table.total === 0}
|
||||||
|
onRetry={refresh}
|
||||||
|
loadingLabel="Loading tasks…"
|
||||||
|
empty={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<CalendarClock className="size-6" />}
|
icon={<CalendarClock className="size-6" />}
|
||||||
title={search ? "No tasks match." : "No scheduled tasks yet."}
|
title={search ? "No tasks match." : "No scheduled tasks yet."}
|
||||||
@@ -334,8 +309,8 @@ export default function ScheduledTasksRoute() {
|
|||||||
}
|
}
|
||||||
className="py-12"
|
className="py-12"
|
||||||
/>
|
/>
|
||||||
) : (
|
}
|
||||||
<>
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={table.pageRows}
|
rows={table.pageRows}
|
||||||
@@ -352,8 +327,7 @@ export default function ScheduledTasksRoute() {
|
|||||||
onPageChange={table.setPage}
|
onPageChange={table.setPage}
|
||||||
onPageSizeChange={table.setPageSize}
|
onPageSizeChange={table.setPageSize}
|
||||||
/>
|
/>
|
||||||
</>
|
</DataState>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,14 +345,15 @@ export default function ScheduledTasksRoute() {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!pendingDelete) return
|
if (!pendingDelete) return
|
||||||
|
const name = pendingDelete.name
|
||||||
try {
|
try {
|
||||||
await deleteScheduledTask(arcadia, pendingDelete.id)
|
await deleteScheduledTask(arcadia, pendingDelete.id)
|
||||||
setPendingDelete(null)
|
setPendingDelete(null)
|
||||||
setInfo("Task deleted.")
|
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(`Deleted ${name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
|
||||||
setPendingDelete(null)
|
setPendingDelete(null)
|
||||||
|
toast.error(errorMessage(err, `delete ${name}`))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -386,14 +361,14 @@ export default function ScheduledTasksRoute() {
|
|||||||
<TaskEditorDialog
|
<TaskEditorDialog
|
||||||
state={editor}
|
state={editor}
|
||||||
onClose={() => setEditor(null)}
|
onClose={() => setEditor(null)}
|
||||||
onSaved={async () => {
|
onSaved={async (msg) => {
|
||||||
setEditor(null)
|
setEditor(null)
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(msg)
|
||||||
}}
|
}}
|
||||||
onError={setError}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RunsDialog task={runsFor} onClose={() => setRunsFor(null)} onError={setError} />
|
<RunsDialog task={runsFor} onClose={() => setRunsFor(null)} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -406,11 +381,10 @@ function rowActions(
|
|||||||
setEditor: (s: EditorState) => void
|
setEditor: (s: EditorState) => void
|
||||||
setPendingDelete: (t: ScheduledTask | null) => void
|
setPendingDelete: (t: ScheduledTask | null) => void
|
||||||
setRunsFor: (t: ScheduledTask | null) => void
|
setRunsFor: (t: ScheduledTask | null) => void
|
||||||
setError: (m: string | null) => void
|
toast: ReturnType<typeof useToast>
|
||||||
setInfo: (m: string | null) => void
|
|
||||||
},
|
},
|
||||||
): ActionItem[] {
|
): ActionItem[] {
|
||||||
const { arcadia, refresh, setEditor, setPendingDelete, setRunsFor, setError, setInfo } = ctx
|
const { arcadia, refresh, setEditor, setPendingDelete, setRunsFor, toast } = ctx
|
||||||
const items: ActionItem[] = []
|
const items: ActionItem[] = []
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
@@ -421,10 +395,12 @@ function rowActions(
|
|||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
try {
|
try {
|
||||||
await triggerScheduledTask(arcadia, t.id)
|
await triggerScheduledTask(arcadia, t.id)
|
||||||
setInfo(`${t.name} triggered. Check the run log for status.`)
|
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(`Triggered ${t.name}`, {
|
||||||
|
description: "Check the run log for status.",
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Trigger failed.")
|
toast.error(errorMessage(err, `trigger ${t.name}`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -451,10 +427,10 @@ function rowActions(
|
|||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
try {
|
try {
|
||||||
await disableScheduledTask(arcadia, t.id)
|
await disableScheduledTask(arcadia, t.id)
|
||||||
setInfo(`${t.name} disabled.`)
|
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(`Disabled ${t.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Disable failed.")
|
toast.error(errorMessage(err, `disable ${t.name}`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -467,10 +443,10 @@ function rowActions(
|
|||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
try {
|
try {
|
||||||
await enableScheduledTask(arcadia, t.id)
|
await enableScheduledTask(arcadia, t.id)
|
||||||
setInfo(`${t.name} enabled.`)
|
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(`Enabled ${t.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Enable failed.")
|
toast.error(errorMessage(err, `enable ${t.name}`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -492,12 +468,10 @@ function TaskEditorDialog({
|
|||||||
state,
|
state,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
onError,
|
|
||||||
}: {
|
}: {
|
||||||
state: EditorState
|
state: EditorState
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => Promise<void>
|
onSaved: (msg: string) => Promise<void>
|
||||||
onError: (msg: string | null) => void
|
|
||||||
}) {
|
}) {
|
||||||
const arcadia = useArcadiaClient()
|
const arcadia = useArcadiaClient()
|
||||||
const open = state !== null
|
const open = state !== null
|
||||||
@@ -515,6 +489,13 @@ function TaskEditorDialog({
|
|||||||
const [maxRetries, setMaxRetries] = useState("3")
|
const [maxRetries, setMaxRetries] = useState("3")
|
||||||
const [timeoutSeconds, setTimeoutSeconds] = useState("30")
|
const [timeoutSeconds, setTimeoutSeconds] = useState("30")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
// The dialog owns its failures — including the local "config isn't valid
|
||||||
|
// JSON" throw, which the operator can only fix in this very textarea.
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setError(null)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -546,7 +527,7 @@ function TaskEditorDialog({
|
|||||||
}, [open, initial])
|
}, [open, initial])
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
onError(null)
|
setError(null)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
let parsedConfig: Record<string, unknown>
|
let parsedConfig: Record<string, unknown>
|
||||||
@@ -574,17 +555,17 @@ function TaskEditorDialog({
|
|||||||
timeout_seconds: Math.max(1, Number(timeoutSeconds) || 30),
|
timeout_seconds: Math.max(1, Number(timeoutSeconds) || 30),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && initial) await updateScheduledTask(arcadia, initial.id, input)
|
if (isEdit && initial) {
|
||||||
else await createScheduledTask(arcadia, input)
|
await updateScheduledTask(arcadia, initial.id, input)
|
||||||
await onSaved()
|
await onSaved(`Saved ${name}`)
|
||||||
|
} else {
|
||||||
|
await createScheduledTask(arcadia, input)
|
||||||
|
await onSaved(`Created ${name}`)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(
|
// Stay open with the form intact: cron and JSON config are fiddly enough
|
||||||
err instanceof ArcadiaError
|
// that retyping them after a failure would be its own bug report.
|
||||||
? err.message
|
setError(err)
|
||||||
: err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Save failed.",
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -724,6 +705,13 @@ function TaskEditorDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<DialogError
|
||||||
|
error={error}
|
||||||
|
context={isEdit ? "save the task" : "create the task"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={saving} data-action="task-form-cancel">
|
<Button variant="outline" onClick={onClose} disabled={saving} data-action="task-form-cancel">
|
||||||
Cancel
|
Cancel
|
||||||
@@ -745,31 +733,38 @@ function TaskEditorDialog({
|
|||||||
function RunsDialog({
|
function RunsDialog({
|
||||||
task,
|
task,
|
||||||
onClose,
|
onClose,
|
||||||
onError,
|
|
||||||
}: {
|
}: {
|
||||||
task: ScheduledTask | null
|
task: ScheduledTask | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onError: (msg: string | null) => void
|
|
||||||
}) {
|
}) {
|
||||||
const arcadia = useArcadiaClient()
|
const arcadia = useArcadiaClient()
|
||||||
const [runs, setRuns] = useState<TaskRun[]>([])
|
const [runs, setRuns] = useState<TaskRun[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
// Independent of the task list's error: a 500 on the run log must not blank
|
||||||
|
// the table behind this dialog, and must not read as "no runs yet".
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
const [expanded, setExpanded] = useState<string | null>(null)
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const taskId = task?.id
|
||||||
if (!task) return
|
|
||||||
let mounted = true
|
const load = useCallback(async () => {
|
||||||
|
if (!taskId) return
|
||||||
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
listTaskRuns(arcadia, task.id, { limit: 50 })
|
try {
|
||||||
.then((r) => mounted && setRuns(r))
|
setRuns(await listTaskRuns(arcadia, taskId, { limit: 50 }))
|
||||||
.catch((err) =>
|
} catch (err) {
|
||||||
onError(err instanceof ArcadiaError ? err.message : "Failed to load runs."),
|
setError(err)
|
||||||
)
|
} finally {
|
||||||
.finally(() => mounted && setLoading(false))
|
setLoading(false)
|
||||||
return () => {
|
|
||||||
mounted = false
|
|
||||||
}
|
}
|
||||||
}, [arcadia, task, onError])
|
}, [arcadia, taskId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) return
|
||||||
|
setRuns([])
|
||||||
|
load()
|
||||||
|
}, [taskId, load])
|
||||||
|
|
||||||
if (!task) return null
|
if (!task) return null
|
||||||
|
|
||||||
@@ -783,13 +778,21 @@ function RunsDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{loading ? (
|
<DataState
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
loading={loading}
|
||||||
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading…
|
error={error}
|
||||||
</p>
|
isEmpty={runs.length === 0}
|
||||||
) : runs.length === 0 ? (
|
onRetry={load}
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">No runs yet.</p>
|
loadingLabel="Loading runs…"
|
||||||
) : (
|
empty={
|
||||||
|
<EmptyState
|
||||||
|
icon={<History className="size-6" />}
|
||||||
|
title="No runs yet."
|
||||||
|
description="Trigger the task to see its first run here."
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<ul className="flex flex-col divide-y rounded-md border">
|
<ul className="flex flex-col divide-y rounded-md border">
|
||||||
{runs.map((r) => {
|
{runs.map((r) => {
|
||||||
const open = expanded === r.id
|
const open = expanded === r.id
|
||||||
@@ -851,7 +854,7 @@ function RunsDialog({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
</DataState>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} data-action="task-runs-close">
|
<Button variant="outline" onClick={onClose} data-action="task-runs-close">
|
||||||
@@ -877,3 +880,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
|
|||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
|
|||||||
919
app/routes/search.tsx
Normal file
919
app/routes/search.tsx
Normal file
@@ -0,0 +1,919 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Power,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
useTable,
|
||||||
|
type ActionItem,
|
||||||
|
type Column,
|
||||||
|
} from "@crema/table-ui"
|
||||||
|
import { SearchInput } from "@crema/search-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 {
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
searchAdmin,
|
||||||
|
type CorpusSummary,
|
||||||
|
type TenantSummary,
|
||||||
|
} from "~/lib/search-admin"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { useSession } from "~/lib/session"
|
||||||
|
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Search")
|
||||||
|
|
||||||
|
type Row = CorpusSummary & { rowId: string }
|
||||||
|
|
||||||
|
type EditorState =
|
||||||
|
| { kind: "new-tenant" }
|
||||||
|
| { kind: "new-corpus"; tenant: string }
|
||||||
|
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
||||||
|
| null
|
||||||
|
|
||||||
|
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)
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
const [pendingDeleteCorpus, setPendingDeleteCorpus] = useState<{
|
||||||
|
tenant: string
|
||||||
|
corpus: string
|
||||||
|
} | null>(null)
|
||||||
|
const [restartConfirm, setRestartConfirm] = useState(false)
|
||||||
|
const [rebuilding, setRebuilding] = useState<string | null>(null)
|
||||||
|
|
||||||
|
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. 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 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) {
|
||||||
|
setError(err)
|
||||||
|
setTenants([])
|
||||||
|
setCorpora([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return
|
||||||
|
refresh()
|
||||||
|
}, [session, refresh])
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const indexed = corpora.filter((c) => c.indexed).length
|
||||||
|
const docs = corpora.reduce((a, c) => a + (c.num_docs ?? 0), 0)
|
||||||
|
return { indexed, docs }
|
||||||
|
}, [corpora])
|
||||||
|
|
||||||
|
// Publish a snapshot to the assistant's admin context so the agent
|
||||||
|
// can answer "what corpora exist?" / "is the docs corpus indexed?"
|
||||||
|
// without having to call list_search_corpora.
|
||||||
|
const adminSurface = useMemo(
|
||||||
|
() => ({
|
||||||
|
endpoint: searchAdmin.baseUrl,
|
||||||
|
tenants: tenants.map((t) => ({ id: t.id, corpus_count: t.corpus_count })),
|
||||||
|
corpora: corpora.map((c) => ({
|
||||||
|
tenant: c.tenant,
|
||||||
|
corpus: c.corpus,
|
||||||
|
indexed: c.indexed,
|
||||||
|
num_docs: c.num_docs,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
[tenants, corpora],
|
||||||
|
)
|
||||||
|
useRegisterContext("search", adminSurface)
|
||||||
|
|
||||||
|
const rebuild = useCallback(
|
||||||
|
async (tenant: string, corpus: string) => {
|
||||||
|
const id = `${tenant}/${corpus}`
|
||||||
|
setRebuilding(id)
|
||||||
|
try {
|
||||||
|
const out = await searchAdmin.rebuild(tenant, corpus)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`Rebuilt ${id}`, {
|
||||||
|
description: `${out.chunk_count} chunks indexed.`,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err, `rebuild ${id}`))
|
||||||
|
} finally {
|
||||||
|
setRebuilding(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refresh, toast],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Search</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage arcadia-search tenants and corpora. Trigger rebuilds and
|
||||||
|
restart the service after env changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
data-action="search-refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`size-4 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRestartConfirm(true)}
|
||||||
|
data-action="search-restart"
|
||||||
|
>
|
||||||
|
<Power className="size-4" />
|
||||||
|
Restart service
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditor({ kind: "new-tenant" })}
|
||||||
|
data-action="search-new-tenant"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New tenant
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={tenants.length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
setEditor({
|
||||||
|
kind: "new-corpus",
|
||||||
|
tenant: tenants[0]?.id ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-action="search-new-corpus"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New corpus
|
||||||
|
</Button>
|
||||||
|
</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 ? (
|
||||||
|
<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 ? (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<TenantsCard
|
||||||
|
tenants={tenants}
|
||||||
|
loading={loading}
|
||||||
|
onRetry={refresh}
|
||||||
|
onDelete={(id) => setPendingDeleteTenant(id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CorporaCard
|
||||||
|
corpora={corpora}
|
||||||
|
loading={loading}
|
||||||
|
error={corporaError}
|
||||||
|
onRetry={refresh}
|
||||||
|
rebuildingId={rebuilding}
|
||||||
|
onRebuild={rebuild}
|
||||||
|
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
|
||||||
|
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New tenant */}
|
||||||
|
<NewTenantDialog
|
||||||
|
open={editor?.kind === "new-tenant"}
|
||||||
|
onClose={() => setEditor(null)}
|
||||||
|
onCreated={async (msg) => {
|
||||||
|
setEditor(null)
|
||||||
|
await refresh()
|
||||||
|
toast.success(msg)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New / edit corpus */}
|
||||||
|
<CorpusEditor
|
||||||
|
editor={
|
||||||
|
editor?.kind === "new-corpus" || editor?.kind === "edit-corpus"
|
||||||
|
? editor
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
tenants={tenants}
|
||||||
|
onClose={() => setEditor(null)}
|
||||||
|
onSaved={async (msg) => {
|
||||||
|
setEditor(null)
|
||||||
|
await refresh()
|
||||||
|
toast.success(msg)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete tenant */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDeleteTenant !== null}
|
||||||
|
onOpenChange={(o) => !o && setPendingDeleteTenant(null)}
|
||||||
|
title={`Delete tenant ${pendingDeleteTenant ?? ""}?`}
|
||||||
|
description="Removes the tenant's config directory AND its entire index directory. This cannot be undone."
|
||||||
|
confirmLabel="Delete tenant"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingDeleteTenant) return
|
||||||
|
const id = pendingDeleteTenant
|
||||||
|
try {
|
||||||
|
await searchAdmin.deleteTenant(id)
|
||||||
|
setPendingDeleteTenant(null)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`Deleted tenant ${id}`)
|
||||||
|
} catch (err) {
|
||||||
|
setPendingDeleteTenant(null)
|
||||||
|
toast.error(errorMessage(err, `delete tenant ${id}`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete corpus */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDeleteCorpus !== null}
|
||||||
|
onOpenChange={(o) => !o && setPendingDeleteCorpus(null)}
|
||||||
|
title={
|
||||||
|
pendingDeleteCorpus
|
||||||
|
? `Delete ${pendingDeleteCorpus.tenant}/${pendingDeleteCorpus.corpus}?`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
description="Removes the corpus config and its index directory. The tenant is preserved."
|
||||||
|
confirmLabel="Delete corpus"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!pendingDeleteCorpus) return
|
||||||
|
const { tenant, corpus } = pendingDeleteCorpus
|
||||||
|
try {
|
||||||
|
await searchAdmin.deleteCorpus(tenant, corpus)
|
||||||
|
setPendingDeleteCorpus(null)
|
||||||
|
await refresh()
|
||||||
|
toast.success(`Deleted ${tenant}/${corpus}`)
|
||||||
|
} catch (err) {
|
||||||
|
setPendingDeleteCorpus(null)
|
||||||
|
toast.error(errorMessage(err, `delete ${tenant}/${corpus}`))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Restart confirm */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={restartConfirm}
|
||||||
|
onOpenChange={(o) => !o && setRestartConfirm(false)}
|
||||||
|
title="Restart arcadia-search admin?"
|
||||||
|
description="The sidecar will exit and systemd will bring it back up. Active rebuilds will be aborted."
|
||||||
|
confirmLabel="Restart"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={async () => {
|
||||||
|
setRestartConfirm(false)
|
||||||
|
try {
|
||||||
|
await searchAdmin.restart()
|
||||||
|
toast.success("Requested a restart of arcadia-search")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err, "restart arcadia-search"))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tenants card --------------------------------------------------------
|
||||||
|
|
||||||
|
function TenantsCard({
|
||||||
|
tenants,
|
||||||
|
loading,
|
||||||
|
onRetry,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
tenants: TenantSummary[]
|
||||||
|
loading: boolean
|
||||||
|
onRetry: () => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-base font-semibold">Tenants</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<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
|
||||||
|
key={t.id}
|
||||||
|
className="flex items-center gap-2 rounded-md border bg-card px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<code className="font-mono text-xs">{t.id}</code>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{t.corpus_count} corpus{t.corpus_count === 1 ? "" : "es"}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onDelete(t.id)}
|
||||||
|
aria-label={`Delete tenant ${t.id}`}
|
||||||
|
data-action={`tenant-${t.id}-delete`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DataState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Corpora table -------------------------------------------------------
|
||||||
|
|
||||||
|
function CorporaCard({
|
||||||
|
corpora,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
rebuildingId,
|
||||||
|
onRebuild,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
corpora: Row[]
|
||||||
|
loading: boolean
|
||||||
|
error: unknown
|
||||||
|
onRetry: () => void
|
||||||
|
rebuildingId: string | null
|
||||||
|
onRebuild: (tenant: string, corpus: string) => void
|
||||||
|
onEdit: (tenant: string, corpus: string) => void
|
||||||
|
onDelete: (tenant: string, corpus: string) => void
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
const columns = useMemo<Column<Row>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "tenant",
|
||||||
|
header: "Tenant",
|
||||||
|
accessor: "tenant",
|
||||||
|
sortable: true,
|
||||||
|
cell: (r) => (
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
{r.tenant}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "corpus",
|
||||||
|
header: "Corpus",
|
||||||
|
accessor: "corpus",
|
||||||
|
sortable: true,
|
||||||
|
cell: (r) => (
|
||||||
|
<span className="flex items-center gap-2 font-medium">
|
||||||
|
<Database className="size-4 text-muted-foreground" />
|
||||||
|
{r.corpus}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "indexed",
|
||||||
|
header: "Status",
|
||||||
|
sortable: true,
|
||||||
|
accessor: (r) => (r.indexed ? 1 : 0),
|
||||||
|
cell: (r) =>
|
||||||
|
r.indexed ? (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
Indexed
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Not built
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "docs",
|
||||||
|
header: "Docs",
|
||||||
|
sortable: true,
|
||||||
|
accessor: (r) => r.num_docs ?? -1,
|
||||||
|
cell: (r) =>
|
||||||
|
r.num_docs != null ? (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{r.num_docs.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
align: "right",
|
||||||
|
cell: (r) => {
|
||||||
|
const id = `${r.tenant}/${r.corpus}`
|
||||||
|
const isRebuilding = rebuildingId === id
|
||||||
|
const items: ActionItem[] = [
|
||||||
|
{
|
||||||
|
id: "rebuild",
|
||||||
|
label: isRebuilding ? "Rebuilding…" : "Rebuild",
|
||||||
|
icon: (
|
||||||
|
<RefreshCw
|
||||||
|
className={`size-4 ${isRebuilding ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
dataAction: `corpus-${r.tenant}-${r.corpus}-rebuild`,
|
||||||
|
onSelect: () =>
|
||||||
|
isRebuilding ? undefined : onRebuild(r.tenant, r.corpus),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "edit",
|
||||||
|
label: "Edit config",
|
||||||
|
icon: <FileText className="size-4" />,
|
||||||
|
dataAction: `corpus-${r.tenant}-${r.corpus}-edit`,
|
||||||
|
onSelect: () => onEdit(r.tenant, r.corpus),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
destructive: true,
|
||||||
|
dataAction: `corpus-${r.tenant}-${r.corpus}-delete`,
|
||||||
|
onSelect: () => onDelete(r.tenant, r.corpus),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<ActionsCell
|
||||||
|
items={items}
|
||||||
|
triggerDataAction={`corpus-${r.tenant}-${r.corpus}-actions`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[rebuildingId, onRebuild, onEdit, onDelete],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useTable<Row>({
|
||||||
|
data: corpora,
|
||||||
|
columns,
|
||||||
|
getRowId: (r) => r.rowId,
|
||||||
|
initialPageSize: 25,
|
||||||
|
initialSearch: search,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
table.setSearch(search)
|
||||||
|
}, [search, table])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-3">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
placeholder="Search by tenant or corpus"
|
||||||
|
data-action="corpora-search"
|
||||||
|
className="max-w-sm flex-1"
|
||||||
|
/>
|
||||||
|
<div className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{table.total} of {corpora.length}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="relative p-0">
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
page={table.page}
|
||||||
|
pageSize={table.pageSize}
|
||||||
|
total={table.total}
|
||||||
|
onPageChange={table.setPage}
|
||||||
|
onPageSizeChange={table.setPageSize}
|
||||||
|
/>
|
||||||
|
</DataState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dialogs -------------------------------------------------------------
|
||||||
|
|
||||||
|
function NewTenantDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => 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("")
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await searchAdmin.createTenant(id)
|
||||||
|
await onCreated(`Created tenant ${id}`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New tenant</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Creates an empty config dir at{" "}
|
||||||
|
<code className="font-mono text-xs">
|
||||||
|
$INDEX_CONFIG_DIR/<id>/
|
||||||
|
</code>
|
||||||
|
. Add corpora separately. Names are alphanumeric, dash, or
|
||||||
|
underscore.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="new-tenant-id">Tenant id</Label>
|
||||||
|
<Input
|
||||||
|
id="new-tenant-id"
|
||||||
|
value={id}
|
||||||
|
onChange={(e) => setId(e.target.value)}
|
||||||
|
placeholder="acme"
|
||||||
|
data-action="tenant-form-id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <DialogError error={error} context="create the tenant" /> : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
data-action="tenant-form-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={saving || !id}
|
||||||
|
data-action="tenant-form-save"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<RefreshCw className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
)}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORPUS_CONFIG_TEMPLATE = `{
|
||||||
|
"corpus": "docs",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "arcadia",
|
||||||
|
"list_url": "/api/v1/files?tenant_id={tenant}",
|
||||||
|
"item_url": "/api/v1/files/{id}/content",
|
||||||
|
"title_field": "name",
|
||||||
|
"id_field": "id",
|
||||||
|
"mtime_field": "updated_at",
|
||||||
|
"tags": ["uploaded"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
function CorpusEditor({
|
||||||
|
editor,
|
||||||
|
tenants,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
editor:
|
||||||
|
| { kind: "new-corpus"; tenant: string }
|
||||||
|
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
||||||
|
| null
|
||||||
|
tenants: TenantSummary[]
|
||||||
|
onClose: () => 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 : ""
|
||||||
|
|
||||||
|
// Hydrate on open: load existing config for edit, template for new.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
setTenant(editor.tenant)
|
||||||
|
setError(null)
|
||||||
|
if (editor.kind === "edit-corpus") {
|
||||||
|
setLoading(true)
|
||||||
|
searchAdmin
|
||||||
|
.getCorpus(editor.tenant, editor.corpus)
|
||||||
|
.then((res) => {
|
||||||
|
setText(JSON.stringify(res.config, null, 2))
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
} else {
|
||||||
|
setText(CORPUS_CONFIG_TEMPLATE)
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
if (typeof parsed !== "object" || parsed === null) {
|
||||||
|
throw new Error("config must be a JSON object")
|
||||||
|
}
|
||||||
|
if (editor.kind === "new-corpus") {
|
||||||
|
const corpus = parsed.corpus
|
||||||
|
if (typeof corpus !== "string" || !corpus) {
|
||||||
|
throw new Error('config must have a string "corpus" field')
|
||||||
|
}
|
||||||
|
await searchAdmin.createCorpus(tenant, parsed)
|
||||||
|
await onSaved(`Created ${tenant}/${corpus}`)
|
||||||
|
} else {
|
||||||
|
await searchAdmin.updateCorpus(editor.tenant, editor.corpus, parsed)
|
||||||
|
await onSaved(`Updated ${editor.tenant}/${editor.corpus}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// The JSON the operator just wrote stays in the textarea.
|
||||||
|
setError(err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit ? `Edit ${editor.tenant}/${headerCorpus}` : "New corpus"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
JSON config matching arcadia-search's IndexerConfig schema. The{" "}
|
||||||
|
<code className="font-mono text-xs">tenant</code> field is set
|
||||||
|
from the URL — your value is overwritten.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{!isEdit ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="corpus-tenant">Tenant</Label>
|
||||||
|
<Select value={tenant} onValueChange={setTenant}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="corpus-tenant"
|
||||||
|
data-action="corpus-form-tenant"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Pick a tenant" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="corpus-config">Config JSON</Label>
|
||||||
|
<Textarea
|
||||||
|
id="corpus-config"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
rows={20}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
data-action="corpus-form-config"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<DialogError
|
||||||
|
error={error}
|
||||||
|
context={isEdit ? "save the corpus" : "create the corpus"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
data-action="corpus-form-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={saving || loading || !tenant}
|
||||||
|
data-action="corpus-form-save"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<RefreshCw className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
)}
|
||||||
|
{isEdit ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Link } from "react-router"
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -13,7 +12,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import { useToast } from "@crema/notification-ui"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
@@ -26,9 +26,11 @@ import {
|
|||||||
type Column,
|
type Column,
|
||||||
} from "@crema/table-ui"
|
} from "@crema/table-ui"
|
||||||
import { SearchInput } from "@crema/search-ui"
|
import { SearchInput } from "@crema/search-ui"
|
||||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
import { DataState, DialogError } from "~/components/data-state"
|
||||||
|
import { errorMessage } from "~/lib/errors"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -78,7 +80,7 @@ import {
|
|||||||
} from "~/lib/arcadia/secrets"
|
} from "~/lib/arcadia/secrets"
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
import { useSession } from "~/lib/session"
|
import { useSession } from "~/lib/session"
|
||||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Secrets")
|
export const meta = () => pageTitle("Secrets")
|
||||||
|
|
||||||
@@ -92,11 +94,13 @@ type EditorState =
|
|||||||
export default function SecretsRoute() {
|
export default function SecretsRoute() {
|
||||||
const session = useSession()
|
const session = useSession()
|
||||||
const arcadia = useArcadiaClient()
|
const arcadia = useArcadiaClient()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const [secrets, setSecrets] = useState<Secret[]>([])
|
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
// The raw thrown value — `DataState` normalises it into plain language. A
|
||||||
const [info, setInfo] = useState<string | null>(null)
|
// secrets list that failed to load must never read as "no secrets yet".
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [categoryFilter, setCategoryFilter] = useState<"all" | SecretCategory>("all")
|
const [categoryFilter, setCategoryFilter] = useState<"all" | SecretCategory>("all")
|
||||||
const [editor, setEditor] = useState<EditorState>(null)
|
const [editor, setEditor] = useState<EditorState>(null)
|
||||||
@@ -108,7 +112,7 @@ export default function SecretsRoute() {
|
|||||||
try {
|
try {
|
||||||
setSecrets(await listSecrets(arcadia))
|
setSecrets(await listSecrets(arcadia))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load secrets.")
|
setError(err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -205,15 +209,14 @@ export default function SecretsRoute() {
|
|||||||
refresh,
|
refresh,
|
||||||
setEditor,
|
setEditor,
|
||||||
setPendingDelete,
|
setPendingDelete,
|
||||||
setError,
|
toast,
|
||||||
setInfo,
|
|
||||||
})}
|
})}
|
||||||
triggerDataAction={`secret-${s.name}-actions`}
|
triggerDataAction={`secret-${s.name}-actions`}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[arcadia, refresh],
|
[arcadia, refresh, toast],
|
||||||
)
|
)
|
||||||
|
|
||||||
const summary = useMemo(
|
const summary = useMemo(
|
||||||
@@ -235,7 +238,7 @@ export default function SecretsRoute() {
|
|||||||
}),
|
}),
|
||||||
[secrets],
|
[secrets],
|
||||||
)
|
)
|
||||||
useRegisterAdminContext("secrets", summary)
|
useRegisterContext("secrets", summary)
|
||||||
|
|
||||||
const table = useTable<Secret>({
|
const table = useTable<Secret>({
|
||||||
data: filtered,
|
data: filtered,
|
||||||
@@ -248,31 +251,9 @@ export default function SecretsRoute() {
|
|||||||
table.setSearch(search)
|
table.setSearch(search)
|
||||||
}, [search, table])
|
}, [search, table])
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Secrets">
|
<AppShell>
|
||||||
<div className="p-8">
|
<div className="flex flex-col gap-4">
|
||||||
<Card className="max-w-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Sign in required</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Secrets administration requires an admin session.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/login?next=/secrets">Sign in</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</AppShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell title="Secrets">
|
|
||||||
<div className="flex flex-col gap-4 p-6">
|
|
||||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Secrets</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Secrets</h1>
|
||||||
@@ -302,17 +283,6 @@ export default function SecretsRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
|
||||||
{error}
|
|
||||||
</AlertBanner>
|
|
||||||
) : null}
|
|
||||||
{info ? (
|
|
||||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
|
||||||
{info}
|
|
||||||
</AlertBanner>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row flex-wrap items-center gap-3">
|
<CardHeader className="flex flex-row flex-wrap items-center gap-3">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
@@ -344,8 +314,13 @@ export default function SecretsRoute() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="relative p-0">
|
<CardContent className="relative p-0">
|
||||||
<LoadingOverlay active={loading && secrets.length === 0} label="Loading secrets…" />
|
<DataState
|
||||||
{table.total === 0 && !loading ? (
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
isEmpty={table.total === 0}
|
||||||
|
onRetry={refresh}
|
||||||
|
loadingLabel="Loading secrets…"
|
||||||
|
empty={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={
|
title={
|
||||||
search || categoryFilter !== "all"
|
search || categoryFilter !== "all"
|
||||||
@@ -359,8 +334,8 @@ export default function SecretsRoute() {
|
|||||||
}
|
}
|
||||||
className="py-12"
|
className="py-12"
|
||||||
/>
|
/>
|
||||||
) : (
|
}
|
||||||
<>
|
>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={table.pageRows}
|
rows={table.pageRows}
|
||||||
@@ -377,8 +352,7 @@ export default function SecretsRoute() {
|
|||||||
onPageChange={table.setPage}
|
onPageChange={table.setPage}
|
||||||
onPageSizeChange={table.setPageSize}
|
onPageSizeChange={table.setPageSize}
|
||||||
/>
|
/>
|
||||||
</>
|
</DataState>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,14 +370,15 @@ export default function SecretsRoute() {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!pendingDelete) return
|
if (!pendingDelete) return
|
||||||
|
const target = pendingDelete
|
||||||
try {
|
try {
|
||||||
await deleteSecret(arcadia, pendingDelete.id)
|
await deleteSecret(arcadia, target.id)
|
||||||
setPendingDelete(null)
|
setPendingDelete(null)
|
||||||
setInfo("Secret deleted.")
|
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(`Deleted ${target.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
|
||||||
setPendingDelete(null)
|
setPendingDelete(null)
|
||||||
|
toast.error(errorMessage(err, `delete ${target.name}`))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -411,12 +386,13 @@ export default function SecretsRoute() {
|
|||||||
<SecretEditorDialog
|
<SecretEditorDialog
|
||||||
state={editor}
|
state={editor}
|
||||||
onClose={() => setEditor(null)}
|
onClose={() => setEditor(null)}
|
||||||
onSaved={async (msg) => {
|
onSaved={async (msg, opts) => {
|
||||||
setEditor(null)
|
// The versions dialog stays open across a rollback — only the
|
||||||
if (msg) setInfo(msg)
|
// create/edit/rotate flows close on success.
|
||||||
|
if (!opts?.keepOpen) setEditor(null)
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(msg)
|
||||||
}}
|
}}
|
||||||
onError={setError}
|
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
@@ -443,11 +419,10 @@ function rowActions(
|
|||||||
refresh: () => Promise<void>
|
refresh: () => Promise<void>
|
||||||
setEditor: (e: EditorState) => void
|
setEditor: (e: EditorState) => void
|
||||||
setPendingDelete: (s: Secret | null) => void
|
setPendingDelete: (s: Secret | null) => void
|
||||||
setError: (m: string | null) => void
|
toast: ReturnType<typeof useToast>
|
||||||
setInfo: (m: string | null) => void
|
|
||||||
},
|
},
|
||||||
): ActionItem[] {
|
): ActionItem[] {
|
||||||
const { arcadia, refresh, setEditor, setPendingDelete, setError, setInfo } = ctx
|
const { arcadia, refresh, setEditor, setPendingDelete, toast } = ctx
|
||||||
const items: ActionItem[] = []
|
const items: ActionItem[] = []
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
@@ -480,10 +455,10 @@ function rowActions(
|
|||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
try {
|
try {
|
||||||
await disableSecret(arcadia, s.id)
|
await disableSecret(arcadia, s.id)
|
||||||
setInfo(`${s.name} disabled.`)
|
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(`Disabled ${s.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Disable failed.")
|
toast.error(errorMessage(err, `disable ${s.name}`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -496,10 +471,10 @@ function rowActions(
|
|||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
try {
|
try {
|
||||||
await enableSecret(arcadia, s.id)
|
await enableSecret(arcadia, s.id)
|
||||||
setInfo(`${s.name} enabled.`)
|
|
||||||
await refresh()
|
await refresh()
|
||||||
|
toast.success(`Enabled ${s.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Enable failed.")
|
toast.error(errorMessage(err, `enable ${s.name}`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -517,41 +492,43 @@ function rowActions(
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the parent does when a dialog reports success. */
|
||||||
|
type OnSaved = (message: string, opts?: { keepOpen?: boolean }) => Promise<void>
|
||||||
|
|
||||||
function SecretEditorDialog({
|
function SecretEditorDialog({
|
||||||
state,
|
state,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
onError,
|
|
||||||
}: {
|
}: {
|
||||||
state: EditorState
|
state: EditorState
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: (info?: string) => Promise<void>
|
onSaved: OnSaved
|
||||||
onError: (msg: string | null) => void
|
|
||||||
}) {
|
}) {
|
||||||
if (state?.mode === "versions") {
|
if (state?.mode === "versions") {
|
||||||
return <VersionsDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
|
return <VersionsDialog state={state} onClose={onClose} onSaved={onSaved} />
|
||||||
}
|
}
|
||||||
if (state?.mode === "rotate") {
|
if (state?.mode === "rotate") {
|
||||||
return <RotateDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
|
return <RotateDialog state={state} onClose={onClose} onSaved={onSaved} />
|
||||||
}
|
}
|
||||||
return <UpsertDialog state={state} onClose={onClose} onSaved={onSaved} onError={onError} />
|
return <UpsertDialog state={state} onClose={onClose} onSaved={onSaved} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpsertDialog({
|
function UpsertDialog({
|
||||||
state,
|
state,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
onError,
|
|
||||||
}: {
|
}: {
|
||||||
state: EditorState
|
state: EditorState
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: (info?: string) => Promise<void>
|
onSaved: OnSaved
|
||||||
onError: (msg: string | null) => void
|
|
||||||
}) {
|
}) {
|
||||||
const arcadia = useArcadiaClient()
|
const arcadia = useArcadiaClient()
|
||||||
const open = state?.mode === "create" || state?.mode === "edit"
|
const open = state?.mode === "create" || state?.mode === "edit"
|
||||||
const isEdit = state?.mode === "edit"
|
const isEdit = state?.mode === "edit"
|
||||||
const initial = isEdit ? state.secret : null
|
const initial = isEdit ? state.secret : null
|
||||||
|
// A failed save renders here, not on the page behind the scrim — and the
|
||||||
|
// form keeps its state, including the value the operator just pasted.
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
|
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [value, setValue] = useState("")
|
const [value, setValue] = useState("")
|
||||||
@@ -568,7 +545,11 @@ function UpsertDialog({
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
if (initial) {
|
if (initial) {
|
||||||
setName(initial.name)
|
setName(initial.name)
|
||||||
setValue("")
|
setValue("")
|
||||||
@@ -600,18 +581,19 @@ function UpsertDialog({
|
|||||||
|
|
||||||
const generate = async () => {
|
const generate = async () => {
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const v = await generateSecretValue(arcadia, { length: 48 })
|
const v = await generateSecretValue(arcadia, { length: 48 })
|
||||||
setValue(v)
|
setValue(v)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(err instanceof ArcadiaError ? err.message : "Generate failed.")
|
setError(err)
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
onError(null)
|
setError(null)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const tags = csv(tagsText)
|
const tags = csv(tagsText)
|
||||||
@@ -636,7 +618,7 @@ function UpsertDialog({
|
|||||||
expires_at,
|
expires_at,
|
||||||
rotation_interval_days,
|
rotation_interval_days,
|
||||||
})
|
})
|
||||||
await onSaved("Secret metadata updated.")
|
await onSaved(`Updated ${initial.name}`)
|
||||||
} else {
|
} else {
|
||||||
if (!value) throw new Error("A value is required for new secrets.")
|
if (!value) throw new Error("A value is required for new secrets.")
|
||||||
const input: SecretCreateInput = {
|
const input: SecretCreateInput = {
|
||||||
@@ -653,10 +635,10 @@ function UpsertDialog({
|
|||||||
rotation_interval_days,
|
rotation_interval_days,
|
||||||
}
|
}
|
||||||
await createSecret(arcadia, input)
|
await createSecret(arcadia, input)
|
||||||
await onSaved("Secret created.")
|
await onSaved(`Created ${name}`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
|
setError(err)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -835,6 +817,13 @@ function UpsertDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<DialogError
|
||||||
|
error={error}
|
||||||
|
context={isEdit ? "save the secret" : "create the secret"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={saving} data-action="secret-form-cancel">
|
<Button variant="outline" onClick={onClose} disabled={saving} data-action="secret-form-cancel">
|
||||||
Cancel
|
Cancel
|
||||||
@@ -857,43 +846,46 @@ function RotateDialog({
|
|||||||
state,
|
state,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
onError,
|
|
||||||
}: {
|
}: {
|
||||||
state: { mode: "rotate"; secret: Secret }
|
state: { mode: "rotate"; secret: Secret }
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: (info?: string) => Promise<void>
|
onSaved: OnSaved
|
||||||
onError: (msg: string | null) => void
|
|
||||||
}) {
|
}) {
|
||||||
const arcadia = useArcadiaClient()
|
const arcadia = useArcadiaClient()
|
||||||
const [value, setValue] = useState("")
|
const [value, setValue] = useState("")
|
||||||
const [note, setNote] = useState("")
|
const [note, setNote] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
|
// Rotation is destructive-adjacent: if it fails, the operator must see why
|
||||||
|
// *here*, with the new value still in the field.
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue("")
|
setValue("")
|
||||||
setNote("")
|
setNote("")
|
||||||
|
setError(null)
|
||||||
}, [state])
|
}, [state])
|
||||||
|
|
||||||
const generate = async () => {
|
const generate = async () => {
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
try {
|
try {
|
||||||
setValue(await generateSecretValue(arcadia, { length: 48 }))
|
setValue(await generateSecretValue(arcadia, { length: 48 }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(err instanceof ArcadiaError ? err.message : "Generate failed.")
|
setError(err)
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false)
|
setGenerating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
onError(null)
|
setError(null)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await rotateSecret(arcadia, state.secret.id, { value, note: note || undefined })
|
await rotateSecret(arcadia, state.secret.id, { value, note: note || undefined })
|
||||||
await onSaved(`${state.secret.name} rotated.`)
|
await onSaved(`Rotated ${state.secret.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Rotate failed.")
|
setError(err)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -947,6 +939,10 @@ function RotateDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<DialogError error={error} context={`rotate ${state.secret.name}`} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={saving} data-action="secret-rotate-cancel">
|
<Button variant="outline" onClick={onClose} disabled={saving} data-action="secret-rotate-cancel">
|
||||||
Cancel
|
Cancel
|
||||||
@@ -965,28 +961,31 @@ function VersionsDialog({
|
|||||||
state,
|
state,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
onError,
|
|
||||||
}: {
|
}: {
|
||||||
state: { mode: "versions"; secret: Secret }
|
state: { mode: "versions"; secret: Secret }
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: (info?: string) => Promise<void>
|
onSaved: OnSaved
|
||||||
onError: (msg: string | null) => void
|
|
||||||
}) {
|
}) {
|
||||||
const arcadia = useArcadiaClient()
|
const arcadia = useArcadiaClient()
|
||||||
|
const toast = useToast()
|
||||||
const [versions, setVersions] = useState<SecretVersion[]>([])
|
const [versions, setVersions] = useState<SecretVersion[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
// A versions load that failed is not a secret with no history. Own error
|
||||||
|
// state, rendered in place of the list.
|
||||||
|
const [error, setError] = useState<unknown>(null)
|
||||||
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
const [pendingRollback, setPendingRollback] = useState<SecretVersion | null>(null)
|
const [pendingRollback, setPendingRollback] = useState<SecretVersion | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true
|
let mounted = true
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
listSecretVersions(arcadia, state.secret.id)
|
listSecretVersions(arcadia, state.secret.id)
|
||||||
.then((v) => {
|
.then((v) => {
|
||||||
if (mounted) setVersions(v.sort((a, b) => b.version - a.version))
|
if (mounted) setVersions(v.sort((a, b) => b.version - a.version))
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (mounted)
|
if (mounted) setError(err)
|
||||||
onError(err instanceof ArcadiaError ? err.message : "Failed to load versions.")
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (mounted) setLoading(false)
|
if (mounted) setLoading(false)
|
||||||
@@ -994,7 +993,7 @@ function VersionsDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false
|
mounted = false
|
||||||
}
|
}
|
||||||
}, [arcadia, state.secret.id, onError])
|
}, [arcadia, state.secret.id, reloadKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||||
@@ -1006,15 +1005,18 @@ function VersionsDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{loading ? (
|
<DataState
|
||||||
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
loading={loading}
|
||||||
<RefreshCw className="mr-2 size-4 animate-spin" /> Loading…
|
error={error}
|
||||||
</div>
|
isEmpty={versions.length === 0}
|
||||||
) : versions.length === 0 ? (
|
onRetry={() => setReloadKey((n) => n + 1)}
|
||||||
|
loadingLabel="Loading versions…"
|
||||||
|
empty={
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
No previous versions yet. Rotate the value to create one.
|
No previous versions yet. Rotate the value to create one.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
}
|
||||||
|
>
|
||||||
<ul className="flex flex-col divide-y rounded-md border">
|
<ul className="flex flex-col divide-y rounded-md border">
|
||||||
{versions.map((v) => (
|
{versions.map((v) => (
|
||||||
<li key={v.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
<li key={v.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||||
@@ -1038,7 +1040,7 @@ function VersionsDialog({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
</DataState>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} data-action="secret-versions-close">
|
<Button variant="outline" onClick={onClose} data-action="secret-versions-close">
|
||||||
@@ -1059,13 +1061,25 @@ function VersionsDialog({
|
|||||||
variant="default"
|
variant="default"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!pendingRollback) return
|
if (!pendingRollback) return
|
||||||
|
const target = pendingRollback
|
||||||
try {
|
try {
|
||||||
await rollbackSecret(arcadia, state.secret.id, pendingRollback.version)
|
await rollbackSecret(arcadia, state.secret.id, target.version)
|
||||||
setPendingRollback(null)
|
setPendingRollback(null)
|
||||||
await onSaved(`Rolled back to version ${pendingRollback.version}.`)
|
setReloadKey((n) => n + 1)
|
||||||
|
// Keep the versions dialog open — the rollback minted a new
|
||||||
|
// version, and the operator is looking right at the list.
|
||||||
|
await onSaved(
|
||||||
|
`Rolled ${state.secret.name} back to version ${target.version}`,
|
||||||
|
{ keepOpen: true },
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(err instanceof ArcadiaError ? err.message : "Rollback failed.")
|
|
||||||
setPendingRollback(null)
|
setPendingRollback(null)
|
||||||
|
toast.error(
|
||||||
|
errorMessage(
|
||||||
|
err,
|
||||||
|
`roll ${state.secret.name} back to version ${target.version}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1088,3 +1102,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
|
|||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { Link } from "react-router"
|
||||||
import {
|
import {
|
||||||
Cpu,
|
Bot,
|
||||||
Palette,
|
Palette,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Info,
|
Info,
|
||||||
Users,
|
Users,
|
||||||
|
Building2,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { listModels } from "@crema/llm-ui"
|
import { listModels } from "@crema/llm-ui"
|
||||||
import {
|
import {
|
||||||
@@ -16,10 +19,24 @@ import {
|
|||||||
useSettings as useProviderSettings,
|
useSettings as useProviderSettings,
|
||||||
type LLMProvidersSettings,
|
type LLMProvidersSettings,
|
||||||
} from "@crema/llm-providers-ui"
|
} 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 { 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 { Button } from "~/components/ui/button"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Textarea } from "~/components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -42,20 +59,42 @@ export const meta = () => pageTitle("Settings")
|
|||||||
|
|
||||||
const SECTION_KEY = "crema.settings.section"
|
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
|
id: SectionId
|
||||||
label: string
|
label: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
description: string
|
description: string
|
||||||
}[] = [
|
/** When set, the section only shows if the session holds this capability. */
|
||||||
{ id: "llm", label: "LLM", icon: Cpu, description: "Model endpoint & budgets" },
|
requiresCapability?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: SectionDef[] = [
|
||||||
{
|
{
|
||||||
id: "agents",
|
id: "assistant",
|
||||||
label: "Agents",
|
label: "Assistant",
|
||||||
|
icon: Bot,
|
||||||
|
description: "Model, endpoint & budgets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "personas",
|
||||||
|
label: "Personas",
|
||||||
icon: Users,
|
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",
|
id: "appearance",
|
||||||
@@ -63,7 +102,7 @@ const sections: {
|
|||||||
icon: Palette,
|
icon: Palette,
|
||||||
description: "Theme, font size, surface, background",
|
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" },
|
{ id: "about", label: "About", icon: Info, description: "Version & credits" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -98,15 +137,15 @@ export default function SettingsRoute() {
|
|||||||
arcadiaTenantId,
|
arcadiaTenantId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// In proxy mode the adapter just being built is the strongest signal we
|
// Proxy mode: round-trip a 1-token chat to verify auth → secret
|
||||||
// can get without actually firing a chat request — the proxy endpoint
|
// resolution → upstream dispatch end-to-end. Maps the contract's
|
||||||
// doesn't exist on the backend yet, so any /models probe would 404.
|
// specific error codes to user-facing messages.
|
||||||
if (s.mode === "proxy") {
|
if (s.mode === "proxy") {
|
||||||
return {
|
return probeProxy(arcadia, {
|
||||||
ok: true,
|
provider: s.providerId as LLMProxyProvider,
|
||||||
message:
|
model: s.model || (s.providerId === "anthropic" ? "claude-opus-4-7" : "gpt-4o-mini"),
|
||||||
"Adapter built. Note: the backend proxy (/api/v1/ai/llm/chat) isn't deployed yet — see docs/LLM_PROXY_CONTRACT.md.",
|
secretName: s.secretName || undefined,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct mode — for OpenAI-compatible endpoints, /models is a cheap probe.
|
// Direct mode — for OpenAI-compatible endpoints, /models is a cheap probe.
|
||||||
@@ -118,7 +157,7 @@ export default function SettingsRoute() {
|
|||||||
: s.providerId === "openai"
|
: s.providerId === "openai"
|
||||||
? "https://api.openai.com/v1"
|
? "https://api.openai.com/v1"
|
||||||
: s.providerId === "deepseek"
|
: s.providerId === "deepseek"
|
||||||
? "https://api.deepseek.com/v1"
|
? "https://api.deepseek.com"
|
||||||
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
|
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
|
||||||
// Resolve key for the probe (lmstudio doesn't need one).
|
// Resolve key for the probe (lmstudio doesn't need one).
|
||||||
let apiKey: string | undefined
|
let apiKey: string | undefined
|
||||||
@@ -155,26 +194,36 @@ 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>(() => {
|
const [section, setSection] = useState<SectionId>(() => {
|
||||||
if (typeof window === "undefined") return "llm"
|
if (typeof window === "undefined") return "assistant"
|
||||||
const stored = localStorage.getItem(SECTION_KEY)
|
const stored = localStorage.getItem(SECTION_KEY)
|
||||||
return sections.some((s) => s.id === stored)
|
return sections.some((s) => s.id === stored)
|
||||||
? (stored as SectionId)
|
? (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(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined")
|
if (typeof window !== "undefined")
|
||||||
localStorage.setItem(SECTION_KEY, section)
|
localStorage.setItem(SECTION_KEY, section)
|
||||||
}, [section])
|
}, [section])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Settings">
|
<AppShell>
|
||||||
<div className="grid gap-6 md:grid-cols-[14rem_1fr]">
|
<div className="grid gap-6 pt-10 md:grid-cols-[14rem_1fr] md:pt-0">
|
||||||
<nav
|
<nav
|
||||||
aria-label="Settings sections"
|
aria-label="Settings sections"
|
||||||
className="flex flex-row gap-1 overflow-x-auto md:flex-col md:gap-0.5"
|
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 Icon = s.icon
|
||||||
const active = section === s.id
|
const active = section === s.id
|
||||||
return (
|
return (
|
||||||
@@ -209,23 +258,22 @@ export default function SettingsRoute() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{section === "llm" && (
|
{section === "assistant" && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Card>
|
<LlmConfigurationsPanel />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>LLM</CardTitle>
|
<details className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||||
<CardDescription>
|
<summary className="cursor-pointer text-muted-foreground">
|
||||||
Pick a provider, model, and the arcadia-vault secret holding the API key. Settings
|
Advanced: tweak the active session settings (transport, system prompt,
|
||||||
auto-save as you type. The Assistant picks them up on the next message.
|
context budget) directly
|
||||||
</CardDescription>
|
</summary>
|
||||||
</CardHeader>
|
<div className="pt-3">
|
||||||
<CardContent>
|
|
||||||
<LLMProvidersSettingsCard
|
<LLMProvidersSettingsCard
|
||||||
onTest={testConnection}
|
onTest={testConnection}
|
||||||
hideTransportToggle={false}
|
hideTransportToggle={false}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</details>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -242,39 +290,37 @@ export default function SettingsRoute() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "agents" && <AgentsPanel />}
|
{section === "personas" && <AgentsPanel />}
|
||||||
|
|
||||||
|
{section === "tenant" && <TenantSettingsSection />}
|
||||||
|
|
||||||
{section === "appearance" && (
|
{section === "appearance" && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>Appearance</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Theme, font size, surface tint, and background atmosphere are
|
Theme, density, and background for this browser. These are
|
||||||
in the appbar — the toggles up top write to localStorage and
|
also in the top-right toolbar; they persist locally.
|
||||||
persist across sessions.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<CardContent className="flex flex-col divide-y">
|
||||||
Use the icons in the appbar (top right) to change theme, font
|
<AppearanceRow label="Theme" hint="Light or dark.">
|
||||||
size, surface tint, and background.
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === "account" && (
|
{section === "account" && <AccountSection />}
|
||||||
<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 === "about" && (
|
{section === "about" && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -283,11 +329,8 @@ export default function SettingsRoute() {
|
|||||||
<CardDescription>App version and credits.</CardDescription>
|
<CardDescription>App version and credits.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1 text-sm text-muted-foreground">
|
<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>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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -315,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() {
|
function AgentsPanel() {
|
||||||
const agents = useAgents()
|
const agents = useAgents()
|
||||||
const [activeId, setActiveId] = useState<string>(() => loadActiveAgentId())
|
const [activeId, setActiveId] = useState<string>(() => loadActiveAgentId())
|
||||||
@@ -351,12 +536,12 @@ function AgentsPanel() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Agents</CardTitle>
|
<CardTitle>Personas</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Personas with their own sub-system prompts. Switch the active one in
|
Assistant personas with their own sub-system prompts. Switch the
|
||||||
the chat status bar — the assistant inherits its skills, tone, and
|
active one in the chat status bar — the assistant inherits its skills,
|
||||||
scope. Lets you keep contexts focused: a coder agent doesn't carry
|
tone, and scope. Stored in this browser only, not synced to your
|
||||||
writing-task context; a writer doesn't carry codebase context.
|
account.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
@@ -482,3 +667,5 @@ function AgentsPanel() {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||||
|
|||||||
44
app/routes/signup.tsx
Normal file
44
app/routes/signup.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { useNavigate } from "react-router"
|
||||||
|
|
||||||
|
import { SignupForm } from "@crema/arcadia-auth-ui"
|
||||||
|
import { useBrand } from "~/lib/identity"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { persistFromArcadiaLogin, useSession } from "~/lib/session"
|
||||||
|
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Create account")
|
||||||
|
|
||||||
|
export default function SignupRoute() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const session = useSession()
|
||||||
|
const brand = useBrand()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) navigate("/", { replace: true })
|
||||||
|
}, [session, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell>
|
||||||
|
<SignupForm
|
||||||
|
brand={<AuthBrand />}
|
||||||
|
heading={`Join ${brand.name}`}
|
||||||
|
onSignin={() => navigate("/login")}
|
||||||
|
onSuccess={async ({ tokens, user, emailVerificationSent }) => {
|
||||||
|
if (tokens) {
|
||||||
|
persistFromArcadiaLogin(tokens, user)
|
||||||
|
navigate("/", { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No tokens returned — verification email gating. Bounce to login.
|
||||||
|
navigate(
|
||||||
|
emailVerificationSent
|
||||||
|
? "/login?verify=sent"
|
||||||
|
: "/login",
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user