47 Commits

Author SHA1 Message Date
jules
f5a994e1d8 ui: confirming an alert dialog should close it
AlertDialogAction was a plain <Button> — only AlertDialogCancel wrapped the
primitive's Close — so confirming ran the action but never dismissed the
dialog. Found in Trippy, where "Clear conversation" cleared the chat and left
the modal sitting on screen; the same component is in every Crema app.

Delete-style dialogs hide it by accident: the row they act on unmounts out from
under the open dialog and takes it with it. Any confirm that leaves its target
on screen (a rewrite, a regenerate, a clear) just gets stuck.

Fixed at the component, so every call site gets it. The action still runs —
Base UI merges its own close handler with the caller's onClick — and async work
carries on behind the closed dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:58:33 +10:00
jules
3fcdddefda Phase 7: polish — overview health, reduced-motion, mobile, a11y, copy-ids
- Overview health hero derives from subsystem probes when the backend gives no
  overall status, so it never says "Unknown" next to an all-Healthy Subsystems
  card (the audit's most jarring contradiction). Home error copy now uses the
  Phase-2 describeError instead of raw backend strings.
- Global prefers-reduced-motion guard (transitions, loops, aurora field) +
  motion-reduce on the sidebar width transition.
- Mobile: content reserves a top row (pt-16 md:p-6) so the floating hamburger
  never overlaps the page H1.
- A11y: collapsed rail items get an explicit aria-label.
- Monitoring: disk "busiest mount" tile shows the % as the value and the long
  mount path as a truncated caption, so it no longer clips mid-word.
- New CopyId component (click-to-copy UUIDs), wired into Profile.

Typecheck 36→36, 24-route sweep clean. Remaining items (lib a11y labels,
table aria-sort, dev-only monitoring data) noted in the spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:40:58 +10:00
jules
a03fbd9e9b Phase 6: restructure Settings honestly
Settings was assistant config mislabeled, plus a dev-scaffold Account panel
("Wire ~/lib/identity.ts…") shown to users. Now:

- Assistant (was "LLM") + Personas (was "Agents", labelled "stored in this
  browser") — honest names for what these actually configure.
- Tenant (new, platform.tenants-gated) — reuses the Phase-4 Branding +
  Localization tab components scoped to the operator's active tenant. This is
  the component reuse the phase order was built for.
- Appearance — real inline pickers (theme/font/surface/background) instead of
  "use the icons in the appbar" text.
- Account — real read-only identity from the session + an Edit-profile link,
  replacing the dev scaffold.

Sections filter by capability. Typecheck 36→36, 24-route sweep clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:27:32 +10:00
jules
af2c8d6663 Phase 5: platform feature-flags CRUD, impersonation, billing catalogue
Three new platform screens on top of the Phase 1-4 work.

Feature flags (/feature-flags) — platform-wide flag registry. New route +
lib/arcadia/feature-flags.ts, capability platform.feature_flags, nav under
Automation. List/create/edit/delete with a per-row default toggle; pairs with
the Phase-4 per-tenant override tab.

Impersonation — "Impersonate" action on active users. Entirely client-side
token swap in session.ts (beginImpersonation parks the operator's session +
API token and swaps to the impersonation token; endImpersonation restores it),
with a sticky "Viewing as <email> — Stop" banner in the shell driven by the
JWT's impersonated_by claim. Stop is client-side because the impersonation
token carries the target's roles and can't reach the admin-gated /stop
endpoint; impersonation is stateless JWT so restoring the parked token is
sufficient.

Billing (/billing) — replaced the coming-soon stub with the real plan
catalogue from GET /billing/plans (lib/arcadia/billing.ts). Per-tenant plan
assignment stays on the tenant detail page; Entitlements + Apps remain honestly
marked "Soon".

Verified in-browser with real backend; typecheck adds zero errors (36→36).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:04:09 +10:00
jules
7415b40240 Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page
From the 2026-07-14 UI/UX audit (17/40). Four phases:

P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never
imported). Added imports + a shared route-level error boundary
(components/route-error.tsx) re-exported from all shell routes, so one
crashing panel degrades to an explained card with the nav intact instead of
replacing the whole app with a stack trace. Corrected the stale CLAUDE.md
claim that `npm run typecheck` crashes — it works, and would have caught the
missing import.

P2 — Error/empty/feedback foundations. Fixed useSession identity churn that
fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially
stable snapshot). New lib/errors.ts (describeError → plain-language + the fix)
and components/data-state.tsx (DataState renders exactly one of
error/loading/empty/content, so a failed load never shows as "empty";
DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15
list routes; every mutation now toasts. Surfaced+fixed two silent-failure
bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules
on save).

P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent
sections; collapsed the Apps/Plan/Entitlements stub triplication into one
honest /billing page; deleted fabricated seeded notifications and the dead
Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't
exist); removed orphan /assistant + /library routes; gated dev-seed login
hints behind DEV; renamed /activity → /audit-log with a redirect.

P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*),
closing the provision→configure gap. 8 tabs (Overview, Plan & quotas,
Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound
webhooks), each verified saving to the real backend. Row name links to detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:43:57 +10:00
jules
938143f3f5 refactor: rename service references arcadia-app → arcadia-core
The Phoenix auth/identity/tenancy backend repo is being renamed
arcadia-app → arcadia-core (its primary OTP app is already arcadia_core).
Updates prose, doc paths, and git.sky-ai.com repo URLs. Deliberately
leaves the Rust crate arcadia-app-client and host arcadia-app.internal
(handled separately), and the kept namespace (issuer/release "arcadia").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:40:25 +10:00
jules
ab116f8465 refactor: rename @crema/arcadia-client → @crema/arcadia-core-client
Disambiguates the Phoenix/auth client lib from lib-arcadia-agents-client.
Dir lib-arcadia-client → lib-arcadia-core-client; alias updated in
tsconfig paths, vite config, app.css @source, imports, CI and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:31:56 +10:00
jules
4b817b85ff Wire operator Integrations page + capability-gating framework
Completes the arcadia-admin operator surface for the integration registry and
the capability/route-guard framework it depends on.

- Integration registry: route + Data-group nav entry + `platform.integrations`
  capability; the in-app client now delegates to the shared
  `@crema/integration-registry-client` lib (vite alias + tsconfig); the
  operator Integrations page (committed earlier) is now reachable.
- Capability gating: capabilities map + route-guard + jwt helpers + the
  apps/plan/entitlements routes and supporting tenants/session changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:09:24 +10:00
jules
06490865d3 Add operator Integrations page (integration registry console)
The operator surface for the integration registry: manage platform/pooled
external-API credentials across every scope and inspect cross-tenant usage
(metadata only — secrets are write-only). Talks to arcadia-llm-gateway's
/api/v1/integrations* endpoints via a gateway-pointed ArcadiaClient.

- gateway.ts: second ArcadiaClient at VITE_LLM_GATEWAY_URL, reusing the
  arcadia-app JWT (the gateway validates it via the shared Guardian secret;
  CORS already allows *.sky-ai.com + localhost — no proxy).
- lib/arcadia/integrations.ts: operator API client (any-scope create, scope
  filter, cross-tenant usage). Pure functions over an injected client —
  extraction-ready to share with arcadia-console.
- routes/integrations.tsx: scope filter + per-card scope badge, create
  platform/pooled credentials, credentials/usage, Test (surfaces the
  expiry/budget gate), enable toggle, delete.

The route/nav/capability wiring (routes.ts, app-shell, capabilities.ts) lands
with the in-flight capability framework, not here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:14:13 +10:00
jules
a299900021 organizations: admin surface for tenant orgs
New /organizations route under Tenancy. Lists every org in the current
tenant (via GET /api/v1/admin/organizations), with per-row Manage
members and Settings dialogs.

- Members dialog: invite by email, add restricted sub-user, change role,
  transfer ownership, remove member (owner removal honors the org's
  on_owner_removal policy server-side)
- Settings dialog: edit name, status (active/frozen/pending_deletion),
  and on_owner_removal policy
- app/lib/arcadia/organizations.ts: typed client for the new endpoints
- Nav entry added under Tenancy group

Tenant admins bypass per-org membership checks via the backend's
OrganizationContext plug, so the per-org REST endpoints work for any
org in the tenant without an explicit /admin/* surface.
2026-05-15 19:50:48 +10:00
jules
a74550d73f shell+ai: pristine-style nav groups, mobile fixes for /ai
Sidenav (app-shell.tsx):
- Each NavGroup now carries an icon (Building2 / Database / Plug /
  MessageSquare / Eye / Sparkles) rendered on the LEFT of the group
  header, with the chevron moved to the RIGHT. Header typography
  switched to caption + uppercase + tracking-wider muted, matching
  pristine-ui's main-branch app-shell. Same change applied to the
  mobile sheet's group headers.

/ai mobile fixes (ai.tsx):
- Composer container honors iOS safe-area inset
  (pb-[max(0.75rem,env(safe-area-inset-bottom))]) so the input clears
  the home indicator and stays above the soft keyboard.
- Composer toolbar wraps on narrow viewports (flex-wrap + gap-y-1)
  so the agent / model / reasoning / voice chips don't clip.
- Empty-state card uses px-4 sm:px-8 instead of hard px-8.
- MessageRow's 56px turn-number gutter collapses below sm: prose
  flows full-width on phone, two-column layout returns at sm+.

/ai desktop centering:
- Console wrapper opts out of AppShell's [&>*:first-child]:lg:pr-72
  (the page-header clearance for the floating top-right pill) via
  lg:!pr-0. The /ai surface has no top-right page-header controls,
  so the inherited padding was shifting the chat column ~144px left
  of the visible viewport center.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:08:36 +10:00
jules
a286b9cdce aifirst: lift context/agents/tools runtime to lib-aifirst-ui
The mechanism (context surface registry, persona storage + hooks, tool
parser/dispatcher) is now generic and lives in @crema/aifirst-ui/{context,
agents,tools}. This template keeps only the arcadia-shaped configuration:

- agents.ts — owns DEFAULT_AGENTS + legacy/retired migration sets, calls
  configureAgents() at module load, re-exports the runtime
- admin-tools.ts — keeps the 19 arcadia tool definitions, binds the
  runtime via createToolRuntime(TOOLS), re-exports the bound functions
- admin-context.ts — deleted; 18 routes now import directly from
  @crema/aifirst-ui/context

Routes that import from ~/lib/agents and ~/lib/admin-tools are unchanged
(wrapper modules preserve the existing import surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:18:48 +10:00
jules
c968ac0735 avatar: render immediately + survive reload
The variant pipeline is async, so right after upload all four URLs in
profile.avatar_urls are still null. The first wiring attempt called
pickAvatarUrl() which returned null, and nothing visible changed even
though the upload + PATCH succeeded.

Fixes:
- pickAvatarUrl: use the actual backend keys (small/medium/large/
  original — there's no "thumbnail").
- After upload, when no variant URL is ready, fetch the raw object
  via /api/v1/digital_objects/:id/content as a blob URL for immediate
  display. Persist that URL to localStorage so the appbar's
  useProfile() picks it up via the storage event.
- ProfileBootstrap: detect stale blob: URLs cached from previous
  sessions, clear them, and refetch a fresh blob URL when variants
  still aren't ready. Eventually the persistent variant URLs land
  and overwrite.
- Force-remount AvatarImage via key={src} in the profile page and
  appbar — base-ui's Avatar.Image keeps internal load state that
  doesn't always reset on src change.
- Diagnostic logs in fetchDigitalObjectAsBlobUrl + the upload flow
  to make next debug round one step easier (kept; cheap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:33:14 +10:00
jules
2ab183596c profile: bootstrap avatar URL on app boot
The appbar's <Avatar> reads avatarUrl from the localStorage profile
mirror. Without a global fetcher, that mirror only got populated when
the user navigated to /profile, so a fresh browser session showed
initials in the appbar until then.

- ProfileBootstrap component runs in root.tsx alongside
  LlmConfigBootstrap. On mount and on session change, fetches the
  arcadia profile and caches the resolved avatar URL.
- profile.tsx loadAccount now also persists the URL into localStorage
  on initial fetch (was in-memory only) so it survives reloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:56:40 +10:00
jules
ffe3fc0473 profile: drop unused title/signature/defaultAgentId fields
These were aspirational placeholders — stored to localStorage but never
read by anything. Removed from the form, types, and persistence layer.
Local profile is now just the avatar URL mirror, which the appbar reads
before the server profile fetch resolves on mount.

Preferences card renamed to "Avatar" since that's all that's left.
Re-add server-backed if/when something actually consumes them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:54:24 +10:00
jules
f6a92118da profile: wire bio/phone/location/timezone to arcadia
Adds a "Profile" card backed by /api/v1/profile (PATCH) for the four
public-profile fields arcadia already had columns for. Bio moved out of
local prefs (the server one supersedes); local prefs keeps only title,
signature, defaultAgentId, and the avatar URL mirror.

Save/revert mirror the existing Account card's pattern. The new fields
get arcadia validation + audit logging for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:49:34 +10:00
jules
c2730e3c77 profile: real avatar upload + storage form fix
- Add a digital-objects client (uploadFile: open session → PUT to
  presigned URL → complete) and a profile client (getProfile,
  updateProfile, pickAvatarUrl variant resolver).
- Wire profile.tsx avatar upload to use the real flow: validate
  image+size, upload to digital_objects tagged "avatar", PATCH
  /api/v1/profile with avatar_digital_object_id, mirror the resolved
  URL into local prefs so the existing <AvatarImage> binding keeps
  working. Show Uploading… state and an inline error banner. Clear
  detaches via avatar_digital_object_id: null.
- Fix the storage form sending the wrong field name for the local
  backend — arcadia's StorageConfig changeset requires `base_path`,
  not `path`. The 422 was silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:02:52 +10:00
jules
725540617b shell: collapsible nav groups + mobile-friendly settings
- Reorganize sidenav into collapsible groups (Tenancy, Data,
  Integrations, Communications, Observability, AI & Search) with
  Overview/Settings pinned at top/bottom. Group open/close persists in
  localStorage; the group containing the active route auto-opens.
  Icon-only collapsed rail flattens to a single icon column. Sub-items
  inside groups drop their per-item icons and indent under the header.
- Fix mobile sheet scroll — the nav couldn't reach items past viewport
  height. SheetContent is now flex-col h-svh, header shrink-0, nav
  flex-1 min-h-0 overflow-y-auto.
- Settings page mobile fixes: section nav wraps instead of horizontal
  scroll, top padding clears the floating actions pill, LLM config
  card header and rows stack on narrow widths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:39:02 +10:00
jules
5b0281574e llm: auto-load active config from arcadia on app boot
Adds <LlmConfigBootstrap /> in root.tsx that, when localStorage has no
active LLM settings, fetches enabled configurations from arcadia and
seeds the active settings (provider/model/baseURL/secretName + reasoning
effort) from the preferred row. Idempotent and silent on auth failure;
retries on session change.

Selection: prefer metadata.default === true, otherwise first enabled row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:41:48 +10:00
jules
d1469059d8 assistant: teach the agent about Search admin
Bring the LLM agent's prompts and tools current with the new /search
section and arcadia-search admin sidecar:

- New tools in admin-tools.ts:
  - list_search_corpora: enumerate tenants + corpora with build status,
    so the agent can pick a real corpus instead of guessing.
  - rebuild_search_corpus(tenant, corpus): isWrite=true, surfaces a
    confirm card. Use after uploads or when results look stale.
- search_kb description updated: names docs / operator-tools / files
  explicitly, and points at list_search_corpora when unsure.
- ARCADIA_KNOWLEDGE: adds search-corpus terminology, /search route,
  and a one-liner pointer to the three new tools.
- assistant.tsx UI_CONTROL_PREFACE: nav-search added, full Search
  page action catalog (search-refresh / -restart / -new-tenant /
  -new-corpus, corpora-search, per-row corpus-{t}-{c}-{rebuild,edit,
  delete,actions}, tenant-{id}-delete, dialog form fields). Recipe
  for the manual rebuild path, plus a note steering the agent to
  the rebuild_search_corpus tool by default.
- search.tsx publishes a "search" surface to admin-context with
  tenants + corpora summary, so the agent gets live state without
  needing a tool call when /search is mounted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:17:12 +10:00
jules
eb7bc62d14 search: add Search section calling arcadia-search admin sidecar
New /search route manages tenants and corpora on the arcadia-search
box via its privileged /admin/* surface (default :7801) — KPI tiles,
flat tenant×corpus table with Rebuild / Edit config / Delete
actions, New tenant / New corpus dialogs, and a Restart service
button. New app/lib/search-admin.ts wraps the bearer-token fetch.

Configured by VITE_ARCADIA_SEARCH_ADMIN_URL +
VITE_ARCADIA_SEARCH_ADMIN_TOKEN; the route renders a warning banner
when the token is unset. Token ships in the client bundle — fine for
this internal tool, called out in CLAUDE.md and the source comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:26:34 +10:00
jules
20c592dfa7 admin: completeness + UI consistency pass
Arcadia wiring:
- home: real Overview dashboard (tenants/users/audit/health probe) replacing the inherited Vibespace welcome tiles; skeleton loaders, refresh button, registers admin context
- profile: split into Account (synced via getUser/updateUser of session user) and local Preferences; updateSessionUser keeps the appbar in sync after edits
- session: drop unused signIn mock, add updateSessionUser, refresh tests
- profile schema: drop redundant Profile.name/email (session is the source of truth)
- routes: delete orphaned resources route + lib

Auth flows that previously 404'd:
- /signup, /login/forgot, /login/reset, /login/2fa wired via @crema/arcadia-auth-ui
- shared AuthShell + AuthBrand wrapper

Assistant tools (admin-tools.ts):
- +10 tools: deactivate_tenant, set_user_status, delete_user, list_memberships, list_roles, revoke_api_key, create_user, update_user, assign_role, remove_role
- list_memberships gains user_id filter for "tenants this user belongs to" queries
- search_kb / read_chunk: new token resolution (window override → VITE_ARCADIA_SEARCH_TOKEN service token → operator session JWT → "dev"); on 401/403 emit a tailored hint based on which token was used

UI consistency:
- new PageHeader component
- AppShell.title was unrendered — dropped; first-child padding on #main-content keeps the floating actions pill from colliding with header content
- removed dead "Sign in required" fallback cards from 14 routes (AppShell already redirects)
- stripped p-6 from outer wrappers across 14 routes (was double-padding under AppShell's own p-6)
- migrated home + tenants to PageHeader

arcadia-search ergonomics:
- scripts/mint-search-token.mjs + `npm run mint:search-token` mints HS512 JWT with required tenant_id claim, upserts VITE_ARCADIA_SEARCH_TOKEN into .env.local
- README/.env document the new VITE_ARCADIA_SEARCH_URL / VITE_ARCADIA_SEARCH_TOKEN knobs
- .env.local now gitignored

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:37:31 +10:00
jules
444516e900 docs: RAG.md — combined story for the two RAG surfaces
The browser RAG (@crema/lexical-rag-ui) and the server RAG
(arcadia-search) coexist in this app, and the agent picks between
them via tool descriptions. The two libs each have their own README,
but neither covers the picking decision or how they relate. This doc
fills that gap.

- At-a-glance table: lib, engine, runtime location, corpus size,
  update cadence, auth, agent tool, what it's best for.
- The system-prompt snippet that drives tool picking, with notes on
  observed DeepSeek V3 behavior.
- Per-surface deep dive (build path, tools, storage, limits, why it
  exists).
- Why both run by default (always-on fallback, A/B regression).
- Decision checklist for adding new corpora.
- "What lives where" cheat sheet pointing at the right file in the
  right repo for common tasks.

Cross-references arcadia-search's README.md, MULTI_TENANT.md, and
ARCADIA_INTEGRATION.md so a reader landing here can navigate to the
right rabbit hole.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:15:09 +10:00
jules
628691d2df ai: shake-out fixes from first end-to-end search_kb run
Three problems surfaced when driving search_kb / read_chunk through a
real DeepSeek chat for the first time:

1. vite.config.ts: minisearch wasn't in `aliasedDeps`, so when
   @crema/lexical-rag-ui imports it, Vite couldn't resolve the bare
   specifier from the sibling lib's location. Added it next to the
   other shared deps. tsconfig paths alone is not enough — the Vite
   alias is what propagates resolution to sibling-lib code.

2. ai.tsx reindexKB: was using `toast.show?.()` which doesn't exist on
   useToast()'s API. Optional chaining silently no-op'd, so the button
   click ran the fetch but produced zero UI feedback (success or
   failure). Switched to the actual API: toast.info / toast.success /
   toast.error. Added a console.error in the catch arm so the
   underlying exception is visible in DevTools when something does go
   wrong.

3. ai.tsx MAX_TOOL_ITERATIONS: cap was 3, which is too tight for
   agentic search→read→search loops on real questions. Bumped to 6.
   More importantly, when the cap IS reached, the runner now
   synthesises tool-error messages for each pending tool_call and
   continues the chat — instead of silently dropping them, which left
   the conversation with an assistant.tool_calls turn but no matching
   tool messages. DeepSeek (and the OpenAI spec) reject that
   conversation with 400 ("insufficient tool messages following
   tool_calls"), poisoning the thread. Now the model gets a clean
   "max iterations reached" signal and produces a final answer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:18:18 +10:00
jules
f5189305c7 ai: wire arcadia-search backend (search_kb + read_chunk + reindex button)
Adds the agent-facing surface for the new Tantivy lexical search service
(arcadia-search). Sits alongside the existing search_docs (browser
MiniSearch) — agent picks based on tool description.

- admin-tools.ts: new search_kb(query, corpus, limit?, tags?) and
  read_chunk(chunk_id, corpus) tools. KB_BASE_URL honors
  window.__ARCADIA_SEARCH_URL runtime override + VITE_ARCADIA_SEARCH_URL
  build env, defaults to localhost:7800. Token resolved per-call from
  sessionStorage.arcadia_access_token (matching lib-arcadia-client's
  storage convention) with "dev" fallback for unauthenticated dev.
- assistant.tsx: system-prompt section telling the agent when to pick
  search_docs (browser, bundled) vs search_kb (server, dynamic +
  expandable via read_chunk).
- ai.tsx: reindexKB() helper + "reindex kb (docs)" button on the empty
  state, next to the existing block-preview button. Toasts on
  start/success/failure. Wired with data-action="kb-reindex-docs" so
  the agent can also trigger via the command bus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:41:13 +10:00
jules
49a9b019fc ai: migrate docs-search to @crema/lexical-rag-ui
Replace the in-app docs-search.ts and build-docs-index.mjs with the
new sibling lib (@crema/lexical-rag-ui). Wire-up only — same index
shape, same tool response shape, same MiniSearch config, so the agent
sees no behavior change.

- tsconfig + app.css: wire the lib; alias minisearch to consumer's
  node_modules so sibling-lib resolution works.
- admin-tools.ts: createRAGClient("/docs-index.json"), keep search_docs
  tool's response shape unchanged (collapse tags[] back to category).
- ai.tsx: define DocHit locally — it's the tool-response shape, no
  longer the lib's internal type.
- scripts/build-docs-index.mjs: thin wrapper that injects MiniSearch
  and calls buildIndex. Per-app sources list and tags live here.
- package.json: add minisearch dep + build:docs script + prebuild hook.
- .gitignore: don't commit the generated /public/docs-index.json.

Delete: app/lib/docs-search.ts (was untracked; its logic moved to lib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:34:55 +10:00
jules
9cbe921db7 ai: rich-output blocks via lazy-fetched typed-fence protocol
Assistant replies can now emit typed fenced blocks that render as
@crema/*-ui components inline at their position in the reply.

- message-body.tsx: segmented rendering — alternating prose chunks and
  block dispatch (was: all blocks appended at end). Renderers for kpi,
  table, chart-bar/-line/-donut/-spark, code, diff, flowchart, orgchart,
  steps, checklist, welcome, hint, plus the legacy card kinds.
- block-schemas.ts: single source of truth — BLOCK_INDEX (one-line
  purpose per kind, always in prompt) + SCHEMAS (full JSON shape +
  example, fetched on demand).
- admin-tools.ts: new get_block_schema(kind) tool the model calls once
  per kind per thread to fetch the exact schema. Keeps the always-on
  prompt small (~110 tokens vs ~400 inline).
- assistant.tsx: replaces the inline schema dump with the generated
  thin index.
- ai.tsx: empty-state preview button injects a synthetic assistant
  message exercising every block, for renderer/theme smoke-testing.
- console.css + ai.tsx: shrink ATLAS headline so it doesn't slip under
  the composer with the added preview button.
- tsconfig.json + app.css: wire lib-data-ui, lib-code-ui, lib-diagram-ui,
  lib-onboarding-ui as siblings.

Adding a new block kind = add the lib paths, add a renderer case, add
a schema entry. No prompt edits required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:47:36 +10:00
jules
cdb96499be fix(deepseek): drop /v1 from probe URL
Matches the arcadia-app providers map update — direct-mode "Test
connection" was probing https://api.deepseek.com/v1/models which 404s
on DeepSeek's new endpoint. Now probes /models at the host root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:33:07 +10:00
jules
50afbd7686 login: always render in dark mode regardless of stored preference
The login page is the operator's entry point — it should look the
same every time, not flip between light and dark depending on what
the previous session left in localStorage.

Adds the `dark` class to the login wrapper div instead of
documentElement, so:
- Skyrise's .dark tokens cascade into all descendants (CSS vars defined
  under .dark apply to the subtree).
- After sign-in and navigation, the user's saved light/dark preference
  takes back over for the rest of the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:22:33 +10:00
jules
169acf3cdd fix(ai): import useMemo (missing since hand-off note refactor)
The handoffNote useMemo I added in the agent-history work referenced
useMemo without importing it. Vite was happy at compile time (lazy
binding) but the page crashes at first paint with "Can't find
variable: useMemo".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:18:37 +10:00
jules
c640721c8e ai: composer chip inherits active config's reasoning default
Pulls the reasoning storage out of ai.tsx and into the shared
llm-configs.ts helpers so Settings → LLM and the /ai composer
coordinate via one localStorage key (crema.ai.reasoning):

- loadActiveReasoning / saveActiveReasoning: read/write helpers.
- subscribeActiveReasoning: dispatches a CustomEvent on writes
  (same-tab) plus a storage-event listener (cross-tab), so the
  chip updates live when the operator stars a different config in
  another tab or in the settings panel.

Wiring:
- Settings panel onMakeActive() now also calls
  saveActiveReasoning(c.reasoning_effort ?? "off"). Starring a
  config seeds the chip with that config's default.
- /ai chip useEffect subscribes to changes; a star in Settings
  while /ai is open flips the chip in real time.
- resetAndClear no longer wipes reasoningEffort. Clearing the
  conversation shouldn't silently undo the operator's stated
  intent for thinking-mode (which is bound to their active config,
  not to the conversation).

Net behaviour:
- Star a config with reasoning_effort=medium → chip on /ai shows
  THINK MEDIUM next time you visit (or immediately if /ai is open).
- Cycle the chip while on /ai → just an override for the current
  conversation, not back-propagated to the saved config.
- Edit the config in Settings to change its default → propagates to
  the chip on next star (intentional — direct edits don't auto-
  re-activate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:18:06 +10:00
jules
c379ebc37a ai: per-config reasoning_effort + composer THINK chip
Two layers for thinking-mode control:

1. Per-config default (Settings → LLM)
   New "Reasoning effort" Select in the Add/Edit dialog with
   off/low/medium/high/max + a budget hint per option (~2k, ~8k,
   ~24k, ~64k thinking tokens). Saved row meta line surfaces the
   level inline so it's visible without opening the editor.

2. Per-message override (composer chip)
   New ReasoningChip next to the model picker. Click cycles through
   the same five levels. Hidden chrome when off (muted "think" pill);
   sodium-amber active style with the level label when set.

   Persisted to crema.ai.reasoning so a refresh keeps the operator's
   intent, wiped together with the conversation on Clear.

When sending, withReasoning() merges reasoning_effort into the request
body as a top-level field. The proxy forwards it untouched to OpenAI /
DeepSeek (native field) and translates to Anthropic's thinking block
server-side.

reasoningEffortRef sidesteps a useCallback ordering issue —
regenerateLast/continueLast are declared before the state hook, so
they read the ref instead of a stale closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:15:13 +10:00
jules
20494d1620 ai: persist agent-history + per-message attribution to localStorage
Reloading the page mid-conversation used to keep the messages (via
LIVE_KEY) but lose the two agent-tracking maps, so:
  - row signatures snapped back to whoever was currently active
  - the next turn after reload didn't include the PRIOR HAND-OFF
    block, even though the transcript clearly had multiple personas

Both maps are now stored alongside the live snapshot:
  AGENTS_KEY        crema.ai.agent-history   set of Agent
  MSG_AGENTS_KEY    crema.ai.message-agents  index -> Agent

Stored as JSON arrays since Maps don't serialize. Hydrated on mount
via useState lazy initializer, persisted on every change via two
useEffects, cleared in lockstep with LIVE_KEY when the operator
hits Clear conversation.

Reload mid-thread now reads identically to the pre-reload state:
- atlas» turns 1-3 stay attributed to atlas
- pythia» turn 4 stays attributed to pythia
- next turn after reload still carries the hand-off note

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:04:55 +10:00
jules
e4ed05b815 ai: agent hand-off awareness across personas in one conversation
Two related fixes for the "switching agent mid-thread loses context"
issue:

1. LLM-context fix
   The system prompt now includes a PRIOR HAND-OFF block whenever the
   current conversation has been touched by more than one agent. It
   lists the prior personas (name + role) and tells the new agent:
   "Earlier turns were produced by other personas. Read them as
   context, but answer in your own voice as the current persona."
   Without this, switching from Atlas (Operator) to Pythia (Researcher)
   left Pythia answering as if she'd produced Atlas's prior turns.

   Tracked via two-trigger useEffect:
   - On agent change with messages already in the thread, the prior
     agent gets locked into history.
   - On stream finish, the current active agent gets added (it just
     produced a turn).
   Cleared with the conversation.

2. UI-attribution fix
   Each assistant turn now records which agent produced it
   (messageAgents map: index -> Agent). The row signature in
   MessageRow now reads that stamped agent rather than always echoing
   the currently-active one. Switching agents mid-thread no longer
   retroactively re-attributes prior responses.

Both maps are wiped by Clear conversation alongside the live snapshot
and initialLive ref, so a fresh thread starts truly fresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:03:08 +10:00
jules
a770faf6eb fix(ai): clear conversation actually clears
useChat exposes reset() which calls setMessages(opts.initialMessages),
and the AI page passes initialLive.current as initialMessages — a ref
captured once on mount from localStorage.

resetAndClear was calling reset() then clearLive(). Sequence problem:

  reset()       → setMessages(initialLive.current)  // populated old array
  clearLive()   → localStorage.removeItem(LIVE_KEY) // does nothing to memory

The ref still held the original messages, so reset re-seeded them and
the conversation appeared to "come back" the moment you typed anything
(or sometimes immediately, depending on render timing).

Fix: blank the ref, clear localStorage, and call setMessages([])
directly. reset() is no longer needed at this call site so it's been
dropped from the useChat destructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:59:32 +10:00
jules
2a68389121 button: support Radix-style asChild via base-ui render prop bridge
The project uses @base-ui/react, whose Button has no asChild prop —
just a `render` prop that takes a React element to merge into. About
14 call sites across the routes still use the Radix-shaped
`<Button asChild><Link to="…">…</Link></Button>` pattern, which until
now was producing nested-button DOM violations and asChild leaking as
a DOM attribute.

Bridges asChild → render inside the Button wrapper:

  <Button asChild><Link to="/login">Sign in</Link></Button>

…now renders as a single <a class="…btn classes…">Sign in</a> instead
of <button asChild><a>…</a></button>.

No call-site changes required; consumers keep the Radix ergonomic and
get correct DOM under the hood.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:57:53 +10:00
jules
7eb5093071 fix(ai): unbreak model dropdown — base-ui Trigger doesn't take asChild
The composer's model picker had <DropdownMenuTrigger asChild><button>...
which is the Radix Slot pattern. Project uses @base-ui/react where
Menu.Trigger has no asChild prop and renders its own <button>, so the
result was nested-button-inside-button (DOM-nesting violation) plus
asChild leaking as an unknown DOM attribute (React warning).

Dropped the inner <button> and put className/data-action straight on
the Trigger. Visual output identical, no more console errors.

This pattern is used by ~14 other routes (Button asChild + Link),
mostly behind sign-in-required states. They're broken too but rarely
fire — separate followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:43:00 +10:00
jules
066a16bb8b ai: scope console theme to content wrapper, fix font loading
Two bugs in the previous /ai redesign:

1. theme="console" on AppShell put the entire shell (sidebar, appbar,
   appbar dropdowns, the lot) inside [data-theme="console"], so the
   console palette + JetBrains Mono override leaked into the sidebar
   and made light mode look broken on /ai. Scoped now: the AppShell
   stays in skyrise (so light/dark toggle keeps working everywhere),
   and only the route content area gets data-theme="console" via an
   inner wrapper.

2. The Google Fonts @import inside console.css was being silently
   dropped because @import rules must precede all other rules in the
   final bundle, and skyrise's content lands first. Moved JetBrains
   Mono + Newsreader into app.css's top-level @import url() alongside
   the existing Inter/Instrument Sans/Geist Mono families.

Atmosphere ::before was also position: fixed, which painted the grain
overlay across the whole viewport (including the sidebar) regardless
of where data-theme lived. Now position: absolute on the wrapper, with
isolation: isolate to keep z-index local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:39:51 +10:00
jules
4f699bb90e ai: redesign /ai surface as Mission Console
Replaces the conventional chat aesthetic on /ai with a brutalist-mono
operator deck. The page now reads as a flight recorder — turn numbers
in the gutter, hairline rules, sodium-amber phosphor primary on
deep-ink ground, vim-style modeline at the foot.

Type system is the design's load-bearing element:
- JetBrains Mono for everything system-y (operator lines, signatures,
  modeline, session ids, tool calls)
- Newsreader serif for the agent's prose only — the synthesis voice
  literally lifts off the page in a different family from the machine
  voice. Operator and agent are typographically inseparable from their
  speaker.

Layout changes:
- Sticky session header with a giant base36 session id ("3K9P · A4C2")
  and a metadata strip showing agent, model, turn count, status. The
  status pill flips colour: AMBER on stream, ROSE on awaiting confirm,
  MINT on ready, MUTED on mock.
- Empty state is no longer the apologetic "How can I help you today?".
  It's "ATLAS. standing by." in oversize mono with the agent name in
  italic serif amber, a hairline divider, and a single one-liner
  instruction prefixed with ›. Lines stagger in via animation-delay.
- Operator turns: monospace, 14px, sodium-amber › prompt, no bubble.
  Hangs from a left gutter with T01/T02… turn number + UTC timestamp.
- Agent turns: serif, 17px/1.55, with a tiny mono signature underneath
  ("atlas» 03:14:08Z · recv"). Cyan accent column instead of amber.
- Composer: terminal frame (square, 1px border, focus ring is amber
  glow). Internal ›_ prompt mark in front of the textarea, mono input.
- Bottom modeline: utc clock + turn count + estimated tokens on the
  left, keyboard hints on the right. Streaming flips the right side
  to a pulsing phosphor bar + STREAM label.

Atmosphere details:
- 2px scanline overlay (very faint, 1.2% opacity)
- Corner phosphor blooms (amber top-right, cyan bottom-left)
- Inline SVG turbulence grain (3.5% opacity) over the whole theme
- Cursor blink animation on the prompt mark
- Consolas-tier ligatures on the mono via JetBrains Mono ss01/calt

All theming scoped via [data-theme="console"] — picks up automatically
because /ai's AppShell now passes theme="console". Other routes are
untouched. Tool-call cards from @crema/agent-ui inherit the palette
via overridden CSS variables (--card, --border, --primary, etc) plus
a [data-slot="tool-call-card"] override for the frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:32:22 +10:00
jules
c0eb85d2fe LLM configs: edit/delete buttons available for all rows
Previously gated to tenant-owned rows (tenant_id != null), which made
seed-from-catalog rows uneditable since they default to platform scope
on the backend. The backend doesn't enforce extra ownership rules on
update/delete either, so the gate was a UI overreach.

Buttons now appear on every row. Tooltips clarify when a row is a
platform default so the operator knows the change applies broadly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:59:56 +10:00
jules
b397bbcb9e LLM configs: vault secret picker (Select from api_key secrets)
Replaces the free-text secret name input with a Select populated from
/api/v1/admin/secrets, filtered to category=api_key + enabled. Each
option shows the secret name plus its description for context.

Includes "(none — keyless / local)" for lmstudio-style configs and a
"Type a name…" escape hatch for secrets that don't exist in the vault
yet (the proxy will fail loudly at request time if the name is wrong,
which is the right behaviour — better than silently saving a config
that can't authenticate).

Secrets are fetched once on panel load alongside configs/catalog/usage,
not per modal-open, so the dialog opens instantly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:49:47 +10:00
jules
5dfceeff94 Settings/LLM: unified panel with per-row active toggle, edit, spend
Reworks the LLM settings surface based on UX feedback. Drops the
separate "Active LLM (this session)" card — its functionality is now
inline on each saved config as a star toggle (writes the same
localStorage key the Assistant reads via @crema/llm-providers-ui's
saveSettings, so the existing assistant code picks the change up
without any plumbing).

Per-row controls now include:
- Star: make this config active for the current browser
- Switch: enable/disable server-side
- Pencil: edit (modal, not inline-expand)
- Trash: delete (with confirm)
- Spend (30d): cost + request count, sourced from
  /api/v1/ai/llm/usage/by-model and matched on (provider, model)

Other improvements:
- Add wizard moved to a Dialog modal instead of pushing the list
  around. Same form handles edit.
- Empty state: "Seed from catalog" button creates a curated starter
  set (GPT-4o mini/4o, Sonnet 4.6, Haiku 4.5, DeepSeek V4 Flash, LM
  Studio) so first-time operators don't face a blank panel.
- Catalog dropdown picks now auto-fill input/output costs as you
  switch models, so the rates always reflect the chosen model unless
  manually overridden.
- The lib's full settings card (system prompt, transport, context
  budget) is still reachable for advanced cases — collapsed into a
  <details> below the panel.

Adds llm-configs.ts: getUsageByModel + findSpend helper for the
per-row spend lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:47:45 +10:00
jules
bfe61c220a Default to dark mode and small text on first load
The bootstrap script previously fell back to the OS color-scheme
preference and left font-scale unset until the user picked one. For
admin work — dense tables, lots of small text, monitoring dashboards —
dark + sm is the better starting point and matches what most operators
end up choosing anyway.

Users who've already picked a theme/font keep their stored preference;
this only affects fresh sessions where localStorage has no value yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:41:54 +10:00
jules
8e07f4b9c0 Settings: real model dropdown with cost hints + Custom escape hatch
The previous datalist-on-input approach was fragile — Safari hid the
suggestions, and there was no visual cue that a dropdown existed.
Replace with a proper Select populated from the catalog. Each option
shows the per-1M-token rates inline so operators see cost while
choosing. "Custom…" switches to free-text for models the catalog
doesn't know about, with a "Catalog" button to flip back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:25:49 +10:00
jules
baf42c4cec Settings: server-side LLM configurations + 30d spend roll-up
Replaces the localStorage-only LLM settings with a persisted catalogue
backed by /api/v1/admin/llm-configurations. The Settings → LLM screen
now has two cards:

- "Saved configurations" — full CRUD against the server. Each row shows
  provider/model/secret/published per-1M-token costs. Add wizard
  auto-fills costs from the curated catalog. One-click "Import local"
  button promotes any pre-existing localStorage settings into a server
  row, then clears the local store.
- "Active LLM (this session)" — the existing LLMProvidersSettingsCard,
  scoped down to "what does the Assistant use right now" (still
  localStorage; per-operator).

Spend (30d) tile in the configurations card header reads
/api/v1/ai/llm/usage/summary and surfaces total cost / requests /
tokens. First visible cost roll-up in the admin UI.

New module app/lib/arcadia/llm-configs.ts: typed CRUD client,
catalog lookup, computeCostCents helper (mirrors the server's
LlmConfiguration.compute_cost_cents/3), and getUsageSummary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:06:29 +10:00
jules
29030c9e72 Wire health probes, host stats, and LLM proxy round-trip
Three things from the latest arcadia-app pull:

- health.ts: client for /api/v1/health{,/:service,/detailed,/host}.
  monitoring.tsx now reads real per-subsystem probe state instead of
  synthesizing it from indirect signals (rate limits, sessions, jobs).
- New Host tab on Monitoring with KPI tiles + per-core CPU bars,
  load-avg cards, memory + swap usage, and per-mount disk bars,
  backed by /api/v1/health/host.
- llm-proxy.ts: typed errors (secret_disabled, ip_not_allowed, etc.)
  and a probeProxy() that round-trips a 1-token chat. settings.tsx's
  "Test connection" in proxy mode now exercises the real endpoint
  instead of just confirming the adapter built. Contract doc flipped
  from "not yet implemented" to "implemented".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:05:22 +10:00
jules
0fcb9e40f1 Add Buckets, Monitoring, Memberships, Networking, SSO, Announcements, Status page
Full set of admin surfaces on top of /platform/* and /admin/* endpoints,
plus a migration of /assistant onto @crema/llm-providers-ui.

Buckets (/buckets):
  S3-level CRUD over /platform/buckets — list, create, delete (with the
  6-digit confirmation flow the backend enforces), per-bucket configure
  for versioning / CORS rules / policy JSON, plus an object browser
  with FileGrid/FileList from @crema/file-ui and presigned-URL reveal.
  Storage-config picker scopes the view to one credential at a time.

Monitoring (/monitoring):
  Live dashboard. Service health board derived from indirect signals
  (status-ui OverallStatus + ComponentRow). KPI tiles for sessions,
  jobs, audit. Tabs: background jobs (Donut + BarChart + retry recent),
  sessions (Sparkline of last 24h sign-ins), audit activity (BarChart
  of severity / top resource types), infrastructure (DO summary +
  WorldMapSvg coloured by droplet region + droplet list + Spaces),
  rate limits. 30s auto-refresh.

Memberships (/memberships):
  M:N glue between users and tenants over /admin/memberships. Add /
  edit / suspend / activate / remove with role multi-select.

Networking (/networking):
  Tabs over /platform/{firewalls,vpcs,domains,floating_ips}.
  Read/delete on firewalls, read on VPCs, full DNS-record CRUD, and
  inline assign/unassign for floating IPs.

SSO (/sso):
  /sso/identity-providers CRUD with PEM cert as write-only field, plus
  /sso/sessions list with destroy.

Announcements (/announcements):
  /admin/announcements CRUD. Platform-wide vs per-tenant audience,
  schedule windows, dismissible + active toggles.

Status page (/status-page):
  /admin/status-page/{components,incidents,subscribers}. Components
  CRUD, incidents with timeline + post-update + resolve flow,
  subscriber list. Public preview at the top using StatusBoard +
  IncidentTimeline from @crema/status-ui.

Assistant migration:
  /assistant now uses @crema/llm-providers-ui (provider catalog +
  vault key resolution) instead of ~/lib/llm-settings. Same async
  buildAdapter() flow used by /ai. The legacy lib file is now
  unreferenced and can be removed when ready.

New sibling libs wired (cloned from CremaUIStudio):
  lib-file-ui, lib-card-ui, lib-dashboard-ui, lib-chart-ui,
  lib-map-ui, lib-status-ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:55:46 +10:00
117 changed files with 23658 additions and 4815 deletions

9
.gitignore vendored
View File

@@ -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/

View File

@@ -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
View File

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

View File

@@ -1,6 +1,6 @@
# Arcadia Admin # 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)

View File

@@ -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;
}
}

View File

@@ -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

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

View File

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

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

@@ -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"

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

View File

@@ -0,0 +1,201 @@
import { useState } from "react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { TenantSection, Field } from "~/components/tenant-detail/section"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
import { updateBranding } from "~/lib/arcadia/tenants"
import type { TenantTabProps } from "~/routes/tenants.$id"
/**
* Tenant branding: logo/favicon URLs, the three brand colours, and a custom-CSS
* override. All fields are optional; clearing one and saving sends `null` so it
* clears server-side (the server accepts null and validates any colour it does
* get against a `#rrggbb` hex).
*/
export function BrandingTab({ tenant, reload }: TenantTabProps) {
const arcadia = useArcadiaClient()
const toast = useToast()
const b = tenant.branding
const [logoUrl, setLogoUrl] = useState(b.logo_url ?? "")
const [faviconUrl, setFaviconUrl] = useState(b.favicon_url ?? "")
const [primary, setPrimary] = useState(b.primary_color ?? "")
const [secondary, setSecondary] = useState(b.secondary_color ?? "")
const [accent, setAccent] = useState(b.accent_color ?? "")
const [customCss, setCustomCss] = useState(b.custom_css ?? "")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
const dirty =
logoUrl !== (b.logo_url ?? "") ||
faviconUrl !== (b.favicon_url ?? "") ||
primary !== (b.primary_color ?? "") ||
secondary !== (b.secondary_color ?? "") ||
accent !== (b.accent_color ?? "") ||
customCss !== (b.custom_css ?? "")
const save = async () => {
setSaving(true)
setError(null)
try {
await updateBranding(arcadia, tenant.id, {
logo_url: emptyToNull(logoUrl),
favicon_url: emptyToNull(faviconUrl),
primary_color: emptyToNull(primary),
secondary_color: emptyToNull(secondary),
accent_color: emptyToNull(accent),
custom_css: customCss.trim() === "" ? null : customCss,
})
await reload()
toast.success("Branding updated")
} catch (err) {
setError(err)
} finally {
setSaving(false)
}
}
return (
<TenantSection
title="Branding"
description="How this tenant's apps present themselves. Leave a field blank to fall back to the platform default; clearing a saved value removes it."
onSubmit={save}
saving={saving}
error={error}
errorContext="update branding"
dirty={dirty}
dataAction="tenant-detail-branding-save"
>
<Field
label="Logo URL"
htmlFor="branding-logo-url"
hint="Publicly reachable image URL shown in the tenant's app header."
>
<Input
id="branding-logo-url"
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://…"
data-action="tenant-detail-branding-logo-url"
/>
</Field>
<Field
label="Favicon URL"
htmlFor="branding-favicon-url"
hint="Publicly reachable .ico/.png used as the browser tab icon."
>
<Input
id="branding-favicon-url"
type="url"
value={faviconUrl}
onChange={(e) => setFaviconUrl(e.target.value)}
placeholder="https://…"
data-action="tenant-detail-branding-favicon-url"
/>
</Field>
<ColorField
label="Primary color"
id="branding-primary-color"
value={primary}
onChange={setPrimary}
dataAction="tenant-detail-branding-primary-color"
/>
<ColorField
label="Secondary color"
id="branding-secondary-color"
value={secondary}
onChange={setSecondary}
dataAction="tenant-detail-branding-secondary-color"
/>
<ColorField
label="Accent color"
id="branding-accent-color"
value={accent}
onChange={setAccent}
dataAction="tenant-detail-branding-accent-color"
/>
<Field
label="Custom CSS"
htmlFor="branding-custom-css"
hint="Injected into the tenant's apps. Applies verbatim — test before saving."
>
<Textarea
id="branding-custom-css"
value={customCss}
onChange={(e) => setCustomCss(e.target.value)}
rows={6}
spellCheck={false}
className="font-mono text-xs"
placeholder=":root { --brand: #5b21b6; }"
data-action="tenant-detail-branding-custom-css"
/>
</Field>
</TenantSection>
)
}
/**
* A hex-colour control: a native colour swatch and a text input kept in sync,
* plus a live preview chip. The text field holds the source of truth (may be
* empty or a partial/invalid hex mid-edit); the swatch falls back to black so
* the picker always has something to show.
*/
function ColorField({
label,
id,
value,
onChange,
dataAction,
}: {
label: string
id: string
value: string
onChange: (v: string) => void
dataAction: string
}) {
const trimmed = value.trim()
const valid = /^#[0-9a-fA-F]{6}$/.test(trimmed)
return (
<Field label={label} htmlFor={id} hint="Hex like #5b21b6. Clear to remove.">
<div className="flex items-center gap-2">
<Input
type="color"
aria-label={`${label} swatch`}
value={valid ? trimmed : "#000000"}
onChange={(e) => onChange(e.target.value)}
className="h-8 w-12 shrink-0 cursor-pointer p-1"
data-action={`${dataAction}-swatch`}
/>
<Input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="#000000"
className="font-mono"
data-action={dataAction}
/>
<span
aria-hidden="true"
title={valid ? trimmed : "No colour"}
className="size-8 shrink-0 rounded-lg border border-input"
style={{ background: valid ? trimmed : "transparent" }}
/>
</div>
</Field>
)
}
/** Trim to detect emptiness; a blank field clears server-side via null. */
function emptyToNull(s: string): string | null {
return s.trim() === "" ? null : s.trim()
}

View File

@@ -0,0 +1,364 @@
import { useCallback, useEffect, useState } from "react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { TenantSection, Field } from "~/components/tenant-detail/section"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Switch } from "~/components/ui/switch"
import { NativeSelect, NativeSelectOption } from "~/components/ui/native-select"
import {
EMAIL_PROVIDERS,
SMS_PROVIDERS,
deleteEmailConfig,
deleteSmsConfig,
getEmailConfig,
getSmsConfig,
testEmailConfig,
testSmsConfig,
upsertEmailConfig,
upsertSmsConfig,
type EmailConfigInput,
type SmsConfigInput,
} from "~/lib/arcadia/tenants"
import { errorMessage } from "~/lib/errors"
import type { TenantTabProps } from "~/routes/tenants.$id"
/**
* Email & SMS delivery. These are two independent configs — each loads, saves,
* tests, and deletes on its own — so the tab renders the same <DeliveryConfig>
* frame twice, parameterised by `kind`.
*/
export function DeliveryTab({ tenant, reload }: TenantTabProps) {
return (
<div className="flex flex-col gap-6">
<DeliveryConfig kind="email" tenant={tenant} reload={reload} />
<DeliveryConfig kind="sms" tenant={tenant} reload={reload} />
</div>
)
}
type Kind = "email" | "sms"
/** A credential input the operator can fill. Values are collected into the
* write-only `credentials` object and only sent when actually typed. */
type CredField = { key: string; label: string; type?: string }
function credentialFields(kind: Kind, provider: string): CredField[] {
if (kind === "email") {
if (provider === "smtp") {
return [
{ key: "host", label: "SMTP host" },
{ key: "port", label: "Port" },
{ key: "username", label: "Username" },
{ key: "password", label: "Password", type: "password" },
]
}
return [{ key: "api_key", label: "API key", type: "password" }]
}
// sms
if (provider === "twilio") {
return [
{ key: "account_sid", label: "Account SID" },
{ key: "auth_token", label: "Auth token", type: "password" },
]
}
if (provider === "vonage") {
return [
{ key: "api_key", label: "API key" },
{ key: "api_secret", label: "API secret", type: "password" },
]
}
return [{ key: "api_key", label: "API key", type: "password" }]
}
function DeliveryConfig({ tenant, kind }: TenantTabProps & { kind: Kind }) {
const arcadia = useArcadiaClient()
const toast = useToast()
const providers = kind === "email" ? EMAIL_PROVIDERS : SMS_PROVIDERS
const prefix = `tenant-detail-${kind}`
const label = kind === "email" ? "Email" : "SMS"
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [removing, setRemoving] = useState(false)
// Whether a config exists server-side (loaded non-null, or just saved). Gates
// "Send test" and "Remove", which are meaningless with nothing configured.
const [exists, setExists] = useState(false)
const [provider, setProvider] = useState<string>(providers[0])
const [enabled, setEnabled] = useState(false)
// email-only
const [fromEmail, setFromEmail] = useState("")
const [fromName, setFromName] = useState("")
const [replyTo, setReplyTo] = useState("")
// sms-only
const [fromNumber, setFromNumber] = useState("")
// write-only credentials, keyed by field
const [creds, setCreds] = useState<Record<string, string>>({})
const load = useCallback(async () => {
setError(null)
setLoading(true)
try {
if (kind === "email") {
const cfg = await getEmailConfig(arcadia, tenant.id)
if (cfg) {
setExists(true)
setProvider(cfg.provider)
setEnabled(cfg.enabled)
setFromEmail(cfg.from_email ?? "")
setFromName(cfg.from_name ?? "")
setReplyTo(cfg.reply_to ?? "")
} else {
setExists(false)
}
} else {
const cfg = await getSmsConfig(arcadia, tenant.id)
if (cfg) {
setExists(true)
setProvider(cfg.provider)
setEnabled(cfg.enabled)
setFromNumber(cfg.from_number ?? "")
} else {
setExists(false)
}
}
// Credentials are never returned — always start the write-only fields empty.
setCreds({})
} catch (err) {
// getEmailConfig/getSmsConfig already map 404 → null, so anything thrown
// here is a genuine load failure.
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, tenant.id, kind])
useEffect(() => {
load()
}, [load])
const save = async () => {
setSaving(true)
setError(null)
try {
// Only send credentials the operator actually typed, scoped to the
// current provider — otherwise we'd wipe stored creds with blanks, or
// leak a previous provider's fields.
const typedCreds: Record<string, string> = {}
for (const f of credentialFields(kind, provider)) {
const v = (creds[f.key] ?? "").trim()
if (v) typedCreds[f.key] = v
}
const hasCreds = Object.keys(typedCreds).length > 0
if (kind === "email") {
const input: EmailConfigInput = {
provider,
from_email: fromEmail,
from_name: fromName,
reply_to: replyTo,
enabled,
...(hasCreds ? { credentials: typedCreds } : {}),
}
await upsertEmailConfig(arcadia, tenant.id, input)
toast.success("Email settings saved")
} else {
const input: SmsConfigInput = {
provider,
from_number: fromNumber,
enabled,
...(hasCreds ? { credentials: typedCreds } : {}),
}
await upsertSmsConfig(arcadia, tenant.id, input)
toast.success("SMS settings saved")
}
await load()
} catch (err) {
setError(err)
} finally {
setSaving(false)
}
}
const runTest = async () => {
setTesting(true)
try {
const res =
kind === "email"
? await testEmailConfig(arcadia, tenant.id)
: await testSmsConfig(arcadia, tenant.id)
if (res.ok) toast.success(res.message || `${label} test succeeded`)
else toast.error(res.message || `${label} test failed`)
} catch (err) {
toast.error(errorMessage(err, `test ${label.toLowerCase()} delivery`))
} finally {
setTesting(false)
}
}
const remove = async () => {
setRemoving(true)
try {
if (kind === "email") await deleteEmailConfig(arcadia, tenant.id)
else await deleteSmsConfig(arcadia, tenant.id)
toast.success(`${label} configuration removed`)
await load()
} catch (err) {
toast.error(errorMessage(err, `remove ${label.toLowerCase()} configuration`))
} finally {
setRemoving(false)
}
}
const fields = credentialFields(kind, provider)
return (
<TenantSection
title={`${label} delivery`}
description={
kind === "email"
? "Outbound email for this tenant. Credentials are write-only and never shown again."
: "Outbound SMS for this tenant. Credentials are write-only and never shown again."
}
onSubmit={save}
saving={saving || loading}
error={error}
errorContext={`save ${label.toLowerCase()} settings`}
saveLabel={kind === "email" ? "Save email settings" : "Save SMS settings"}
dataAction={`${prefix}-save`}
footerExtra={
exists ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={runTest}
disabled={testing || saving}
data-action={`${prefix}-test`}
>
{testing ? "Testing…" : "Send test"}
</Button>
<Button
type="button"
variant="ghost"
className="text-destructive"
onClick={remove}
disabled={removing || saving}
data-action={`${prefix}-remove`}
>
{removing ? "Removing…" : "Remove configuration"}
</Button>
</div>
) : null
}
>
<Field label="Provider" htmlFor={`${prefix}-provider`}>
<NativeSelect
id={`${prefix}-provider`}
className="w-full"
value={provider}
onChange={(e) => setProvider(e.target.value)}
data-action={`${prefix}-provider`}
>
{providers.map((p) => (
<NativeSelectOption key={p} value={p}>
{p}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
{kind === "email" ? (
<>
<Field label="From email" htmlFor={`${prefix}-from-email`}>
<Input
id={`${prefix}-from-email`}
type="email"
value={fromEmail}
onChange={(e) => setFromEmail(e.target.value)}
placeholder="no-reply@example.com"
data-action={`${prefix}-from-email`}
/>
</Field>
<Field label="From name" htmlFor={`${prefix}-from-name`}>
<Input
id={`${prefix}-from-name`}
value={fromName}
onChange={(e) => setFromName(e.target.value)}
placeholder="Example App"
data-action={`${prefix}-from-name`}
/>
</Field>
<Field label="Reply-to" htmlFor={`${prefix}-reply-to`}>
<Input
id={`${prefix}-reply-to`}
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
placeholder="support@example.com"
data-action={`${prefix}-reply-to`}
/>
</Field>
</>
) : (
<Field
label="From number"
htmlFor={`${prefix}-from-number`}
hint="The sender number or short code, in E.164 (e.g. +15551234567)."
>
<Input
id={`${prefix}-from-number`}
value={fromNumber}
onChange={(e) => setFromNumber(e.target.value)}
placeholder="+15551234567"
data-action={`${prefix}-from-number`}
/>
</Field>
)}
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Enabled</div>
<p className="text-xs text-muted-foreground">
When off, {label.toLowerCase()} is configured but not sent.
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={(v) => setEnabled(v)}
data-action={`${prefix}-enabled`}
/>
</div>
<div className="flex flex-col gap-4 rounded-lg border border-input bg-muted/30 p-4">
<div>
<div className="text-sm font-medium">Credentials</div>
<p className="text-xs text-muted-foreground">
{exists
? "Write-only and never shown again. Leave blank to keep the stored credentials; fill in to replace them."
: "Write-only and never shown again."}
</p>
</div>
{fields.map((f) => (
<Field key={f.key} label={f.label} htmlFor={`${prefix}-cred-${f.key}`}>
<Input
id={`${prefix}-cred-${f.key}`}
type={f.type ?? "text"}
autoComplete="off"
value={creds[f.key] ?? ""}
onChange={(e) => setCreds((c) => ({ ...c, [f.key]: e.target.value }))}
placeholder={exists ? "•••••• (unchanged)" : ""}
data-action={`${prefix}-cred-${f.key}`}
/>
</Field>
))}
</div>
</TenantSection>
)
}

View File

@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from "react"
import { RefreshCw, RotateCcw } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { BadgeCell } from "@crema/table-ui"
import { EmptyState } from "@crema/feedback-ui"
import type { TenantTabProps } from "~/routes/tenants.$id"
import { DataState } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Switch } from "~/components/ui/switch"
import {
clearFeatureFlag,
listFeatureFlags,
setFeatureFlag,
type TenantFeatureFlag,
} from "~/lib/arcadia/tenants"
/**
* Per-tenant feature-flag overrides. The list is every platform-defined flag
* with this tenant's effective value; a flag is either inherited from the
* platform default ("default") or pinned for this tenant ("override"). Toggling
* a row pins it; "Revert" drops the override so it follows the default again.
* You can't add arbitrary keys here — a flag has to exist at the platform level
* before a tenant can override it.
*/
export function FeatureFlagsTab({ tenant }: TenantTabProps) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [flags, setFlags] = useState<TenantFeatureFlag[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const [busy, setBusy] = useState<string | null>(null)
const load = useCallback(async () => {
setError(null)
setLoading(true)
try {
setFlags(await listFeatureFlags(arcadia, tenant.id))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, tenant.id])
useEffect(() => {
load()
}, [load])
const toggle = async (flag: TenantFeatureFlag, next: boolean) => {
setBusy(flag.key)
// Optimistic: reflect the pin immediately, roll back on failure.
setFlags((prev) =>
prev.map((f) => (f.key === flag.key ? { ...f, enabled: next, source: "override" } : f)),
)
try {
await setFeatureFlag(arcadia, tenant.id, flag.key, next)
toast.success(`${next ? "Enabled" : "Disabled"} ${flag.key} for ${tenant.name}`)
await load()
} catch (err) {
setFlags((prev) => prev.map((f) => (f.key === flag.key ? flag : f)))
toast.error(errorMessage(err, `override ${flag.key}`))
} finally {
setBusy(null)
}
}
const revert = async (flag: TenantFeatureFlag) => {
setBusy(flag.key)
try {
await clearFeatureFlag(arcadia, tenant.id, flag.key)
toast.success(`${flag.key} follows the platform default again`)
await load()
} catch (err) {
toast.error(errorMessage(err, `revert ${flag.key}`))
} finally {
setBusy(null)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>Feature flags</CardTitle>
<CardDescription>
Override a platform flag for this tenant. Un-overridden flags follow
the platform default.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={load}
disabled={loading}
data-action="tenant-detail-flags-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</CardHeader>
<CardContent className="p-0">
<DataState
loading={loading}
error={error}
isEmpty={flags.length === 0}
onRetry={load}
loadingLabel="Loading feature flags…"
empty={
<EmptyState
title="No platform feature flags defined"
description="Flags are defined at the platform level; once they exist, you can pin any of them on or off for this tenant here."
className="py-12"
/>
}
>
<ul className="divide-y">
{flags.map((flag) => (
<li key={flag.key} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<code className="font-mono text-sm">{flag.key}</code>
<BadgeCell
label={flag.source === "override" ? "override" : "default"}
tone={flag.source === "override" ? "info" : "default"}
/>
</div>
{flag.description ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{flag.description}
</p>
) : null}
</div>
{flag.source === "override" ? (
<Button
variant="ghost"
size="sm"
onClick={() => revert(flag)}
disabled={busy === flag.key}
data-action={`tenant-detail-flags-revert-${flag.key}`}
title="Revert to the platform default"
>
<RotateCcw className="size-4" />
Revert
</Button>
) : null}
<Switch
checked={flag.enabled}
onCheckedChange={(v) => toggle(flag, v)}
disabled={busy === flag.key}
data-action={`tenant-detail-flags-toggle-${flag.key}`}
aria-label={`Override ${flag.key}`}
/>
</li>
))}
</ul>
</DataState>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,534 @@
import { useCallback, useEffect, useState } from "react"
import { Clock, ListChecks, Plus, RefreshCw, Trash2 } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { BadgeCell, type BadgeTone } from "@crema/table-ui"
import type { TenantTabProps } from "~/routes/tenants.$id"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { Switch } from "~/components/ui/switch"
import {
createInboundWebhook,
deleteInboundWebhook,
listInboundWebhookDeliveries,
listInboundWebhooks,
updateInboundWebhook,
type InboundWebhookDelivery,
type InboundWebhookSource,
} from "~/lib/arcadia/tenants"
/**
* Inbound webhook sources: external providers whose signed callbacks this
* tenant accepts and verifies. Each source can be toggled, deleted, and has a
* recent-deliveries log.
*/
export function InboundWebhooksTab({ tenant }: TenantTabProps) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [sources, setSources] = useState<InboundWebhookSource[]>([])
const [loading, setLoading] = useState(true)
// Raw thrown value; DataState normalises it. A failed load must never render
// as "no sources yet".
const [error, setError] = useState<unknown>(null)
const [busy, setBusy] = useState<Set<string>>(new Set())
const [addOpen, setAddOpen] = useState(false)
const [deliveriesFor, setDeliveriesFor] = useState<InboundWebhookSource | null>(null)
const [pendingDelete, setPendingDelete] = useState<InboundWebhookSource | null>(null)
const load = useCallback(async () => {
setError(null)
setLoading(true)
try {
setSources(await listInboundWebhooks(arcadia, tenant.id))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, tenant.id])
useEffect(() => {
load()
}, [load])
const markBusy = (id: string, on: boolean) =>
setBusy((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
const toggle = useCallback(
async (src: InboundWebhookSource, next: boolean) => {
markBusy(src.id, true)
setSources((prev) =>
prev.map((s) => (s.id === src.id ? { ...s, enabled: next } : s)),
)
try {
const updated = await updateInboundWebhook(arcadia, tenant.id, src.id, {
enabled: next,
})
setSources((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
toast.success(`${next ? "Enabled" : "Disabled"} ${src.name}`)
} catch (err) {
setSources((prev) =>
prev.map((s) => (s.id === src.id ? { ...s, enabled: !next } : s)),
)
toast.error(errorMessage(err, `update ${src.name}`))
} finally {
markBusy(src.id, false)
}
},
[arcadia, tenant.id, toast],
)
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle>Inbound webhooks</CardTitle>
<CardDescription>
External providers whose signed callbacks {tenant.name} accepts and
verifies.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={load}
disabled={loading}
data-action="tenant-detail-webhooks-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => setAddOpen(true)}
data-action="tenant-detail-webhooks-add"
>
<Plus className="size-4" />
Add source
</Button>
</div>
</CardHeader>
<CardContent className="relative p-0">
<DataState
loading={loading}
error={error}
isEmpty={sources.length === 0}
onRetry={load}
loadingLabel="Loading webhook sources…"
empty={
<EmptyState
title="No inbound webhook sources."
description="Add one to accept and verify signed callbacks from an external provider."
className="py-12"
/>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-6 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Provider</th>
<th className="px-4 py-2 font-medium">Enabled</th>
<th className="px-6 py-2" />
</tr>
</thead>
<tbody className="divide-y">
{sources.map((src) => {
const isBusy = busy.has(src.id)
return (
<tr key={src.id}>
<td className="px-6 py-3 font-medium">{src.name}</td>
<td className="px-4 py-3 text-muted-foreground">
{src.provider || "—"}
</td>
<td className="px-4 py-3">
<Switch
checked={src.enabled}
disabled={isBusy}
onCheckedChange={(next) => toggle(src, next)}
data-action={`tenant-detail-webhooks-toggle-${src.id}`}
/>
</td>
<td className="px-6 py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setDeliveriesFor(src)}
data-action={`tenant-detail-webhooks-deliveries-${src.id}`}
>
<ListChecks className="size-4" />
Deliveries
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={isBusy}
onClick={() => setPendingDelete(src)}
aria-label={`Delete ${src.name}`}
data-action={`tenant-detail-webhooks-delete-${src.id}`}
>
<Trash2 className="size-4" />
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</DataState>
</CardContent>
<AddWebhookDialog
open={addOpen}
tenantId={tenant.id}
onClose={() => setAddOpen(false)}
onSaved={async (msg) => {
setAddOpen(false)
await load()
toast.success(msg)
}}
/>
<DeliveriesDialog
source={deliveriesFor}
tenantId={tenant.id}
onClose={() => setDeliveriesFor(null)}
/>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
title="Delete webhook source?"
description={
pendingDelete
? `${pendingDelete.name} will be removed. Incoming callbacks from this provider will be rejected, and its delivery history is discarded.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const target = pendingDelete
try {
await deleteInboundWebhook(arcadia, tenant.id, target.id)
setPendingDelete(null)
await load()
toast.success(`Deleted ${target.name}`)
} catch (err) {
setPendingDelete(null)
toast.error(errorMessage(err, `delete ${target.name}`))
}
}}
/>
</Card>
)
}
function AddWebhookDialog({
open,
tenantId,
onClose,
onSaved,
}: {
open: boolean
tenantId: string
onClose: () => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [name, setName] = useState("")
const [provider, setProvider] = useState("")
const [signingSecret, setSigningSecret] = useState("")
const [signatureHeader, setSignatureHeader] = useState("")
const [signatureAlgorithm, setSignatureAlgorithm] = useState("hmac_sha256")
const [enabled, setEnabled] = useState(true)
const [saving, setSaving] = useState(false)
// Failed submit renders here, above the buttons, with the form intact.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) return
setName("")
setProvider("")
setSigningSecret("")
setSignatureHeader("")
setSignatureAlgorithm("hmac_sha256")
setEnabled(true)
setError(null)
}, [open])
const submit = async () => {
const trimmedName = name.trim()
setError(null)
setSaving(true)
try {
if (!trimmedName) throw new Error("A name is required.")
await createInboundWebhook(arcadia, tenantId, {
name: trimmedName,
provider: provider.trim() || undefined,
signing_secret: signingSecret || undefined,
signature_header: signatureHeader.trim() || undefined,
signature_algorithm: signatureAlgorithm.trim() || undefined,
enabled,
})
await onSaved(`Added webhook source ${trimmedName}`)
} catch (err) {
setError(err)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add webhook source</DialogTitle>
<DialogDescription>
Register an external provider whose signed callbacks this tenant will
accept and verify.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-name">Name</Label>
<Input
id="webhook-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Stripe events"
autoFocus
data-action="tenant-detail-webhooks-form-name"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-provider">Provider</Label>
<Input
id="webhook-provider"
value={provider}
onChange={(e) => setProvider(e.target.value)}
placeholder="stripe"
data-action="tenant-detail-webhooks-form-provider"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-secret">Signing secret</Label>
<Input
id="webhook-secret"
type="password"
value={signingSecret}
onChange={(e) => setSigningSecret(e.target.value)}
placeholder="whsec_…"
data-action="tenant-detail-webhooks-form-secret"
/>
<p className="text-xs text-muted-foreground">
Stored encrypted; used to verify incoming signatures. Write-only.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-header">Signature header</Label>
<Input
id="webhook-header"
value={signatureHeader}
onChange={(e) => setSignatureHeader(e.target.value)}
placeholder="X-Signature"
className="font-mono"
data-action="tenant-detail-webhooks-form-header"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="webhook-algorithm">Signature algorithm</Label>
<Input
id="webhook-algorithm"
value={signatureAlgorithm}
onChange={(e) => setSignatureAlgorithm(e.target.value)}
placeholder="hmac_sha256"
className="font-mono"
data-action="tenant-detail-webhooks-form-algorithm"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Enabled</div>
<div className="text-xs text-muted-foreground">
Disabled sources reject incoming callbacks.
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
data-action="tenant-detail-webhooks-form-enabled"
/>
</div>
</div>
{error ? <DialogError error={error} context="add the source" /> : null}
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="tenant-detail-webhooks-form-cancel"
>
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || !name.trim()}
data-action="tenant-detail-webhooks-form-save"
>
{saving ? <RefreshCw className="size-4 animate-spin" /> : null}
Add source
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function DeliveriesDialog({
source,
tenantId,
onClose,
}: {
source: InboundWebhookSource | null
tenantId: string
onClose: () => void
}) {
const arcadia = useArcadiaClient()
const [deliveries, setDeliveries] = useState<InboundWebhookDelivery[]>([])
const [loading, setLoading] = useState(true)
// A deliveries load that failed is not a source with no history. Own error
// state, rendered in place of the list.
const [error, setError] = useState<unknown>(null)
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
if (!source) return
let mounted = true
setLoading(true)
setError(null)
listInboundWebhookDeliveries(arcadia, tenantId, source.id)
.then((rows) => {
if (mounted) setDeliveries(rows)
})
.catch((err) => {
if (mounted) setError(err)
})
.finally(() => {
if (mounted) setLoading(false)
})
return () => {
mounted = false
}
}, [arcadia, tenantId, source, reloadKey])
return (
<Dialog open={source !== null} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Deliveries{source ? `${source.name}` : ""}</DialogTitle>
<DialogDescription>
Recent signed callbacks received from this provider.
</DialogDescription>
</DialogHeader>
<DataState
loading={loading}
error={error}
isEmpty={deliveries.length === 0}
onRetry={() => setReloadKey((n) => n + 1)}
loadingLabel="Loading deliveries…"
empty={
<p className="py-8 text-center text-sm text-muted-foreground">
No deliveries yet. Callbacks this source receives and verifies will
appear here.
</p>
}
>
<ul className="flex max-h-[50vh] flex-col divide-y overflow-y-auto rounded-md border">
{deliveries.map((d) => {
const when = d.received_at ?? d.inserted_at
return (
<li
key={d.id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<BadgeCell
label={d.status ?? "unknown"}
tone={deliveryTone(d.status)}
/>
<span className="text-xs text-muted-foreground">
<Clock className="mr-1 inline size-3" />
{when ? new Date(when).toLocaleString() : "—"}
</span>
</li>
)
})}
</ul>
</DataState>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
data-action="tenant-detail-webhooks-deliveries-close"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function deliveryTone(status?: string): BadgeTone {
const s = (status ?? "").toLowerCase()
if (["ok", "success", "delivered", "verified", "processed"].includes(s))
return "success"
if (["failed", "error", "rejected", "invalid"].includes(s)) return "danger"
if (["pending", "retrying", "queued"].includes(s)) return "warning"
return "default"
}

View File

@@ -0,0 +1,389 @@
import { useCallback, useEffect, useState } from "react"
import { Plus, RefreshCw, Trash2 } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { BadgeCell } from "@crema/table-ui"
import type { TenantTabProps } from "~/routes/tenants.$id"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { NativeSelect, NativeSelectOption } from "~/components/ui/native-select"
import { Switch } from "~/components/ui/switch"
import {
createIpRule,
deleteIpRule,
listIpRules,
updateIpRule,
type IpRule,
} from "~/lib/arcadia/tenants"
/**
* Per-tenant IP allow/deny rules. With no allow rules every IP is permitted;
* an allow rule locks access to known ranges, a deny rule blocks specific ones.
*/
export function IpRulesTab({ tenant }: TenantTabProps) {
const arcadia = useArcadiaClient()
const toast = useToast()
const [rules, setRules] = useState<IpRule[]>([])
const [loading, setLoading] = useState(true)
// Raw thrown value; DataState normalises it. A failed load must never render
// as "no rules — every IP allowed".
const [error, setError] = useState<unknown>(null)
const [busy, setBusy] = useState<Set<string>>(new Set())
const [addOpen, setAddOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<IpRule | null>(null)
const load = useCallback(async () => {
setError(null)
setLoading(true)
try {
setRules(await listIpRules(arcadia, tenant.id))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [arcadia, tenant.id])
useEffect(() => {
load()
}, [load])
const markBusy = (id: string, on: boolean) =>
setBusy((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
const toggle = useCallback(
async (rule: IpRule, next: boolean) => {
markBusy(rule.id, true)
setRules((prev) =>
prev.map((r) => (r.id === rule.id ? { ...r, enabled: next } : r)),
)
try {
const updated = await updateIpRule(arcadia, tenant.id, rule.id, {
enabled: next,
})
setRules((prev) => prev.map((r) => (r.id === updated.id ? updated : r)))
toast.success(
`${next ? "Enabled" : "Disabled"} ${rule.rule_type} rule ${rule.cidr}`,
)
} catch (err) {
setRules((prev) =>
prev.map((r) => (r.id === rule.id ? { ...r, enabled: !next } : r)),
)
toast.error(errorMessage(err, `update rule ${rule.cidr}`))
} finally {
markBusy(rule.id, false)
}
},
[arcadia, tenant.id, toast],
)
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle>IP rules</CardTitle>
<CardDescription>
Control which client IPs may reach {tenant.name}. With no allow
rules, every IP is allowed.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={load}
disabled={loading}
data-action="tenant-detail-ip-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => setAddOpen(true)}
data-action="tenant-detail-ip-add"
>
<Plus className="size-4" />
Add rule
</Button>
</div>
</CardHeader>
<CardContent className="relative p-0">
<DataState
loading={loading}
error={error}
isEmpty={rules.length === 0}
onRetry={load}
loadingLabel="Loading IP rules…"
empty={
<EmptyState
title="No IP rules."
description="With no allow rules, every IP is allowed; add a deny rule to block specific ranges, or an allow rule to lock access down to known ranges."
className="py-12"
/>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-6 py-2 font-medium">CIDR</th>
<th className="px-4 py-2 font-medium">Type</th>
<th className="px-4 py-2 font-medium">Description</th>
<th className="px-4 py-2 font-medium">Enabled</th>
<th className="px-6 py-2" />
</tr>
</thead>
<tbody className="divide-y">
{rules.map((rule) => {
const isBusy = busy.has(rule.id)
return (
<tr key={rule.id}>
<td className="px-6 py-3">
<code className="font-mono text-xs">{rule.cidr}</code>
</td>
<td className="px-4 py-3">
<BadgeCell
label={rule.rule_type}
tone={rule.rule_type === "deny" ? "danger" : "success"}
/>
</td>
<td className="px-4 py-3 text-muted-foreground">
{rule.description || "—"}
</td>
<td className="px-4 py-3">
<Switch
checked={rule.enabled}
disabled={isBusy}
onCheckedChange={(next) => toggle(rule, next)}
data-action={`tenant-detail-ip-toggle-${rule.id}`}
/>
</td>
<td className="px-6 py-3 text-right">
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={isBusy}
onClick={() => setPendingDelete(rule)}
aria-label={`Delete rule ${rule.cidr}`}
data-action={`tenant-detail-ip-delete-${rule.id}`}
>
<Trash2 className="size-4" />
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</DataState>
</CardContent>
<AddIpRuleDialog
open={addOpen}
tenantId={tenant.id}
onClose={() => setAddOpen(false)}
onSaved={async (msg) => {
setAddOpen(false)
await load()
toast.success(msg)
}}
/>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
title="Delete IP rule?"
description={
pendingDelete
? `The ${pendingDelete.rule_type} rule for ${pendingDelete.cidr} will be removed. If this was the last allow rule, every IP becomes allowed again.`
: ""
}
confirmLabel="Delete"
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const target = pendingDelete
try {
await deleteIpRule(arcadia, tenant.id, target.id)
setPendingDelete(null)
await load()
toast.success(`Deleted ${target.rule_type} rule ${target.cidr}`)
} catch (err) {
setPendingDelete(null)
toast.error(errorMessage(err, `delete rule ${target.cidr}`))
}
}}
/>
</Card>
)
}
function AddIpRuleDialog({
open,
tenantId,
onClose,
onSaved,
}: {
open: boolean
tenantId: string
onClose: () => void
onSaved: (message: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const [cidr, setCidr] = useState("")
const [ruleType, setRuleType] = useState<"allow" | "deny">("allow")
const [description, setDescription] = useState("")
const [enabled, setEnabled] = useState(true)
const [saving, setSaving] = useState(false)
// Failed submit renders here, above the buttons, form kept intact.
const [error, setError] = useState<unknown>(null)
useEffect(() => {
if (!open) return
setCidr("")
setRuleType("allow")
setDescription("")
setEnabled(true)
setError(null)
}, [open])
const submit = async () => {
const trimmed = cidr.trim()
setError(null)
setSaving(true)
try {
if (!trimmed) throw new Error("A CIDR range is required.")
await createIpRule(arcadia, tenantId, {
cidr: trimmed,
rule_type: ruleType,
description: description.trim() || undefined,
enabled,
})
await onSaved(`Added ${ruleType} rule ${trimmed}`)
} catch (err) {
setError(err)
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add IP rule</DialogTitle>
<DialogDescription>
Allow rules lock access to known ranges; deny rules block specific
ones.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-cidr">CIDR range</Label>
<Input
id="ip-cidr"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
placeholder="203.0.113.0/24"
className="font-mono"
autoFocus
data-action="tenant-detail-ip-form-cidr"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-type">Rule type</Label>
<NativeSelect
id="ip-type"
className="w-full"
value={ruleType}
onChange={(e) => setRuleType(e.target.value as "allow" | "deny")}
data-action="tenant-detail-ip-form-type"
>
<NativeSelectOption value="allow">Allow</NativeSelectOption>
<NativeSelectOption value="deny">Deny</NativeSelectOption>
</NativeSelect>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-description">Description</Label>
<Input
id="ip-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Office VPN egress"
data-action="tenant-detail-ip-form-description"
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<div>
<div className="text-sm font-medium">Enabled</div>
<div className="text-xs text-muted-foreground">
Disabled rules are kept but not enforced.
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
data-action="tenant-detail-ip-form-enabled"
/>
</div>
</div>
{error ? <DialogError error={error} context="add the rule" /> : null}
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={saving}
data-action="tenant-detail-ip-form-cancel"
>
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || !cidr.trim()}
data-action="tenant-detail-ip-form-save"
>
{saving ? <RefreshCw className="size-4 animate-spin" /> : null}
Add rule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,114 @@
import { useState } from "react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { TenantSection, Field } from "~/components/tenant-detail/section"
import { NativeSelect, NativeSelectOption } from "~/components/ui/native-select"
import {
updateLocalization,
TENANT_LOCALES,
TENANT_CURRENCIES,
COMMON_TIMEZONES,
} from "~/lib/arcadia/tenants"
import type { TenantTabProps } from "~/routes/tenants.$id"
export function LocalizationTab({ tenant, reload }: TenantTabProps) {
const arcadia = useArcadiaClient()
const toast = useToast()
const current = tenant.localization
const [locale, setLocale] = useState(current?.locale ?? "")
const [timezone, setTimezone] = useState(current?.timezone ?? "")
const [currency, setCurrency] = useState(current?.currency ?? "")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
// The tenant's real timezone might be an IANA name outside our curated list —
// prepend it so the select shows the current value instead of silently
// snapping to the first option.
const timezoneOptions =
current?.timezone && !COMMON_TIMEZONES.includes(current.timezone)
? [current.timezone, ...COMMON_TIMEZONES]
: COMMON_TIMEZONES
const dirty =
locale !== (current?.locale ?? "") ||
timezone !== (current?.timezone ?? "") ||
currency !== (current?.currency ?? "")
const save = async () => {
setSaving(true)
setError(null)
try {
await updateLocalization(arcadia, tenant.id, { locale, timezone, currency })
await reload()
toast.success("Localization updated")
} catch (err) {
setError(err)
} finally {
setSaving(false)
}
}
return (
<TenantSection
title="Localization"
description="Default locale, timezone, and currency for this tenant."
onSubmit={save}
saving={saving}
error={error}
errorContext="update localization"
dirty={dirty}
dataAction="tenant-detail-localization-save"
>
<Field label="Locale" htmlFor="tenant-detail-localization-locale">
<NativeSelect
id="tenant-detail-localization-locale"
className="w-full"
value={locale}
onChange={(e) => setLocale(e.target.value)}
data-action="tenant-detail-localization-locale"
>
{TENANT_LOCALES.map((l) => (
<NativeSelectOption key={l} value={l}>
{l}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field label="Currency" htmlFor="tenant-detail-localization-currency">
<NativeSelect
id="tenant-detail-localization-currency"
className="w-full"
value={currency}
onChange={(e) => setCurrency(e.target.value)}
data-action="tenant-detail-localization-currency"
>
{TENANT_CURRENCIES.map((c) => (
<NativeSelectOption key={c} value={c}>
{c}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field label="Timezone" htmlFor="tenant-detail-localization-timezone">
<NativeSelect
id="tenant-detail-localization-timezone"
className="w-full"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
data-action="tenant-detail-localization-timezone"
>
{timezoneOptions.map((tz) => (
<NativeSelectOption key={tz} value={tz}>
{tz}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</TenantSection>
)
}

View File

@@ -0,0 +1,279 @@
import { useEffect, useState } from "react"
import { Plus, X } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import { TenantSection, Field } from "~/components/tenant-detail/section"
import { DataState } from "~/components/data-state"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { NativeSelect, NativeSelectOption } from "~/components/ui/native-select"
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"
import {
listUsage,
updatePlan,
TENANT_PLANS,
type UsageRow,
} from "~/lib/arcadia/tenants"
import type { TenantTabProps } from "~/routes/tenants.$id"
// One editable limit row. `locked` marks a key that already existed on the
// tenant (its name is fixed — you edit the value or remove the row); new rows
// added with "Add limit" have an editable key.
type LimitRow = { id: number; key: string; value: string; locked: boolean }
let rowSeq = 0
const nextRowId = () => ++rowSeq
function rowsFromLimits(limits: Record<string, unknown> | undefined): LimitRow[] {
return Object.entries(limits ?? {}).map(([key, value]) => ({
id: nextRowId(),
key,
value: String(value ?? ""),
locked: true,
}))
}
// Collapse the editable rows into the Record<string, number> the server wants.
// Blank keys are dropped; non-numeric values coerce to 0 (Number("") === 0 too).
function buildLimits(rows: LimitRow[]): Record<string, number> {
const out: Record<string, number> = {}
for (const row of rows) {
const key = row.key.trim()
if (!key) continue
out[key] = Number(row.value)
}
return out
}
function normaliseLimits(limits: Record<string, unknown> | undefined): Record<string, number> {
const out: Record<string, number> = {}
for (const [key, value] of Object.entries(limits ?? {})) out[key] = Number(value)
return out
}
export function PlanTab({ tenant, reload }: TenantTabProps) {
const arcadia = useArcadiaClient()
const toast = useToast()
const currentPlan = tenant.plan?.name ?? ""
const [plan, setPlan] = useState(currentPlan || TENANT_PLANS[0])
const [rows, setRows] = useState<LimitRow[]>(() => rowsFromLimits(tenant.plan?.limits))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<unknown>(null)
const originalLimits = normaliseLimits(tenant.plan?.limits)
const dirty =
plan !== currentPlan ||
JSON.stringify(buildLimits(rows)) !== JSON.stringify(originalLimits)
const setRowKey = (id: number, key: string) =>
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, key } : r)))
const setRowValue = (id: number, value: string) =>
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, value } : r)))
const removeRow = (id: number) => setRows((rs) => rs.filter((r) => r.id !== id))
const addRow = () =>
setRows((rs) => [...rs, { id: nextRowId(), key: "", value: "", locked: false }])
const save = async () => {
setSaving(true)
setError(null)
try {
await updatePlan(arcadia, tenant.id, {
plan,
plan_limits: buildLimits(rows),
})
await reload()
toast.success("Plan updated")
} catch (err) {
setError(err)
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<TenantSection
title="Plan"
description="The tenant's plan and its quota limits are saved together. The plan slug must be one the server recognises."
onSubmit={save}
saving={saving}
error={error}
errorContext="update the plan"
dirty={dirty}
dataAction="tenant-detail-plan-save"
>
<Field label="Plan" htmlFor="tenant-detail-plan-select">
<NativeSelect
id="tenant-detail-plan-select"
className="w-full"
value={plan}
onChange={(e) => setPlan(e.target.value)}
data-action="tenant-detail-plan-select"
>
{TENANT_PLANS.map((p) => (
<NativeSelectOption key={p} value={p}>
{p}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field
label="Plan limits"
hint="Numeric quotas attached to this plan (e.g. users, storage_gb). Saved with the plan above."
>
<div className="flex flex-col gap-2">
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">No limits set.</p>
) : (
rows.map((row) => (
<div key={row.id} className="flex items-center gap-2">
<Input
className="flex-1"
placeholder="key"
value={row.key}
readOnly={row.locked}
disabled={row.locked}
onChange={(e) => setRowKey(row.id, e.target.value)}
aria-label="Limit key"
data-action="tenant-detail-plan-limit-key"
/>
<Input
className="w-32"
type="number"
inputMode="numeric"
placeholder="value"
value={row.value}
onChange={(e) => setRowValue(row.id, e.target.value)}
aria-label="Limit value"
data-action="tenant-detail-plan-limit-value"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => removeRow(row.id)}
aria-label="Remove limit"
data-action="tenant-detail-plan-limit-remove"
>
<X className="size-4" />
</Button>
</div>
))
)}
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
data-action="tenant-detail-plan-limit-add"
>
<Plus className="size-4" />
Add limit
</Button>
</div>
</div>
</Field>
</TenantSection>
<PlanUsage tenantId={tenant.id} />
</div>
)
}
// Read-only usage-vs-quota panel. Loads independently so a metering failure
// never blanks the plan form above it.
function PlanUsage({ tenantId }: { tenantId: string }) {
const arcadia = useArcadiaClient()
const [rows, setRows] = useState<UsageRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const load = async () => {
setError(null)
setLoading(true)
try {
setRows(await listUsage(arcadia, tenantId))
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantId])
return (
<Card>
<CardHeader>
<CardTitle>Usage</CardTitle>
</CardHeader>
<CardContent>
<DataState
loading={loading}
error={error}
isEmpty={rows.length === 0}
onRetry={load}
loadingLabel="Loading usage…"
empty={
<div className="py-6 text-center text-sm text-muted-foreground">
No metered usage yet.
</div>
}
>
<ul className="flex flex-col divide-y divide-border">
{rows.map((row) => (
<li key={row.category} className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0">
<div className="font-medium capitalize">{row.category}</div>
<div className="grid gap-1 text-sm text-muted-foreground sm:grid-cols-3">
<UsageMeter
label="This minute"
used={row.usage.minute}
limit={row.quota.enabled ? row.quota.calls_per_minute : null}
/>
<UsageMeter
label="Today"
used={row.usage.day}
limit={row.quota.enabled ? row.quota.calls_per_day : null}
/>
<UsageMeter
label="This month"
used={row.usage.month}
limit={row.quota.enabled ? row.quota.calls_per_month : null}
/>
</div>
</li>
))}
</ul>
</DataState>
</CardContent>
</Card>
)
}
function UsageMeter({
label,
used,
limit,
}: {
label: string
used: number
limit: number | null
}) {
return (
<div>
<span className="text-xs uppercase tracking-wider">{label}</span>
<div className="text-foreground">
{used} {limit != null ? `/ ${limit}` : "/ no limit"}
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import type { ReactNode } from "react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Button } from "~/components/ui/button"
import { DialogError } from "~/components/data-state"
/**
* The shared frame every tenant-detail settings tab renders through: a titled
* card whose footer holds a Save button and, on failure, an inline error in the
* same place (never a page-level banner). Keeps the eight tabs visually and
* behaviourally identical so the operator learns the surface once.
*/
export function TenantSection({
title,
description,
children,
onSubmit,
saving,
error,
errorContext,
saveLabel = "Save changes",
dirty = true,
dataAction,
footerExtra,
}: {
title: string
description?: ReactNode
children: ReactNode
onSubmit: () => void
saving: boolean
error: unknown
errorContext: string
saveLabel?: string
/** Disable Save until something changed. Defaults to always-enabled. */
dirty?: boolean
dataAction: string
/** Rendered to the left of Save (e.g. a "Send test" or "Remove" button). */
footerExtra?: ReactNode
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="flex flex-col gap-5"
>
{children}
{error ? <DialogError error={error} context={errorContext} /> : null}
<div className="flex items-center justify-between gap-2 pt-1">
<div>{footerExtra}</div>
<Button type="submit" disabled={saving || !dirty} data-action={dataAction}>
{saving ? "Saving…" : saveLabel}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}
/** A labelled form row — label, control, and an optional hint underneath. */
export function Field({
label,
htmlFor,
hint,
children,
}: {
label: string
htmlFor?: string
hint?: ReactNode
children: ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={htmlFor} className="text-sm font-medium">
{label}
</label>
{children}
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
)
}

View File

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

View File

@@ -141,14 +141,24 @@ function AlertDialogDescription({
) )
} }
// Confirming closes the dialog, exactly as cancelling does. This was a plain
// <Button>, so the dialog stayed open after you confirmed — every call site
// already assumed otherwise, and the ones that looked fine only did because
// the thing they acted on (a row, a card) unmounted underneath them. The
// handler still runs: Base UI merges its close handler with the caller's
// onClick, so async work carries on behind the closed dialog.
function AlertDialogAction({ 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}
/> />
) )

View File

@@ -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 }

View File

@@ -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"

View File

@@ -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])
}

View File

@@ -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 secondsminutes 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 }
}

View File

@@ -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[] {
return readFromStorage()
}
export function saveAgents(next: Agent[]) {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function resetAgents() {
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[] { export {
if (!cached) cached = readFromStorage() composeSystemPrompt,
return cached loadActiveAgentId,
} loadAgents,
newAgentId,
function getServerSnapshot(): Agent[] { resetAgents,
return DEFAULT_AGENTS saveActiveAgentId,
} saveAgents,
useAgents,
export function useAgents(): Agent[] { type Agent,
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } from "@crema/aifirst-ui/agents"
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)}`
}

View File

@@ -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.

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

View File

@@ -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

View File

@@ -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

View File

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

217
app/lib/arcadia/buckets.ts Normal file
View 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>,
})
}

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

View File

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

94
app/lib/arcadia/health.ts Normal file
View 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)
}

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

View File

@@ -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

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

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

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

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

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

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

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

View File

@@ -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

View File

@@ -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"

View File

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

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

View File

@@ -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[]> = {

View File

@@ -6,7 +6,7 @@
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)` // gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
// and drop these manual types. // and drop these manual types.
import type { ArcadiaClient } from "@crema/arcadia-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
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
View 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 (26 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 26 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 ~80120px 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
View 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
View File

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

38
app/lib/gateway.ts Normal file
View 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
View 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
}
}

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

View File

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

View File

@@ -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
}

View File

@@ -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

View File

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

View File

@@ -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
View 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"),
}

View File

@@ -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")
}) })
}) })

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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

View File

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

View File

@@ -1,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

View 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
View File

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

1533
app/routes/buckets.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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
View 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 &amp; 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">Couldnt 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"

View File

@@ -1,205 +0,0 @@
import { useState } from "react"
import { BookOpen, Copy, Download, Trash2, MessagesSquare } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import { pageTitle } from "~/lib/page-meta"
import {
deleteLibraryItem,
useLibrary,
type LibraryItem,
} from "~/lib/library"
export const meta = () => pageTitle("Library")
export default function LibraryRoute() {
const items = useLibrary()
const [query, setQuery] = useState("")
const [openId, setOpenId] = useState<string | null>(null)
const filtered = items.filter((it) => {
if (!query.trim()) return true
const q = query.toLowerCase()
return (
it.title.toLowerCase().includes(q) ||
it.content.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
})
const open = items.find((x) => x.id === openId) ?? null
return (
<AppShell 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
View 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>
)
}

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

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

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

876
app/routes/networking.tsx Normal file
View 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"

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

View File

@@ -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"

View File

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

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import { import {
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
View 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/&lt;id&gt;/
</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"

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router"
import { import {
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"

View File

@@ -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
View 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