Compare commits
42 Commits
7ba415d78e
...
fix/audit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9147a06c00 | ||
| 6ab9b730f5 | |||
|
|
4b817b85ff | ||
|
|
06490865d3 | ||
|
|
a299900021 | ||
|
|
a74550d73f | ||
|
|
a286b9cdce | ||
|
|
c968ac0735 | ||
|
|
2ab183596c | ||
|
|
ffe3fc0473 | ||
|
|
f6a92118da | ||
|
|
c2730e3c77 | ||
|
|
725540617b | ||
|
|
5b0281574e | ||
|
|
d1469059d8 | ||
|
|
eb7bc62d14 | ||
|
|
20c592dfa7 | ||
|
|
444516e900 | ||
|
|
628691d2df | ||
|
|
f5189305c7 | ||
|
|
49a9b019fc | ||
|
|
9cbe921db7 | ||
|
|
cdb96499be | ||
|
|
50afbd7686 | ||
|
|
169acf3cdd | ||
|
|
c640721c8e | ||
|
|
c379ebc37a | ||
|
|
20494d1620 | ||
|
|
e4ed05b815 | ||
|
|
a770faf6eb | ||
|
|
2a68389121 | ||
|
|
7eb5093071 | ||
|
|
066a16bb8b | ||
|
|
4f699bb90e | ||
|
|
c0eb85d2fe | ||
|
|
b397bbcb9e | ||
|
|
5dfceeff94 | ||
|
|
bfe61c220a | ||
|
|
8e07f4b9c0 | ||
|
|
baf42c4cec | ||
|
|
29030c9e72 | ||
|
|
0fcb9e40f1 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
@@ -9,3 +11,7 @@
|
||||
.demo.log
|
||||
.demo.pid
|
||||
.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
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ To use it for real:
|
||||
|---|---|---|
|
||||
| `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_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
|
||||
|
||||
|
||||
18
app/app.css
18
app/app.css
@@ -1,8 +1,12 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Geist+Mono:wght@100..900&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Geist+Mono:wght@100..900&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap");
|
||||
|
||||
/* Active theme — must be first so its @import url() font directives resolve
|
||||
* to the top of the output. Themes are self-contained: tokens + fonts. */
|
||||
@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 "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@@ -19,6 +23,18 @@
|
||||
@source "../../lib-auth-ui/src";
|
||||
@source "../../lib-agent-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 */
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
// Renders an assistant message: GFM markdown for prose, custom ```card```
|
||||
// blocks rendered as rich UI (status pills, tenant cards, KPIs), pills for
|
||||
// command-bus action blocks, and tool-result cards (role: "tool").
|
||||
// Renders an assistant message: GFM markdown for prose, plus typed fenced
|
||||
// code blocks rendered as rich UI from `@crema/*-ui` libs (charts, tables,
|
||||
// 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 ReactMarkdown from "react-markdown"
|
||||
@@ -9,9 +23,33 @@ import { Sparkles, Wrench } from "lucide-react"
|
||||
|
||||
import { extractActionBlocks } from "@crema/action-bus"
|
||||
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 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 = {
|
||||
content: string
|
||||
@@ -19,33 +57,231 @@ export type MessageBodyProps = {
|
||||
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 =
|
||||
| { kind: "pill"; status: string; label?: string }
|
||||
| { kind: "stat"; label: string; value: string | number; tone?: string }
|
||||
| { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||
| { kind: string; [k: string]: unknown }
|
||||
|
||||
function parseCardBlocks(content: string): { blocks: CardSpec[]; stripped: string } {
|
||||
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 {
|
||||
function CardBlock({ spec }: { spec: CardSpec }) {
|
||||
switch (spec.kind) {
|
||||
case "pill": {
|
||||
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-border bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}
|
||||
>
|
||||
<span className={`my-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}>
|
||||
{s.label ?? s.status}
|
||||
</span>
|
||||
)
|
||||
@@ -68,19 +302,14 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
case "stat": {
|
||||
const s = spec as { kind: "stat"; label: string; value: string | number }
|
||||
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="font-semibold tabular-nums">{s.value}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
case "callout": {
|
||||
const s = spec as {
|
||||
kind: "callout"
|
||||
title?: string
|
||||
tone?: "info" | "warning" | "danger" | "success"
|
||||
body?: string
|
||||
}
|
||||
const s = spec as { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||
const tone = s.tone ?? "info"
|
||||
const palette: Record<string, string> = {
|
||||
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",
|
||||
}
|
||||
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.body && <div className="text-muted-foreground">{s.body}</div>}
|
||||
</div>
|
||||
@@ -97,24 +326,67 @@ function renderCard(spec: CardSpec): ReactNode {
|
||||
}
|
||||
default:
|
||||
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)}
|
||||
</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) {
|
||||
const { prose, actionCount, cardBlocks } = useMemo(() => {
|
||||
const { segments, actionCount } = useMemo(() => {
|
||||
const blocks = extractActionBlocks(content)
|
||||
const cleaned = stripToolCallTags(content)
|
||||
.replace(ACTION_BLOCK_RE, "")
|
||||
.trim()
|
||||
const { blocks: cardBlocks, stripped } = parseCardBlocks(cleaned)
|
||||
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "")
|
||||
return {
|
||||
prose: stripped.trim(),
|
||||
segments: parseSegments(cleaned),
|
||||
actionCount: blocks.length,
|
||||
cardBlocks,
|
||||
}
|
||||
}, [content])
|
||||
|
||||
@@ -134,76 +406,8 @@ export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyPro
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{prose && (
|
||||
<ReactMarkdown
|
||||
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>
|
||||
{segments.map((seg, i) =>
|
||||
seg.type === "prose" ? <ProseChunk key={i} text={seg.text} /> : renderBlock(seg.kind, seg.spec, i),
|
||||
)}
|
||||
{actionCount > 0 && (
|
||||
<span
|
||||
|
||||
33
app/components/auth/auth-shell.tsx
Normal file
33
app/components/auth/auth-shell.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { useBrand } from "~/lib/identity"
|
||||
|
||||
export function AuthShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="dark relative isolate flex min-h-svh items-center justify-center p-4"
|
||||
style={{ background: "var(--background)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuthBrand() {
|
||||
const brand = useBrand()
|
||||
const BrandIcon = brand.icon
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
}}
|
||||
>
|
||||
<BrandIcon className="size-4" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{brand.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
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 {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
@@ -25,6 +26,21 @@ import {
|
||||
KeyRound,
|
||||
Webhook as WebhookIcon,
|
||||
CalendarClock,
|
||||
Gauge,
|
||||
UserCheck,
|
||||
Network,
|
||||
Building,
|
||||
ShieldCheck,
|
||||
Megaphone,
|
||||
AlertOctagon,
|
||||
SearchCode,
|
||||
ChevronDown,
|
||||
Database,
|
||||
Plug,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
CreditCard,
|
||||
// CREMA:NAV-ICONS
|
||||
} from "lucide-react"
|
||||
|
||||
@@ -53,6 +69,7 @@ import {
|
||||
} from "~/components/ui/popover"
|
||||
import { profileInitials, useProfile } from "~/lib/profile"
|
||||
import { signOut, useSession } from "~/lib/session"
|
||||
import { capabilityForPath, useCapabilities } from "~/lib/capabilities"
|
||||
import {
|
||||
addNotification,
|
||||
dismiss,
|
||||
@@ -82,6 +99,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet"
|
||||
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
|
||||
import { RouteGuard } from "~/components/route-guard"
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
@@ -90,22 +108,113 @@ type NavItem = {
|
||||
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: "/tenants", icon: Building2, label: "Tenants" },
|
||||
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
||||
{ to: "/users", icon: UsersIcon, label: "Users" },
|
||||
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
|
||||
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
||||
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||||
{ to: "/ai", icon: Bot, label: "AI" },
|
||||
]
|
||||
|
||||
// Pinned items render flat at the bottom of the rail, below all groups.
|
||||
const pinnedBottom: NavItem[] = [
|
||||
{ to: "/settings", icon: Settings, label: "Settings" },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
key: "tenancy",
|
||||
label: "Tenancy",
|
||||
icon: Building2,
|
||||
items: [
|
||||
{ to: "/tenants", icon: Building2, label: "Tenants" },
|
||||
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
|
||||
{ to: "/organizations", icon: Building, label: "Organizations" },
|
||||
{ to: "/users", icon: UsersIcon, label: "Users" },
|
||||
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: "Billing",
|
||||
icon: CreditCard,
|
||||
items: [
|
||||
{ to: "/apps", icon: LayoutGrid, label: "Apps" },
|
||||
{ to: "/plan", icon: CreditCard, label: "Plan" },
|
||||
{ to: "/entitlements", icon: Gauge, label: "Entitlements" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: "/integrations", icon: Plug, label: "Integrations" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
label: "Integrations",
|
||||
icon: Plug,
|
||||
items: [
|
||||
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
|
||||
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
|
||||
{ to: "/networking", icon: Network, label: "Networking" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "comms",
|
||||
label: "Communications",
|
||||
icon: MessageSquare,
|
||||
items: [
|
||||
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
|
||||
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "observability",
|
||||
label: "Observability",
|
||||
icon: Eye,
|
||||
items: [
|
||||
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
|
||||
{ to: "/activity", icon: Activity, label: "Audit log" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
label: "AI & Search",
|
||||
icon: Sparkles,
|
||||
items: [
|
||||
{ to: "/ai", icon: Bot, label: "AI" },
|
||||
{ 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
|
||||
]
|
||||
|
||||
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 = {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
brand?: Brand
|
||||
user?: User
|
||||
@@ -118,7 +227,6 @@ type AppShellProps = {
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
title,
|
||||
children,
|
||||
brand: brandOverride,
|
||||
user: userOverride,
|
||||
@@ -128,16 +236,14 @@ export function AppShell({
|
||||
const defaultUser = useUser()
|
||||
const profile = useProfile()
|
||||
const session = useSession()
|
||||
const caps = useCapabilities()
|
||||
const navigate = useNavigate()
|
||||
const brand = brandOverride ?? defaultBrand
|
||||
// Prefer the live session for identity, fall back to the editable profile,
|
||||
// fall back to the stub user.
|
||||
// Prefer the live session for identity, fall back to the stub user.
|
||||
const user = userOverride ?? {
|
||||
name: session?.name || profile.name || defaultUser.name,
|
||||
email: session?.email || profile.email || defaultUser.email,
|
||||
initials: profileInitials(
|
||||
session?.name || profile.name || defaultUser.name,
|
||||
),
|
||||
name: session?.name || defaultUser.name,
|
||||
email: session?.email || defaultUser.email,
|
||||
initials: profileInitials(session?.name || defaultUser.name),
|
||||
}
|
||||
|
||||
// Protected shell: bounce to /login when there's no session.
|
||||
@@ -150,7 +256,9 @@ export function AppShell({
|
||||
navigate(`/login?next=${next}`, { replace: true })
|
||||
}
|
||||
}, [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>(() => {
|
||||
if (typeof window === "undefined") return false
|
||||
return localStorage.getItem(SIDEBAR_KEY) === "1"
|
||||
@@ -160,10 +268,80 @@ export function AppShell({
|
||||
}, [expanded])
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false)
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-theme={theme}
|
||||
@@ -205,31 +383,67 @@ export function AppShell({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
title={expanded ? undefined : item.label}
|
||||
data-action={`nav-${item.label.toLowerCase()}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"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" />
|
||||
{expanded && <span className="truncate">{item.label}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{expanded ? (
|
||||
<>
|
||||
{visiblePinnedTop.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
|
||||
{visibleNavGroups.map((group) => {
|
||||
const isOpen = !!openGroups[group.key]
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-action={`nav-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"
|
||||
>
|
||||
<GroupIcon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{group.label}</span>
|
||||
<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>
|
||||
|
||||
<div className="shrink-0 border-t p-2">
|
||||
@@ -263,8 +477,11 @@ export function AppShell({
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetContent
|
||||
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">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<BrandIcon className="size-4" />
|
||||
@@ -272,30 +489,81 @@ export function AppShell({
|
||||
{brand.name}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{visiblePinnedTop.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
expanded
|
||||
mobile
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleNavGroups.map((group) => {
|
||||
const isOpen = !!openGroups[group.key]
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
data-action={`nav-mobile-${item.label.toLowerCase()}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
<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"
|
||||
>
|
||||
<GroupIcon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{group.label}</span>
|
||||
<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>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -328,7 +596,11 @@ export function AppShell({
|
||||
>
|
||||
<Avatar className="size-7 cursor-pointer">
|
||||
{profile.avatarUrl ? (
|
||||
<AvatarImage src={profile.avatarUrl} alt={user.name} />
|
||||
<AvatarImage
|
||||
key={profile.avatarUrl}
|
||||
src={profile.avatarUrl}
|
||||
alt={user.name}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback>{user.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -376,9 +648,16 @@ export function AppShell({
|
||||
<div
|
||||
id="main-content"
|
||||
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. */}
|
||||
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 p-6 [&>*:first-child]:lg:pr-72">
|
||||
<RouteGuard>{children}</RouteGuard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -388,6 +667,59 @@ 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}
|
||||
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() {
|
||||
// Hidden bridge so the action bus can create real notifications:
|
||||
// fill notify-title "Hello"
|
||||
|
||||
36
app/components/layout/page-header.tsx
Normal file
36
app/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
/** Inline indicators after the title (badges, status pills). */
|
||||
badges?: ReactNode
|
||||
/** Toolbar rendered below the title row — primary actions go here. */
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
// Right-side space for the appbar's floating actions pill is reserved by
|
||||
// the AppShell's first-child padding rule, not here — keep this layout
|
||||
// concerned only with title/description/actions composition.
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
badges,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{badges}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
{actions ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
50
app/components/route-guard.tsx
Normal file
50
app/components/route-guard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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 requires the <code className="font-mono text-xs">{capability}</code>{" "}
|
||||
capability on your active tenant. If you think you should have it,
|
||||
switch tenants from the avatar menu or ask an admin.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function ScriptsDialog({
|
||||
data-action="scripts-dsl"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={"navigate /resources\nclick nav-resources"}
|
||||
placeholder={"navigate /tenants\nclick nav-tenants"}
|
||||
spellCheck={false}
|
||||
rows={6}
|
||||
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
|
||||
905
app/components/settings/llm-configurations-panel.tsx
Normal file
905
app/components/settings/llm-configurations-panel.tsx
Normal file
@@ -0,0 +1,905 @@
|
||||
// LLM configurations panel.
|
||||
//
|
||||
// One unified surface for everything LLM-config-related: server-persisted
|
||||
// configurations, the per-operator "active" choice (which one the assistant
|
||||
// uses on the next message), and 30-day spend per row. The "active" toggle
|
||||
// writes to the same localStorage key @crema/llm-providers-ui reads via
|
||||
// loadSettings/saveSettings, so the existing assistant code picks it up
|
||||
// without any plumbing changes.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
|
||||
import { useArcadiaClient } from "@crema/arcadia-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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react"
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
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({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild,
|
||||
children,
|
||||
render,
|
||||
...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 (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={mergedClassName}
|
||||
render={child}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={mergedClassName}
|
||||
render={render}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</ButtonPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export type { ButtonProps }
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -6,40 +6,169 @@
|
||||
// raw HTTP — only the menu below.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
import type { Tool, ToolCall as LLMToolCall } from "@crema/llm-ui"
|
||||
import {
|
||||
createToolRuntime,
|
||||
type ToolDef,
|
||||
} from "@crema/aifirst-ui/tools"
|
||||
|
||||
import {
|
||||
activateTenant,
|
||||
deactivateTenant,
|
||||
getTenant,
|
||||
listTenants,
|
||||
suspendTenant,
|
||||
type Tenant,
|
||||
} 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 = {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
// Lazy singleton — first tool call fetches /docs-index.json, subsequent
|
||||
// calls reuse the parsed MiniSearch instance.
|
||||
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-app + 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 = {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
ok: boolean
|
||||
data?: unknown
|
||||
error?: string
|
||||
const KB_BASE_URL: string =
|
||||
(typeof window !== "undefined" &&
|
||||
(window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) ||
|
||||
readEnv("VITE_ARCADIA_SEARCH_URL") ||
|
||||
"http://127.0.0.1:7800"
|
||||
|
||||
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 = {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown> // JSON Schema for OpenAI tool calling
|
||||
isWrite: boolean
|
||||
run: (args: Record<string, unknown>, ctx: ToolCtx) => Promise<unknown>
|
||||
// True when the operator's session JWT was minted by an arcadia other than
|
||||
// the one hosting search — i.e. signing keys almost certainly don't match
|
||||
// and a session-token fallback will 401 silently. We treat any non-localhost
|
||||
// arcadia URL as "remote" for this heuristic.
|
||||
function isRemoteArcadia(): boolean {
|
||||
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 }
|
||||
|
||||
const TOOLS: ToolDef[] = [
|
||||
const TOOLS: ToolDef<ToolCtx>[] = [
|
||||
{
|
||||
name: "list_tenants",
|
||||
description:
|
||||
@@ -221,6 +350,544 @@ const TOOLS: ToolDef[] = [
|
||||
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-app documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
|
||||
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-app architecture/ops docs (same as the browser RAG, server-hosted for parity).\n- `operator-tools` — arcadia-search + arcadia-admin documentation (admin sidecar, deploy script, search admin UI, MULTI_TENANT, RAG, AI_FIRST, LIBS, LLM_PROXY_CONTRACT).\n- `files` — markdown/text files uploaded by tenant users via arcadia-app.\n\nIf you're not sure what's available, call `list_search_corpora` first. Operators can add new corpora via the `/search` route.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Lexical search query." },
|
||||
corpus: {
|
||||
type: "string",
|
||||
description:
|
||||
"Which indexed corpus to search. See list_search_corpora for the live set; common values: `docs`, `operator-tools`, `files`.",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Max hits. Default 5, cap 20.",
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Optional tag filter — return only hits whose chunk has at least one matching tag.",
|
||||
},
|
||||
},
|
||||
required: ["query", "corpus"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (args) => {
|
||||
const query = typeof args.query === "string" ? args.query.trim() : ""
|
||||
const corpus = typeof args.corpus === "string" ? args.corpus.trim() : ""
|
||||
if (!query) throw new Error("search_kb requires a non-empty { query }")
|
||||
if (!corpus) throw new Error("search_kb requires a { corpus } name")
|
||||
const limit = Math.min(20, Math.max(1, typeof args.limit === "number" ? args.limit : 5))
|
||||
const tags = Array.isArray(args.tags) ? (args.tags as string[]) : undefined
|
||||
return await kbSearch(query, corpus, limit, tags)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_chunk",
|
||||
description:
|
||||
"Fetch the full body of one chunk by id from the arcadia-search backend, after `search_kb` returned it as a snippet. Use this to expand a hit when the snippet looked promising but you need more context to answer.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chunk_id: { type: "string", description: "The chunk_id from a prior search_kb hit." },
|
||||
corpus: { type: "string", description: "Same corpus the chunk came from." },
|
||||
},
|
||||
required: ["chunk_id", "corpus"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (args) => {
|
||||
const chunkId = typeof args.chunk_id === "string" ? args.chunk_id : ""
|
||||
const corpus = typeof args.corpus === "string" ? args.corpus : ""
|
||||
if (!chunkId || !corpus) {
|
||||
throw new Error("read_chunk requires { chunk_id, corpus }")
|
||||
}
|
||||
const result = await kbRead(chunkId, corpus)
|
||||
if (result === null) {
|
||||
return { error: "chunk not found", chunk_id: chunkId, corpus }
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_search_corpora",
|
||||
description:
|
||||
"Enumerate the corpora currently configured on the arcadia-search admin sidecar. Returns each tenant's corpora with build status (indexed?, num_docs). Call this when you don't know what corpora exist before invoking `search_kb`, or when the user asks what knowledge is available. Requires the search admin token to be configured.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async () => {
|
||||
try {
|
||||
const tenantsRes = await searchAdmin.listTenants()
|
||||
const tenants = await Promise.all(
|
||||
tenantsRes.tenants.map(async (t) => {
|
||||
try {
|
||||
const c = await searchAdmin.listCorpora(t.id)
|
||||
return {
|
||||
tenant: t.id,
|
||||
corpora: c.corpora.map((cc) => ({
|
||||
corpus: cc.corpus,
|
||||
indexed: cc.indexed,
|
||||
num_docs: cc.num_docs,
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return { tenant: t.id, corpora: [] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { tenants }
|
||||
} catch (err) {
|
||||
if (err instanceof SearchAdminError) {
|
||||
return {
|
||||
error: `search-admin ${err.status}: ${err.message}`,
|
||||
hint: "VITE_ARCADIA_SEARCH_ADMIN_TOKEN may be unset, or the sidecar (default :7801) may be down.",
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rebuild_search_corpus",
|
||||
description:
|
||||
"Trigger a synchronous rebuild of one corpus on arcadia-search. Use when the operator says the index is stale, after they've uploaded new files, or when search_kb returned suspiciously few/old hits. Returns chunk_count and built_at on success. The operator confirms before the rebuild runs (rebuilds can take seconds–minutes depending on corpus size).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tenant: {
|
||||
type: "string",
|
||||
description: "Search tenant id (e.g. `platform-admin`). See list_search_corpora for available tenants.",
|
||||
},
|
||||
corpus: {
|
||||
type: "string",
|
||||
description: "Corpus name within that tenant (e.g. `docs`, `operator-tools`, `files`).",
|
||||
},
|
||||
},
|
||||
required: ["tenant", "corpus"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: true,
|
||||
run: async (args) => {
|
||||
const tenant = typeof args.tenant === "string" ? args.tenant.trim() : ""
|
||||
const corpus = typeof args.corpus === "string" ? args.corpus.trim() : ""
|
||||
if (!tenant || !corpus) {
|
||||
throw new Error("rebuild_search_corpus requires { tenant, corpus }")
|
||||
}
|
||||
try {
|
||||
return await searchAdmin.rebuild(tenant, corpus)
|
||||
} catch (err) {
|
||||
if (err instanceof SearchAdminError) {
|
||||
return { error: `search-admin ${err.status}: ${err.message}` }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_block_schema",
|
||||
description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries(
|
||||
BLOCK_INDEX,
|
||||
)
|
||||
.map(([k, v]) => `${k} (${v})`)
|
||||
.join(", ")}.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: {
|
||||
type: "string",
|
||||
description: "The block kind to fetch the schema for.",
|
||||
enum: Object.keys(BLOCK_INDEX),
|
||||
},
|
||||
},
|
||||
required: ["kind"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
isWrite: false,
|
||||
run: async (args) => {
|
||||
const kind = typeof args.kind === "string" ? args.kind : ""
|
||||
const schema = getBlockSchema(kind)
|
||||
if (!schema) {
|
||||
return {
|
||||
error: `Unknown block kind "${kind}". Available: ${Object.keys(BLOCK_INDEX).join(", ")}.`,
|
||||
}
|
||||
}
|
||||
return { kind, schema }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
interface AuditEntry {
|
||||
@@ -242,58 +909,6 @@ interface UserEntry {
|
||||
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) {
|
||||
return {
|
||||
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 {
|
||||
try {
|
||||
const text = JSON.stringify(value, null, 2)
|
||||
if (text.length > 6000) return text.slice(0, 6000) + "\n…(truncated)"
|
||||
return text
|
||||
} catch {
|
||||
return "(unserializable)"
|
||||
}
|
||||
}
|
||||
export const getOpenAITools = runtime.getOpenAITools
|
||||
export const classifyCalls = runtime.classifyCalls
|
||||
export const runLLMToolCalls = runtime.runLLMToolCalls
|
||||
|
||||
/** Run a list of provider-native tool calls and return `tool` role messages
|
||||
* ready to push back into useChat history. */
|
||||
export async function runLLMToolCalls(
|
||||
calls: LLMToolCall[],
|
||||
ctx: ToolCtx,
|
||||
opts: { allowWrites?: boolean } = {},
|
||||
): 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 }
|
||||
}
|
||||
export {
|
||||
buildDenialMessages,
|
||||
formatToolCallArgs,
|
||||
type ToolCall,
|
||||
type ToolResult,
|
||||
} from "@crema/aifirst-ui/tools"
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
// Agent personas — named, role-scoped sub-system prompts.
|
||||
// Each persona stacks on top of the main systemPrompt to specialize the
|
||||
// assistant for a task. Persisted in localStorage; reactive across tabs.
|
||||
// Arcadia Admin's agent roster + migration config.
|
||||
// The persona machinery lives in @crema/aifirst-ui/agents — this file
|
||||
// just owns the *which personas* config and re-exports the runtime so
|
||||
// route code keeps importing from "~/lib/agents".
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
export type Agent = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
prompt: string
|
||||
}
|
||||
import { configureAgents, type Agent } from "@crema/aifirst-ui/agents"
|
||||
|
||||
export const DEFAULT_AGENTS: Agent[] = [
|
||||
{
|
||||
@@ -21,148 +15,58 @@ export const DEFAULT_AGENTS: Agent[] = [
|
||||
},
|
||||
{
|
||||
id: "auditor",
|
||||
name: "Ledger",
|
||||
name: "Notary",
|
||||
role: "Auditor",
|
||||
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.",
|
||||
},
|
||||
{
|
||||
id: "triage",
|
||||
name: "Beacon",
|
||||
name: "Tracer",
|
||||
role: "Incident Triage",
|
||||
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.",
|
||||
},
|
||||
{
|
||||
id: "analyst",
|
||||
name: "Tally",
|
||||
name: "Census",
|
||||
role: "Platform Analyst",
|
||||
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.",
|
||||
},
|
||||
{
|
||||
id: "ui-driver",
|
||||
name: "Cursor",
|
||||
name: "Pilot",
|
||||
role: "UI Operator",
|
||||
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.",
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
// generic defaults from before Arcadia Admin had its own personas.
|
||||
const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"])
|
||||
|
||||
function isLegacyDefaultSet(agents: Agent[]): boolean {
|
||||
return agents.some((a) => LEGACY_AGENT_IDS.has(a.id))
|
||||
}
|
||||
// Retired arcadia-era persona names. If we see any of these in storage, the
|
||||
// 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[] {
|
||||
if (typeof window === "undefined") return DEFAULT_AGENTS
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_AGENTS
|
||||
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
|
||||
}
|
||||
}
|
||||
configureAgents({
|
||||
defaults: DEFAULT_AGENTS,
|
||||
shouldReseed: (stored) =>
|
||||
stored.some((a) => LEGACY_AGENT_IDS.has(a.id)) ||
|
||||
stored.some((a) => RETIRED_AGENT_NAMES.has(a.name)),
|
||||
})
|
||||
|
||||
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[] {
|
||||
if (!cached) cached = readFromStorage()
|
||||
return cached
|
||||
}
|
||||
|
||||
function getServerSnapshot(): Agent[] {
|
||||
return DEFAULT_AGENTS
|
||||
}
|
||||
|
||||
export function useAgents(): Agent[] {
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
useEffect(() => {
|
||||
cached = null
|
||||
}, [])
|
||||
return value
|
||||
}
|
||||
|
||||
export function loadActiveAgentId(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_AGENTS[0].id
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_KEY) ?? DEFAULT_AGENTS[0].id
|
||||
} catch {
|
||||
return DEFAULT_AGENTS[0].id
|
||||
}
|
||||
}
|
||||
|
||||
export function saveActiveAgentId(id: string) {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(ACTIVE_KEY, id)
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export function composeSystemPrompt(
|
||||
base: string,
|
||||
agent: Agent | undefined,
|
||||
): string {
|
||||
if (!agent) return base
|
||||
return `${base}\n\nActive persona: ${agent.name} — ${agent.role}\n${agent.prompt}`
|
||||
}
|
||||
|
||||
export function newAgentId(): string {
|
||||
return `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
export {
|
||||
composeSystemPrompt,
|
||||
loadActiveAgentId,
|
||||
loadAgents,
|
||||
newAgentId,
|
||||
resetAgents,
|
||||
saveActiveAgentId,
|
||||
saveAgents,
|
||||
useAgents,
|
||||
type Agent,
|
||||
} from "@crema/aifirst-ui/agents"
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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):
|
||||
|
||||
@@ -31,6 +32,7 @@ Things to keep in mind when assisting:
|
||||
- The operator can impersonate tenant users for debugging (POST /api/v1/admin/impersonate/:user_id) — surface this when they ask "why can't user X log in".
|
||||
- Quotas / rate cards / billing config errors usually surface as 402/403 from /api/v1 endpoints — diagnose by checking the tenant's billing-config and api-metering quotas.
|
||||
- The reference Phoenix app lives at \`reference/arcadia-app/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-client/scripts/sync-spec.mjs\`).
|
||||
- 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.
|
||||
|
||||
|
||||
79
app/lib/arcadia/announcements.ts
Normal file
79
app/lib/arcadia/announcements.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Platform announcements helpers.
|
||||
// Backend: /api/v1/admin/announcements (admin CRUD).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-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}`)
|
||||
}
|
||||
217
app/lib/arcadia/buckets.ts
Normal file
217
app/lib/arcadia/buckets.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// Platform-level bucket management.
|
||||
// Backend: /api/v1/platform/buckets/*. All operations require a
|
||||
// storage_config_id pointing at a credential row in /api/v1/storage_configs.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface Bucket {
|
||||
name: string
|
||||
region?: string
|
||||
size_bytes?: number | null
|
||||
object_count?: number | null
|
||||
created_at?: string | null
|
||||
/** Backend may return additional provider-specific fields. */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BucketObject {
|
||||
key: string
|
||||
size: number
|
||||
last_modified: string | null
|
||||
etag: string | null
|
||||
storage_class: string | null
|
||||
}
|
||||
|
||||
export interface ListObjectsResponse {
|
||||
objects: BucketObject[]
|
||||
is_truncated: boolean
|
||||
continuation_token: string | null
|
||||
prefix: string | null
|
||||
bucket_name: string
|
||||
}
|
||||
|
||||
export interface CreateBucketInput {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
region?: string
|
||||
acl?: "private" | "public-read" | string
|
||||
versioning?: boolean
|
||||
/** Pre-validate without creating. Default false. */
|
||||
dry_run?: boolean
|
||||
}
|
||||
|
||||
export interface DeleteBucketInput {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
/** 6-digit code from /confirmation-code. */
|
||||
confirmation_code?: string
|
||||
/** DANGEROUS — empty the bucket first. */
|
||||
force_empty?: boolean
|
||||
/** Verify a backup exists before delete. Default true. */
|
||||
verify_backup?: boolean
|
||||
/** Preview-only. Default true on first call so the UI can confirm. */
|
||||
dry_run?: boolean
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/platform/buckets"
|
||||
|
||||
export async function listBuckets(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
): Promise<Bucket[]> {
|
||||
const res = await arcadia.GET<{ buckets: Bucket[]; count: number }>(`${BASE}/list`, {
|
||||
params: { storage_config_id: storageConfigId },
|
||||
})
|
||||
return res.buckets ?? []
|
||||
}
|
||||
|
||||
export async function createBucket(
|
||||
arcadia: ArcadiaClient,
|
||||
input: CreateBucketInput,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/create`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteBucket(
|
||||
arcadia: ArcadiaClient,
|
||||
input: DeleteBucketInput,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/delete`, { body: input })
|
||||
}
|
||||
|
||||
export async function generateConfirmationCode(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<{ code: string; expires_at?: string }> {
|
||||
return arcadia.GET(`${BASE}/confirmation-code`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRegions(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
): Promise<string[]> {
|
||||
const res = await arcadia.GET<{ regions?: string[]; data?: string[] }>(`${BASE}/regions`, {
|
||||
params: { storage_config_id: storageConfigId },
|
||||
})
|
||||
return res.regions ?? res.data ?? []
|
||||
}
|
||||
|
||||
// --- Versioning / lifecycle / replication / policy / CORS -----------------
|
||||
|
||||
export async function configureVersioning(
|
||||
arcadia: ArcadiaClient,
|
||||
input: { storage_config_id: string; bucket_name: string; enabled: boolean; dry_run?: boolean },
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/versioning`, { body: input })
|
||||
}
|
||||
|
||||
export async function configureLifecycle(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
rules: Array<Record<string, unknown>>
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/lifecycle`, { body: input })
|
||||
}
|
||||
|
||||
export async function configureReplication(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
destination_bucket: string
|
||||
destination_region?: string
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/replication`, { body: input })
|
||||
}
|
||||
|
||||
export async function configurePolicy(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
policy: Record<string, unknown>
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/policy`, { body: input })
|
||||
}
|
||||
|
||||
export interface CorsRule {
|
||||
allowed_origins: string[]
|
||||
allowed_methods: string[]
|
||||
allowed_headers?: string[]
|
||||
expose_headers?: string[]
|
||||
max_age_seconds?: number
|
||||
}
|
||||
|
||||
export async function getCors(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<{ rules: CorsRule[] } | null> {
|
||||
return arcadia.GET(`${BASE}/cors`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
export async function configureCors(
|
||||
arcadia: ArcadiaClient,
|
||||
input: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
rules: CorsRule[]
|
||||
dry_run?: boolean
|
||||
},
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/cors`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteCors(
|
||||
arcadia: ArcadiaClient,
|
||||
storageConfigId: string,
|
||||
bucketName: string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.DELETE(`${BASE}/cors`, {
|
||||
params: { storage_config_id: storageConfigId, bucket_name: bucketName },
|
||||
})
|
||||
}
|
||||
|
||||
// --- Objects ---------------------------------------------------------------
|
||||
|
||||
export async function listObjects(
|
||||
arcadia: ArcadiaClient,
|
||||
params: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
prefix?: string
|
||||
max_keys?: number
|
||||
continuation_token?: string
|
||||
},
|
||||
): Promise<ListObjectsResponse> {
|
||||
return arcadia.GET<ListObjectsResponse>(`${BASE}/objects`, {
|
||||
params: params as Record<string, string | number | boolean | null | undefined>,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPresignedUrl(
|
||||
arcadia: ArcadiaClient,
|
||||
params: {
|
||||
storage_config_id: string
|
||||
bucket_name: string
|
||||
key: string
|
||||
expires_in?: number
|
||||
},
|
||||
): Promise<{ url: string; expires_at?: string; expires_in?: number }> {
|
||||
return arcadia.GET(`${BASE}/presigned-url`, {
|
||||
params: params as Record<string, string | number | undefined>,
|
||||
})
|
||||
}
|
||||
130
app/lib/arcadia/digital-objects.ts
Normal file
130
app/lib/arcadia/digital-objects.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// Arcadia digital objects API — minimal client covering the upload flow
|
||||
// used by the avatar uploader. The full digital-objects API is much
|
||||
// larger; add endpoints here as we wire more features.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-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)
|
||||
}
|
||||
94
app/lib/arcadia/health.ts
Normal file
94
app/lib/arcadia/health.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Arcadia health probes.
|
||||
//
|
||||
// Backed by /api/v1/health* (public — no auth). Each subsystem is probed
|
||||
// independently; the overall endpoint aggregates and returns 503 if any
|
||||
// subsystem is not "ok". See arcadia-app commit f427892.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type HealthSubsystem = "api" | "db" | "workers" | "storage"
|
||||
|
||||
export type HealthStatus = "ok" | "degraded" | "error" | "unconfigured"
|
||||
|
||||
export interface SubsystemHealth {
|
||||
status: HealthStatus
|
||||
/** Optional human-readable detail. */
|
||||
message?: string
|
||||
/** Free-form metrics — shape is subsystem-specific. */
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface OverallHealth {
|
||||
status: HealthStatus
|
||||
checked_at: string
|
||||
subsystems: Record<HealthSubsystem, SubsystemHealth>
|
||||
}
|
||||
|
||||
export interface DetailedHealth extends OverallHealth {
|
||||
/** BEAM info — present on /health/detailed only. */
|
||||
system?: {
|
||||
otp_release?: string
|
||||
elixir_version?: string
|
||||
process_count?: number
|
||||
memory_total_bytes?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface HostStats {
|
||||
cpu: {
|
||||
util_pct: number | null
|
||||
per_cpu_pct: number[]
|
||||
load_avg_1: number | null
|
||||
load_avg_5: number | null
|
||||
load_avg_15: number | null
|
||||
schedulers_online: number
|
||||
num_cpus: number | null
|
||||
}
|
||||
memory: {
|
||||
total_bytes: number | null
|
||||
free_bytes: number | null
|
||||
available_bytes: number | null
|
||||
buffered_bytes: number | null
|
||||
cached_bytes: number | null
|
||||
swap_total_bytes: number | null
|
||||
swap_free_bytes: number | null
|
||||
}
|
||||
disks: Array<{ mount: string; total_kb: number; used_pct: number }>
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/health"
|
||||
|
||||
export async function getHealth(arcadia: ArcadiaClient): Promise<OverallHealth> {
|
||||
const res = await arcadia.GET<{ data: OverallHealth } | OverallHealth>(BASE)
|
||||
return unwrap(res)
|
||||
}
|
||||
|
||||
export async function getServiceHealth(
|
||||
arcadia: ArcadiaClient,
|
||||
service: HealthSubsystem,
|
||||
): Promise<SubsystemHealth> {
|
||||
const res = await arcadia.GET<{ data: SubsystemHealth } | SubsystemHealth>(
|
||||
`${BASE}/${service}`,
|
||||
)
|
||||
return unwrap(res)
|
||||
}
|
||||
|
||||
export async function getHealthDetailed(arcadia: ArcadiaClient): Promise<DetailedHealth> {
|
||||
const res = await arcadia.GET<{ data: DetailedHealth } | DetailedHealth>(`${BASE}/detailed`)
|
||||
return unwrap(res)
|
||||
}
|
||||
|
||||
export async function getHostStats(arcadia: ArcadiaClient): Promise<HostStats> {
|
||||
const res = await arcadia.GET<{ data: HostStats } | HostStats>(`${BASE}/host`)
|
||||
return unwrap(res)
|
||||
}
|
||||
|
||||
export const SUBSYSTEMS: HealthSubsystem[] = ["api", "db", "workers", "storage"]
|
||||
|
||||
function unwrap<T>(res: { data: T } | T): T {
|
||||
return res && typeof res === "object" && "data" in (res as object)
|
||||
? (res as { data: T }).data
|
||||
: (res as T)
|
||||
}
|
||||
40
app/lib/arcadia/integrations.ts
Normal file
40
app/lib/arcadia/integrations.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Integration-registry client (operator surface) — thin shim over the shared
|
||||
// `@crema/integration-registry-client` lib, bound to `operator` mode. The lib
|
||||
// owns the types, the HTTP contract, and the display helpers (shared with
|
||||
// arcadia-console's tenant surface); this file just exposes operator-idiomatic
|
||||
// names so the page reads naturally.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-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)
|
||||
246
app/lib/arcadia/llm-configs.ts
Normal file
246
app/lib/arcadia/llm-configs.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
// Arcadia LLM configurations API.
|
||||
//
|
||||
// Backed by /api/v1/admin/llm-configurations — server-side persisted
|
||||
// provider/model/secret/cost settings. Replaces the localStorage-driven
|
||||
// settings the admin UI used previously, so configurations and costs
|
||||
// survive across browsers and operators.
|
||||
//
|
||||
// `tenant_id: null` configurations are platform-defaults visible to
|
||||
// every tenant. Names are unique within (tenant, name).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-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-app — keep in sync.
|
||||
*/
|
||||
export function computeCostCents(
|
||||
config: Pick<LlmConfiguration, "input_cost_per_million" | "output_cost_per_million">,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
): number {
|
||||
const inRate = config.input_cost_per_million ?? 0
|
||||
const outRate = config.output_cost_per_million ?? 0
|
||||
const cents = ((inputTokens * inRate + outputTokens * outRate) / 1_000_000) * 100
|
||||
return Math.round(cents)
|
||||
}
|
||||
|
||||
/** Format a cost in cents as "$X.XX" or "$0.0XX" for sub-dollar amounts. */
|
||||
export function formatCost(cents: number): string {
|
||||
if (cents === 0) return "$0"
|
||||
if (cents < 100) return `$${(cents / 100).toFixed(2)}`
|
||||
return `$${(cents / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM usage summary (cost roll-up)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LlmUsageSummary {
|
||||
total_requests: number | null
|
||||
total_input_tokens: number | null
|
||||
total_output_tokens: number | null
|
||||
total_tokens: number | null
|
||||
total_cost_cents: number | null
|
||||
avg_latency_ms: number | null
|
||||
}
|
||||
|
||||
export async function getUsageSummary(
|
||||
arcadia: ArcadiaClient,
|
||||
opts: { days?: number } = {},
|
||||
): Promise<LlmUsageSummary> {
|
||||
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||
if (opts.days != null) params.days = opts.days
|
||||
const res = await arcadia.GET<{ data: LlmUsageSummary } | LlmUsageSummary>(
|
||||
"/api/v1/ai/llm/usage/summary",
|
||||
{ params },
|
||||
)
|
||||
return "data" in (res as object) ? (res as { data: LlmUsageSummary }).data : (res as LlmUsageSummary)
|
||||
}
|
||||
|
||||
export interface UsageByModelRow {
|
||||
provider: string
|
||||
model: string
|
||||
requests: number
|
||||
total_tokens: number
|
||||
cost_cents: number
|
||||
}
|
||||
|
||||
export async function getUsageByModel(
|
||||
arcadia: ArcadiaClient,
|
||||
opts: { days?: number } = {},
|
||||
): Promise<UsageByModelRow[]> {
|
||||
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||
if (opts.days != null) params.days = opts.days
|
||||
const res = await arcadia.GET<{ data: UsageByModelRow[] } | UsageByModelRow[]>(
|
||||
"/api/v1/ai/llm/usage/by-model",
|
||||
{ params },
|
||||
)
|
||||
return "data" in (res as object) ? (res as { data: UsageByModelRow[] }).data : (res as UsageByModelRow[])
|
||||
}
|
||||
|
||||
/** Find the spend row matching a given config's (provider, model). */
|
||||
export function findSpend(
|
||||
rows: UsageByModelRow[],
|
||||
config: Pick<LlmConfiguration, "provider" | "model">,
|
||||
): UsageByModelRow | undefined {
|
||||
return rows.find((r) => r.provider === config.provider && r.model === config.model)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active reasoning_effort (shared between settings panel and /ai composer)
|
||||
//
|
||||
// Stored under crema.ai.reasoning. Written when the operator stars a config
|
||||
// in the settings panel (so the chip on /ai inherits that config's default
|
||||
// on next mount) and when the operator cycles the THINK chip on /ai (per-
|
||||
// conversation override). Wiped on Clear conversation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTIVE_REASONING_KEY = "crema.ai.reasoning"
|
||||
const ACTIVE_REASONING_EVENT = "crema:ai-reasoning-change"
|
||||
|
||||
export function loadActiveReasoning(): ReasoningEffort {
|
||||
if (typeof window === "undefined") return "off"
|
||||
const v = localStorage.getItem(ACTIVE_REASONING_KEY) as ReasoningEffort | null
|
||||
return v && REASONING_EFFORTS.includes(v) ? v : "off"
|
||||
}
|
||||
|
||||
export function saveActiveReasoning(v: ReasoningEffort): void {
|
||||
if (typeof window === "undefined") return
|
||||
if (v === "off") localStorage.removeItem(ACTIVE_REASONING_KEY)
|
||||
else localStorage.setItem(ACTIVE_REASONING_KEY, v)
|
||||
window.dispatchEvent(new CustomEvent(ACTIVE_REASONING_EVENT, { detail: v }))
|
||||
}
|
||||
|
||||
export function subscribeActiveReasoning(
|
||||
listener: (v: ReasoningEffort) => void,
|
||||
): () => void {
|
||||
if (typeof window === "undefined") return () => {}
|
||||
const onChange = (e: Event) => {
|
||||
const detail = (e as CustomEvent<ReasoningEffort>).detail
|
||||
if (detail) listener(detail)
|
||||
else listener(loadActiveReasoning())
|
||||
}
|
||||
// Same-tab via the custom event; cross-tab via the storage event.
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === ACTIVE_REASONING_KEY) listener(loadActiveReasoning())
|
||||
}
|
||||
window.addEventListener(ACTIVE_REASONING_EVENT, onChange)
|
||||
window.addEventListener("storage", onStorage)
|
||||
return () => {
|
||||
window.removeEventListener(ACTIVE_REASONING_EVENT, onChange)
|
||||
window.removeEventListener("storage", onStorage)
|
||||
}
|
||||
}
|
||||
182
app/lib/arcadia/llm-proxy.ts
Normal file
182
app/lib/arcadia/llm-proxy.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Arcadia LLM proxy client.
|
||||
//
|
||||
// Implements the spec in docs/LLM_PROXY_CONTRACT.md against arcadia-app's
|
||||
// POST /api/v1/ai/llm/chat. The lib (@crema/llm-providers-ui buildAdapter)
|
||||
// owns the streaming chat path itself; this module exposes a lightweight
|
||||
// non-streaming probe so the Settings "Test connection" button can verify
|
||||
// the proxy round-trips end-to-end (auth → secret resolution → upstream
|
||||
// dispatch → response shape).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type LLMProxyProvider =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "deepseek"
|
||||
| "qwen"
|
||||
| "lmstudio"
|
||||
|
||||
export type LLMProxyErrorCode =
|
||||
| "unauthorized"
|
||||
| "secret_disabled"
|
||||
| "secret_expired"
|
||||
| "secret_consumed"
|
||||
| "ip_not_allowed"
|
||||
| "unknown_provider"
|
||||
| "upstream_unavailable"
|
||||
| "rate_limited"
|
||||
| "unknown"
|
||||
|
||||
export interface LLMProxyChatRequest {
|
||||
provider: LLMProxyProvider
|
||||
/** Required for every provider except `lmstudio`. */
|
||||
secret_name?: string
|
||||
model: string
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>
|
||||
stream?: boolean
|
||||
max_tokens?: number
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export interface LLMProxyChatResponse {
|
||||
id: string
|
||||
object: "chat.completion"
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
finish_reason: string | null
|
||||
message: { role: "assistant"; content: string; tool_calls: unknown }
|
||||
}>
|
||||
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
}
|
||||
|
||||
export class LLMProxyError extends Error {
|
||||
readonly code: LLMProxyErrorCode
|
||||
readonly status: number
|
||||
readonly retryAfter?: number
|
||||
|
||||
constructor(code: LLMProxyErrorCode, message: string, status: number, retryAfter?: number) {
|
||||
super(message)
|
||||
this.name = "LLMProxyError"
|
||||
this.code = code
|
||||
this.status = status
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming chat completion via the proxy. The streaming path is owned
|
||||
* by @crema/llm-providers-ui's buildAdapter; use this for probes and
|
||||
* one-shot calls where SSE is overkill.
|
||||
*/
|
||||
export async function chat(
|
||||
arcadia: ArcadiaClient,
|
||||
req: LLMProxyChatRequest,
|
||||
): Promise<LLMProxyChatResponse> {
|
||||
try {
|
||||
const res = await arcadia.POST<LLMProxyChatResponse>(
|
||||
"/api/v1/ai/llm/chat",
|
||||
{ body: { ...req, stream: false } },
|
||||
)
|
||||
return res
|
||||
} catch (e) {
|
||||
throw asProxyError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap end-to-end probe for the Settings "Test connection" flow in proxy
|
||||
* mode. Sends a 1-token "ping" and reports whether the proxy is wired,
|
||||
* the secret resolves, and the upstream answered. Intentionally tolerant
|
||||
* of token-budget rejections — those still prove the round-trip works.
|
||||
*/
|
||||
export async function probeProxy(
|
||||
arcadia: ArcadiaClient,
|
||||
opts: { provider: LLMProxyProvider; model: string; secretName?: string },
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
const res = await chat(arcadia, {
|
||||
provider: opts.provider,
|
||||
secret_name: opts.secretName,
|
||||
model: opts.model,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
})
|
||||
const used = res.usage?.total_tokens
|
||||
return {
|
||||
ok: true,
|
||||
message: `Proxy OK — ${res.model}${used != null ? ` · ${used} tokens` : ""}.`,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof LLMProxyError) {
|
||||
return { ok: false, message: friendly(e) }
|
||||
}
|
||||
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
function asProxyError(e: unknown): LLMProxyError {
|
||||
// ArcadiaClient throws ArcadiaError with a wrapped { error: { code, message } }
|
||||
// body and HTTP status. Best-effort destructure without coupling to the
|
||||
// class shape (it lives in a sibling lib).
|
||||
if (e && typeof e === "object") {
|
||||
const anyE = e as {
|
||||
status?: number
|
||||
code?: string
|
||||
message?: string
|
||||
body?: { error?: { code?: string; message?: string } }
|
||||
headers?: Headers | Record<string, string>
|
||||
}
|
||||
const status = anyE.status ?? 0
|
||||
const code = (anyE.body?.error?.code ?? anyE.code) as LLMProxyErrorCode | undefined
|
||||
const message = anyE.body?.error?.message ?? anyE.message ?? "Proxy request failed."
|
||||
const retryAfter = readRetryAfter(anyE.headers)
|
||||
return new LLMProxyError(code ?? inferCodeFromStatus(status), message, status, retryAfter)
|
||||
}
|
||||
return new LLMProxyError("unknown", String(e), 0)
|
||||
}
|
||||
|
||||
function inferCodeFromStatus(status: number): LLMProxyErrorCode {
|
||||
if (status === 401) return "unauthorized"
|
||||
if (status === 403) return "ip_not_allowed"
|
||||
if (status === 404) return "unknown_provider"
|
||||
if (status === 410) return "secret_expired"
|
||||
if (status === 429) return "rate_limited"
|
||||
if (status === 502 || status === 503 || status === 504) return "upstream_unavailable"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function readRetryAfter(h: Headers | Record<string, string> | undefined): number | undefined {
|
||||
if (!h) return undefined
|
||||
const raw = h instanceof Headers ? h.get("retry-after") : h["retry-after"] ?? h["Retry-After"]
|
||||
if (!raw) return undefined
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
export function friendly(err: LLMProxyError): string {
|
||||
switch (err.code) {
|
||||
case "unauthorized":
|
||||
return "Sign in expired — refresh and try again."
|
||||
case "secret_disabled":
|
||||
return "The vault secret is disabled. Re-enable it under /secrets."
|
||||
case "secret_expired":
|
||||
return "The vault secret has expired. Rotate it under /secrets."
|
||||
case "secret_consumed":
|
||||
return "Read-once secret already used. Rotate it under /secrets."
|
||||
case "ip_not_allowed":
|
||||
return "This client's IP is blocked by the secret's allowlist."
|
||||
case "unknown_provider":
|
||||
return "The proxy doesn't recognise this provider. Check the provider id."
|
||||
case "upstream_unavailable":
|
||||
return "The upstream LLM provider returned an error or timed out."
|
||||
case "rate_limited":
|
||||
return err.retryAfter
|
||||
? `Rate limited. Retry in ${err.retryAfter}s.`
|
||||
: "Rate limited — slow down and try again."
|
||||
default:
|
||||
return err.message
|
||||
}
|
||||
}
|
||||
96
app/lib/arcadia/memberships.ts
Normal file
96
app/lib/arcadia/memberships.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Tenant memberships — the M:N glue between users and tenants.
|
||||
// Backend: /api/v1/admin/memberships (admin) + /api/v1/me/tenants (self).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type MembershipStatus = "active" | "suspended" | "deactivated" | string
|
||||
|
||||
export interface MembershipUser {
|
||||
id: string
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MembershipTenant {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MembershipRole {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface Membership {
|
||||
id: string
|
||||
tenant_id: string
|
||||
tenant: MembershipTenant | null
|
||||
user_id: string
|
||||
user: MembershipUser | null
|
||||
status: MembershipStatus
|
||||
is_primary: boolean
|
||||
joined_at: string | null
|
||||
last_accessed_at: string | null
|
||||
metadata: Record<string, unknown>
|
||||
roles: MembershipRole[]
|
||||
}
|
||||
|
||||
export interface MembershipInput {
|
||||
user_id: string
|
||||
status?: MembershipStatus
|
||||
metadata?: Record<string, unknown>
|
||||
role_ids?: string[]
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/admin/memberships"
|
||||
|
||||
export async function listMemberships(arcadia: ArcadiaClient): Promise<Membership[]> {
|
||||
const res = await arcadia.GET<{ data: Membership[] }>(BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
input: MembershipInput,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(BASE, {
|
||||
body: { membership: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<MembershipInput>,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.PATCH<{ data: Membership }>(`${BASE}/${id}`, {
|
||||
body: { membership: input },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteMembership(arcadia: ArcadiaClient, id: string): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export async function suspendMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/suspend`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function activateMembership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Membership> {
|
||||
const res = await arcadia.POST<{ data: Membership }>(`${BASE}/${id}/activate`)
|
||||
return res.data
|
||||
}
|
||||
199
app/lib/arcadia/monitoring.ts
Normal file
199
app/lib/arcadia/monitoring.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// Server stats / health helpers.
|
||||
// Wraps /api/v1/admin/monitoring/* + /api/v1/platform/* + a few observability
|
||||
// endpoints used by the monitoring dashboard.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
// --- Rate limits ---------------------------------------------------------
|
||||
|
||||
export interface RateLimit {
|
||||
type: string
|
||||
max_requests: number
|
||||
window_seconds: number
|
||||
}
|
||||
|
||||
export async function getRateLimits(arcadia: ArcadiaClient): Promise<RateLimit[]> {
|
||||
const res = await arcadia.GET<{ data: { limits: RateLimit[] } }>(
|
||||
"/api/v1/admin/monitoring/rate-limits",
|
||||
)
|
||||
return res.data.limits ?? []
|
||||
}
|
||||
|
||||
// --- Active sessions ----------------------------------------------------
|
||||
|
||||
export interface ActiveSession {
|
||||
user_id: string
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
status: string
|
||||
user_type: string | null
|
||||
last_sign_in_at: string
|
||||
tenant_id: string
|
||||
two_factor_enabled: boolean
|
||||
}
|
||||
|
||||
export async function getActiveSessions(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<{ sessions: ActiveSession[]; count: number }> {
|
||||
const res = await arcadia.GET<{ data: { sessions: ActiveSession[]; count: number } }>(
|
||||
"/api/v1/admin/monitoring/sessions",
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// --- Background jobs (Oban) ---------------------------------------------
|
||||
|
||||
export type JobState =
|
||||
| "available"
|
||||
| "executing"
|
||||
| "scheduled"
|
||||
| "retryable"
|
||||
| "discarded"
|
||||
| "cancelled"
|
||||
| "completed"
|
||||
|
||||
export interface JobStats {
|
||||
counts: Record<JobState, number>
|
||||
by_queue: Record<string, Partial<Record<JobState, number>>>
|
||||
queues: string[]
|
||||
}
|
||||
|
||||
export interface ObanJob {
|
||||
id: number
|
||||
queue: string
|
||||
state: JobState
|
||||
worker: string
|
||||
attempt: number
|
||||
max_attempts: number
|
||||
inserted_at: string
|
||||
attempted_at: string | null
|
||||
completed_at: string | null
|
||||
scheduled_at: string | null
|
||||
errors: Array<{ at?: string; attempt?: number; error?: string }> | null
|
||||
}
|
||||
|
||||
export async function getJobStats(arcadia: ArcadiaClient): Promise<JobStats> {
|
||||
const res = await arcadia.GET<{ data: JobStats }>(
|
||||
"/api/v1/admin/monitoring/jobs/stats",
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getRecentJobs(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: { limit?: number; state?: JobState; queue?: string },
|
||||
): Promise<ObanJob[]> {
|
||||
const res = await arcadia.GET<{ data: { jobs: ObanJob[]; count: number } }>(
|
||||
"/api/v1/admin/monitoring/jobs",
|
||||
{ params: params as Record<string, string | number | undefined> },
|
||||
)
|
||||
return res.data.jobs ?? []
|
||||
}
|
||||
|
||||
export async function retryJob(arcadia: ArcadiaClient, id: number): Promise<void> {
|
||||
await arcadia.POST(`/api/v1/admin/monitoring/jobs/${id}/retry`)
|
||||
}
|
||||
|
||||
// --- Platform infrastructure (DigitalOcean) -----------------------------
|
||||
|
||||
/** Provider returns whatever it returns; admin UI surfaces it loosely. */
|
||||
export type InfrastructureSummary = Record<string, unknown>
|
||||
export type Space = Record<string, unknown>
|
||||
|
||||
export async function getInfrastructureSummary(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<InfrastructureSummary | null> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: InfrastructureSummary }>(
|
||||
"/api/v1/platform/infrastructure/summary",
|
||||
)
|
||||
return res.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSpaces(arcadia: ArcadiaClient): Promise<Space[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: Space[] }>(
|
||||
"/api/v1/platform/infrastructure/spaces",
|
||||
)
|
||||
return res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Droplets ------------------------------------------------------------
|
||||
|
||||
export interface Droplet {
|
||||
id: number | string
|
||||
name: string
|
||||
status: string
|
||||
region?: { slug?: string; name?: string } | string
|
||||
size_slug?: string
|
||||
vcpus?: number
|
||||
memory?: number
|
||||
disk?: number
|
||||
created_at?: string
|
||||
networks?: unknown
|
||||
/** Provider-specific fields surface verbatim. */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DropletMetrics {
|
||||
cpu?: Array<{ time: string; value: number }>
|
||||
memory?: Array<{ time: string; value: number }>
|
||||
disk?: Array<{ time: string; value: number }>
|
||||
bandwidth?: Array<{ time: string; value: number }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listDroplets(arcadia: ArcadiaClient): Promise<Droplet[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ droplets?: Droplet[]; data?: Droplet[] }>(
|
||||
"/api/v1/platform/droplets",
|
||||
)
|
||||
return res.droplets ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDropletMetrics(
|
||||
arcadia: ArcadiaClient,
|
||||
id: number | string,
|
||||
): Promise<DropletMetrics | null> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ data: DropletMetrics }>(
|
||||
`/api/v1/platform/droplets/${id}/metrics`,
|
||||
)
|
||||
return res.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Audit stats (already used by /activity, exposed here for the dashboard) ---
|
||||
|
||||
export interface AuditStats {
|
||||
total: number
|
||||
by_action?: Record<string, number>
|
||||
by_severity?: Record<string, number>
|
||||
by_resource_type?: Record<string, number>
|
||||
/** When backend supports it: { period: ISO, total: number }[] */
|
||||
over_time?: Array<{ period: string; total: number }>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function getAuditStats(
|
||||
arcadia: ArcadiaClient,
|
||||
params?: { from?: string; to?: string },
|
||||
): Promise<AuditStats> {
|
||||
const res = await arcadia.GET<{ data: AuditStats }>(
|
||||
"/api/v1/observability/audit_stats",
|
||||
{ params: params as Record<string, string | undefined> },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
162
app/lib/arcadia/networking.ts
Normal file
162
app/lib/arcadia/networking.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Networking helpers: firewalls, VPCs, domains + DNS records, floating IPs.
|
||||
// Backend: /api/v1/platform/{firewalls,vpcs,domains,floating_ips,...}.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
const BASE = "/api/v1/platform"
|
||||
|
||||
// --- Firewalls ----------------------------------------------------------
|
||||
|
||||
export interface Firewall {
|
||||
id: string | number
|
||||
name: string
|
||||
status?: string
|
||||
inbound_rules?: unknown[]
|
||||
outbound_rules?: unknown[]
|
||||
droplet_ids?: Array<string | number>
|
||||
created_at?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listFirewalls(arcadia: ArcadiaClient): Promise<Firewall[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ firewalls?: Firewall[]; data?: Firewall[] }>(
|
||||
`${BASE}/firewalls`,
|
||||
)
|
||||
return res.firewalls ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFirewall(
|
||||
arcadia: ArcadiaClient,
|
||||
input: Partial<Firewall>,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/firewalls`, { body: input })
|
||||
}
|
||||
|
||||
export async function deleteFirewall(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string | number,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/firewalls/${id}`)
|
||||
}
|
||||
|
||||
// --- VPCs ---------------------------------------------------------------
|
||||
|
||||
export interface Vpc {
|
||||
id: string
|
||||
name: string
|
||||
region?: string
|
||||
ip_range?: string
|
||||
default?: boolean
|
||||
created_at?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listVpcs(arcadia: ArcadiaClient): Promise<Vpc[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ vpcs?: Vpc[]; data?: Vpc[] }>(`${BASE}/vpcs`)
|
||||
return res.vpcs ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Domains + DNS records ----------------------------------------------
|
||||
|
||||
export interface Domain {
|
||||
name: string
|
||||
ttl?: number
|
||||
zone_file?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface DnsRecord {
|
||||
id: string | number
|
||||
type: string
|
||||
name: string
|
||||
data: string
|
||||
priority?: number | null
|
||||
port?: number | null
|
||||
ttl?: number
|
||||
weight?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listDomains(arcadia: ArcadiaClient): Promise<Domain[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ domains?: Domain[]; data?: Domain[] }>(`${BASE}/domains`)
|
||||
return res.domains ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDnsRecords(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
): Promise<DnsRecord[]> {
|
||||
const res = await arcadia.GET<{ domain_records?: DnsRecord[]; data?: DnsRecord[] }>(
|
||||
`${BASE}/domains/${encodeURIComponent(domainName)}/records`,
|
||||
)
|
||||
return res.domain_records ?? res.data ?? []
|
||||
}
|
||||
|
||||
export async function createDnsRecord(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
input: { type: string; name: string; data: string; ttl?: number; priority?: number },
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/domains/${encodeURIComponent(domainName)}/records`, {
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteDnsRecord(
|
||||
arcadia: ArcadiaClient,
|
||||
domainName: string,
|
||||
recordId: string | number,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(
|
||||
`${BASE}/domains/${encodeURIComponent(domainName)}/records/${recordId}`,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Floating IPs -------------------------------------------------------
|
||||
|
||||
export interface FloatingIp {
|
||||
ip: string
|
||||
region?: { slug?: string; name?: string } | string
|
||||
droplet?: { id: number | string; name?: string } | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function listFloatingIps(arcadia: ArcadiaClient): Promise<FloatingIp[]> {
|
||||
try {
|
||||
const res = await arcadia.GET<{ floating_ips?: FloatingIp[]; data?: FloatingIp[] }>(
|
||||
`${BASE}/floating_ips`,
|
||||
)
|
||||
return res.floating_ips ?? res.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignFloatingIp(
|
||||
arcadia: ArcadiaClient,
|
||||
ip: string,
|
||||
dropletId: number | string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/floating_ips/${ip}/assign`, {
|
||||
body: { droplet_id: dropletId },
|
||||
})
|
||||
}
|
||||
|
||||
export async function unassignFloatingIp(
|
||||
arcadia: ArcadiaClient,
|
||||
ip: string,
|
||||
): Promise<unknown> {
|
||||
return arcadia.POST(`${BASE}/floating_ips/${ip}/unassign`)
|
||||
}
|
||||
180
app/lib/arcadia/organizations.ts
Normal file
180
app/lib/arcadia/organizations.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// Organizations — end-user workspaces nested under a tenant.
|
||||
// Backend: /api/v1/organizations + /api/v1/admin/organizations.
|
||||
//
|
||||
// Tenant admins (arcadia-admin) bypass per-org membership checks via the
|
||||
// `OrganizationContext` plug, so the same per-org routes used by end-users
|
||||
// are used here to mutate any org in the tenant.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export type OrgStatus = "active" | "frozen" | "pending_deletion" | string
|
||||
export type OnOwnerRemoval =
|
||||
| "delete"
|
||||
| "require_transfer"
|
||||
| "freeze_until_new_owner"
|
||||
export type OrgRole = "owner" | "admin" | "member"
|
||||
export type MembershipStatus = "active" | "suspended" | "invited" | string
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
tenant_id: string
|
||||
slug: string
|
||||
name: string
|
||||
status: OrgStatus
|
||||
on_owner_removal: OnOwnerRemoval
|
||||
settings: Record<string, unknown>
|
||||
metadata: Record<string, unknown>
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface OrgMembership {
|
||||
id: string
|
||||
organization_id: string
|
||||
user_id: string
|
||||
role: OrgRole
|
||||
status: MembershipStatus
|
||||
joined_at: string | null
|
||||
}
|
||||
|
||||
export interface CreateOrgInput {
|
||||
name: string
|
||||
slug: string
|
||||
on_owner_removal?: OnOwnerRemoval
|
||||
settings?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UpdateOrgInput {
|
||||
name?: string
|
||||
status?: OrgStatus
|
||||
on_owner_removal?: OnOwnerRemoval
|
||||
settings?: Record<string, unknown>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface InviteByEmailInput {
|
||||
email: string
|
||||
role?: OrgRole
|
||||
}
|
||||
|
||||
export interface AddRestrictedUserInput {
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
role?: OrgRole
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/organizations"
|
||||
const ADMIN_BASE = "/api/v1/admin/organizations"
|
||||
|
||||
// Tenant-wide list: every org in the current tenant. Admin-only.
|
||||
export async function listAllOrganizations(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<Organization[]> {
|
||||
const res = await arcadia.GET<{ data: Organization[] }>(ADMIN_BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// End-user list: orgs the current user is a member of.
|
||||
export async function listMyOrganizations(
|
||||
arcadia: ArcadiaClient,
|
||||
): Promise<Organization[]> {
|
||||
const res = await arcadia.GET<{ data: Organization[] }>(BASE)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createOrganization(
|
||||
arcadia: ArcadiaClient,
|
||||
input: CreateOrgInput,
|
||||
): Promise<Organization> {
|
||||
const res = await arcadia.POST<{ data: Organization }>(BASE, { body: input })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getOrganization(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<Organization> {
|
||||
const res = await arcadia.GET<{ data: Organization }>(`${BASE}/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateOrganization(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: UpdateOrgInput,
|
||||
): Promise<Organization> {
|
||||
const res = await arcadia.PATCH<{ data: Organization }>(`${BASE}/${id}`, {
|
||||
body: input,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function listMembers(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
status?: MembershipStatus,
|
||||
): Promise<OrgMembership[]> {
|
||||
const path = status
|
||||
? `${BASE}/${id}/members?status=${encodeURIComponent(status)}`
|
||||
: `${BASE}/${id}/members`
|
||||
const res = await arcadia.GET<{ data: OrgMembership[] }>(path)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function inviteMember(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: InviteByEmailInput,
|
||||
): Promise<{ type: "membership" | "email_invitation"; [k: string]: unknown }> {
|
||||
const res = await arcadia.POST<{
|
||||
data: { type: "membership" | "email_invitation"; [k: string]: unknown }
|
||||
}>(`${BASE}/${id}/members/invite`, { body: input })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function addRestrictedMember(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: AddRestrictedUserInput,
|
||||
): Promise<{ user: { id: string; email: string; account_type: string }; membership: OrgMembership }> {
|
||||
const res = await arcadia.POST<{
|
||||
data: { user: { id: string; email: string; account_type: string }; membership: OrgMembership }
|
||||
}>(`${BASE}/${id}/members/add_restricted`, { body: input })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function changeMemberRole(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
userId: string,
|
||||
role: "admin" | "member",
|
||||
): Promise<OrgMembership> {
|
||||
const res = await arcadia.PATCH<{ data: OrgMembership }>(
|
||||
`${BASE}/${id}/members/${userId}/role`,
|
||||
{ body: { role } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function removeMember(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/${id}/members/${userId}`)
|
||||
}
|
||||
|
||||
export async function transferOwnership(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
newOwnerUserId: string,
|
||||
): Promise<OrgMembership> {
|
||||
const res = await arcadia.POST<{ data: OrgMembership }>(
|
||||
`${BASE}/${id}/transfer_ownership`,
|
||||
{ body: { new_owner_user_id: newOwnerUserId } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
79
app/lib/arcadia/profiles.ts
Normal file
79
app/lib/arcadia/profiles.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Arcadia profile API. Backed by /api/v1/profile (current user) — handles
|
||||
// avatar wiring (avatar_digital_object_id + variant URLs) and the basic
|
||||
// profile fields. The "profile" here is the per-tenant profile row, not
|
||||
// the auth account.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-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
|
||||
}
|
||||
99
app/lib/arcadia/sso.ts
Normal file
99
app/lib/arcadia/sso.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// SSO / SAML helpers.
|
||||
// Backend: /api/v1/sso/identity-providers (tenant CRUD) + /sessions.
|
||||
// Note: certificates are large and write-only.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
export interface IdentityProvider {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
entity_id: string
|
||||
sso_url: string
|
||||
slo_url: string | null
|
||||
name_id_format: string | null
|
||||
attribute_mapping: Record<string, string>
|
||||
sp_entity_id: string | null
|
||||
sign_requests: boolean
|
||||
metadata_url: string | null
|
||||
callback_url: string | null
|
||||
enabled: boolean
|
||||
has_certificate: boolean
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface IdentityProviderInput {
|
||||
name: string
|
||||
entity_id: string
|
||||
sso_url: string
|
||||
slo_url?: string | null
|
||||
name_id_format?: string | null
|
||||
attribute_mapping?: Record<string, string>
|
||||
sp_entity_id?: string | null
|
||||
sign_requests?: boolean
|
||||
metadata_url?: string | null
|
||||
callback_url?: string | null
|
||||
enabled?: boolean
|
||||
/** PEM cert from the IdP. Write-only. */
|
||||
certificate?: string
|
||||
}
|
||||
|
||||
export interface SamlSession {
|
||||
id: string
|
||||
user_id: string
|
||||
idp_id: string
|
||||
name_id: string | null
|
||||
session_index: string | null
|
||||
expires_at: string | null
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/sso"
|
||||
|
||||
export async function listIdentityProviders(arcadia: ArcadiaClient): Promise<IdentityProvider[]> {
|
||||
const res = await arcadia.GET<{ data: IdentityProvider[] }>(`${BASE}/identity-providers`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
input: IdentityProviderInput,
|
||||
): Promise<IdentityProvider> {
|
||||
const res = await arcadia.POST<{ data: IdentityProvider }>(
|
||||
`${BASE}/identity-providers`,
|
||||
{ body: { identity_provider: input } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IdentityProviderInput>,
|
||||
): Promise<IdentityProvider> {
|
||||
const res = await arcadia.PATCH<{ data: IdentityProvider }>(
|
||||
`${BASE}/identity-providers/${id}`,
|
||||
{ body: { identity_provider: input } },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteIdentityProvider(
|
||||
arcadia: ArcadiaClient,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/identity-providers/${id}`)
|
||||
}
|
||||
|
||||
export async function listSamlSessions(arcadia: ArcadiaClient): Promise<SamlSession[]> {
|
||||
const res = await arcadia.GET<{ data: SamlSession[] }>(`${BASE}/sessions`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function destroySamlSession(
|
||||
arcadia: ArcadiaClient,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
await arcadia.DELETE(`${BASE}/sessions/${sessionId}`)
|
||||
}
|
||||
172
app/lib/arcadia/status-page.ts
Normal file
172
app/lib/arcadia/status-page.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// Status page helpers — components, incidents, subscribers.
|
||||
// Backend: /api/v1/admin/status-page/* (admin CRUD).
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-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
|
||||
}
|
||||
@@ -149,7 +149,9 @@ export const SECRET_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||
export const REQUIRED_FIELDS: Record<StorageBackend, readonly string[]> = {
|
||||
s3: ["bucket", "region", "access_key_id", "secret_access_key"],
|
||||
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[]> = {
|
||||
|
||||
@@ -91,3 +91,23 @@ export async function deactivateTenant(arcadia: ArcadiaClient, id: string): Prom
|
||||
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`)
|
||||
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
|
||||
}
|
||||
|
||||
247
app/lib/block-schemas.ts
Normal file
247
app/lib/block-schemas.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// Lazy-fetched schemas for the typed fenced blocks the assistant can emit.
|
||||
// The system prompt only ships a thin index (kind → one-line purpose). Full
|
||||
// JSON schemas + examples live here and are pulled on demand via the
|
||||
// `get_block_schema` tool. Keeps the always-on prompt small and lets new
|
||||
// blocks be added by editing this file alone — no prompt edits required.
|
||||
//
|
||||
// Renderer is in app/components/assistant/message-body.tsx — keep these in
|
||||
// sync (kinds, field names) when adding or changing blocks.
|
||||
|
||||
export type BlockKind =
|
||||
| "kpi"
|
||||
| "table"
|
||||
| "chart-bar"
|
||||
| "chart-line"
|
||||
| "chart-donut"
|
||||
| "chart-spark"
|
||||
| "code"
|
||||
| "diff"
|
||||
| "card"
|
||||
| "flowchart"
|
||||
| "orgchart"
|
||||
| "steps"
|
||||
| "checklist"
|
||||
| "welcome"
|
||||
| "hint"
|
||||
|
||||
export const BLOCK_INDEX: Record<BlockKind, string> = {
|
||||
kpi: "Headline numbers row (2–6 metrics).",
|
||||
table: "Tabular data (≥3 rows or ≥3 columns).",
|
||||
"chart-bar": "Compare ≤8 categories.",
|
||||
"chart-line": "Ordered series / trend over time.",
|
||||
"chart-donut": "Part-to-whole, ≤5 slices.",
|
||||
"chart-spark": "Inline trend, no axes.",
|
||||
code: "Syntax-highlighted snippet (SQL, JSON, YAML, etc).",
|
||||
diff: "Before/after comparison.",
|
||||
card: "Inline pill, stat chip, or callout banner.",
|
||||
flowchart: "Process / decision flow with shaped nodes (start/end/process/decision/io).",
|
||||
orgchart: "Tree of nested entities (org structure, dependency tree, taxonomy).",
|
||||
steps: "Multi-step plan with statuses (queued/running/done/error/skipped).",
|
||||
checklist: "Onboarding checklist with completable tasks (links/CTAs allowed).",
|
||||
welcome: "Hero welcome card with title, description, primary/secondary CTA.",
|
||||
hint: "Tip / lightbulb card with tone (info/success/warning/neutral/primary).",
|
||||
}
|
||||
|
||||
const SCHEMAS: Record<BlockKind, string> = {
|
||||
kpi: `\`\`\`kpi
|
||||
{ "items": [
|
||||
{ "label": "Tenants", "value": 42 },
|
||||
{ "label": "Active users", "value": 318, "unit": "/day" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: items[]: { label: string, value: string|number, unit?: string }.
|
||||
Use 2–6 items. Don't repeat the numbers in prose.`,
|
||||
|
||||
table: `\`\`\`table
|
||||
{ "columns": [
|
||||
{ "id": "slug", "header": "Tenant" },
|
||||
{ "id": "users", "header": "Users", "align": "right" },
|
||||
{ "id": "status", "header": "Status" }
|
||||
],
|
||||
"rows": [
|
||||
{ "slug": "acme", "users": 42, "status": "active" },
|
||||
{ "slug": "globex", "users": 18, "status": "suspended" }
|
||||
],
|
||||
"idKey": "slug" }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- columns[]: { id: string, header?: string, align?: "left"|"center"|"right", sortable?: boolean }
|
||||
- rows[]: object keyed by column id.
|
||||
- idKey?: string — column whose value is the row id (defaults to first column).
|
||||
Use for ≥3 rows OR ≥3 columns. Smaller lists → markdown table.`,
|
||||
|
||||
"chart-bar": `\`\`\`chart-bar
|
||||
{ "title": "Users by tenant",
|
||||
"data": [
|
||||
{ "label": "acme", "value": 42 },
|
||||
{ "label": "globex", "value": 18 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||
≤8 categories. For more, use a table.`,
|
||||
|
||||
"chart-line": `\`\`\`chart-line
|
||||
{ "title": "Signups over time",
|
||||
"series": [
|
||||
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, series[]: { x: number, y: number }.
|
||||
Use for ordered numeric series ≥3 points. x is treated as a numeric axis.`,
|
||||
|
||||
"chart-donut": `\`\`\`chart-donut
|
||||
{ "title": "Status breakdown",
|
||||
"data": [
|
||||
{ "label": "active", "value": 38 },
|
||||
{ "label": "suspended", "value": 4 }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||
≤5 slices. Skip if one slice would be >90%.`,
|
||||
|
||||
"chart-spark": `\`\`\`chart-spark
|
||||
{ "values": [3, 5, 4, 8, 12, 9, 14] }
|
||||
\`\`\`
|
||||
Fields: values: number[], width?, height?, stroke?, fill?.
|
||||
Use inline next to a single number to show its recent trend.`,
|
||||
|
||||
code: `\`\`\`code
|
||||
{ "code": "SELECT count(*) FROM tenants WHERE status='active';",
|
||||
"language": "sql",
|
||||
"title": "Active tenant count",
|
||||
"lineNumbers": false,
|
||||
"highlightLines": [] }
|
||||
\`\`\`
|
||||
Fields: code: string, language?: string, title?: string, lineNumbers?: boolean, highlightLines?: number[].
|
||||
Languages with syntax: js/ts/tsx, python, rust, go, html, css, sql, json, yaml.
|
||||
Prefer this over plain markdown fences when the snippet matters (queries the user might copy, configs, etc.).`,
|
||||
|
||||
diff: `\`\`\`diff
|
||||
{ "oldCode": "max_users: 100\\n",
|
||||
"newCode": "max_users: 250\\n",
|
||||
"language": "yaml",
|
||||
"title": "Tenant quota change",
|
||||
"mode": "unified" }
|
||||
\`\`\`
|
||||
Fields: oldCode: string, newCode: string, language?: string, title?: string, mode?: "unified"|"split".
|
||||
Use for showing exactly what changed in a config, query, or file.`,
|
||||
|
||||
card: `\`\`\`card
|
||||
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "This action is destructive." }
|
||||
\`\`\`
|
||||
Three sub-kinds:
|
||||
- pill: { "kind": "pill", "status": "active"|"suspended"|"deactivated"|other, "label"?: string } — small status badge.
|
||||
- stat: { "kind": "stat", "label": string, "value": string|number } — inline metric chip.
|
||||
- callout: { "kind": "callout", "tone": "info"|"warning"|"danger"|"success", "title"?: string, "body"?: string } — banner.
|
||||
Use sparingly. For multiple metrics use \`kpi\` instead of multiple \`stat\` cards.`,
|
||||
|
||||
flowchart: `\`\`\`flowchart
|
||||
{ "nodes": [
|
||||
{ "id": "a", "type": "start", "label": "Receive request", "x": 60, "y": 20 },
|
||||
{ "id": "b", "type": "process", "label": "Validate token", "x": 60, "y": 100 },
|
||||
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 60, "y": 180 },
|
||||
{ "id": "d", "type": "process", "label": "Process", "x": 220, "y": 180 },
|
||||
{ "id": "e", "type": "end", "label": "Reject", "x": 60, "y": 280 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "a", "to": "b" },
|
||||
{ "from": "b", "to": "c" },
|
||||
{ "from": "c", "to": "d", "label": "yes" },
|
||||
{ "from": "c", "to": "e", "label": "no" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- nodes[]: { id: string, type: "start"|"end"|"process"|"decision"|"io", label: string, x: number, y: number } — coordinates in pixels (canvas auto-sizes).
|
||||
- edges[]: { from: nodeId, to: nodeId, label?: string }.
|
||||
Use for control flow, workflows, request lifecycles. Keep ≤12 nodes; lay out top-to-bottom or left-to-right with ~80–120px spacing.`,
|
||||
|
||||
orgchart: `\`\`\`orgchart
|
||||
{ "data": {
|
||||
"id": "root", "name": "Platform", "title": "Tenant",
|
||||
"children": [
|
||||
{ "id": "a", "name": "Auth", "title": "Service",
|
||||
"children": [
|
||||
{ "id": "a1", "name": "Sessions", "title": "Module" },
|
||||
{ "id": "a2", "name": "MFA", "title": "Module" }
|
||||
] },
|
||||
{ "id": "b", "name": "Billing", "title": "Service" }
|
||||
] },
|
||||
"horizontal": false }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- data: OrgNode = { id: string, name: string, title?: string, avatar?: string (url), children?: OrgNode[] }
|
||||
- horizontal?: boolean — left-to-right vs top-to-bottom (default).
|
||||
Use for nested hierarchies (org charts, dependency trees, taxonomies). Skip for flat lists.`,
|
||||
|
||||
steps: `\`\`\`steps
|
||||
{ "steps": [
|
||||
{ "id": "1", "title": "List tenants", "status": "done", "detail": "Found 42 tenants" },
|
||||
{ "id": "2", "title": "Filter suspended", "status": "running" },
|
||||
{ "id": "3", "title": "Build report", "status": "queued" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- steps[]: { id: string, title: string, status: "queued"|"planning"|"running"|"waiting"|"done"|"error"|"skipped", detail?: string, substeps?: same-shape[] }
|
||||
Use for: showing a multi-step plan you're about to execute, or a post-hoc trail of what you did. Skip for single-step actions.`,
|
||||
|
||||
checklist: `\`\`\`checklist
|
||||
{ "title": "Get started",
|
||||
"description": "Finish setting up your tenant.",
|
||||
"tasks": [
|
||||
{ "id": "1", "title": "Invite your team", "description": "Add at least one admin.", "completed": true, "estimate": "2 min" },
|
||||
{ "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets", "estimate": "5 min" },
|
||||
{ "id": "3", "title": "Set up SSO", "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
|
||||
] }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title?: string, description?: string
|
||||
- tasks[]: { id: string, title: string, description?: string, completed?: boolean, optional?: boolean, estimate?: string, href?: string }
|
||||
Use for: actionable setup lists with progress. Each task with an href becomes a click-through link. Toggling is read-only in chat (can't persist completion across turns).`,
|
||||
|
||||
welcome: `\`\`\`welcome
|
||||
{ "title": "Welcome to Arcadia Admin",
|
||||
"description": "Manage tenants, users, and platform settings from one place.",
|
||||
"badge": "v2",
|
||||
"primaryAction": { "label": "Create your first tenant", "href": "/tenants" },
|
||||
"secondaryAction": { "label": "Read the docs", "href": "/library" } }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title: string (required), description?: string, badge?: string
|
||||
- primaryAction?, secondaryAction?: { label: string, href?: string }
|
||||
Use sparingly — once at the top of a thread that's introducing a feature/product, never as a recurring response.`,
|
||||
|
||||
hint: `\`\`\`hint
|
||||
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
|
||||
\`\`\`
|
||||
Fields:
|
||||
- title?: string, body: string
|
||||
- tone?: "info"|"success"|"warning"|"neutral"|"primary" (default "info")
|
||||
- action?: { label: string, href?: string }
|
||||
Use for: discoverability tips, gotchas, "did you know". One per reply.`,
|
||||
}
|
||||
|
||||
const ALL_KINDS = Object.keys(SCHEMAS) as BlockKind[]
|
||||
|
||||
export function isBlockKind(kind: string): kind is BlockKind {
|
||||
return (ALL_KINDS as string[]).includes(kind)
|
||||
}
|
||||
|
||||
export function getBlockSchema(kind: string): string | null {
|
||||
if (!isBlockKind(kind)) return null
|
||||
return SCHEMAS[kind]
|
||||
}
|
||||
|
||||
/** Thin index suitable for the always-on system prompt. */
|
||||
export function blockIndexForPrompt(): string {
|
||||
const lines = ALL_KINDS.map((k) => ` ${k} — ${BLOCK_INDEX[k]}`)
|
||||
return [
|
||||
"Rich output: when a UI primitive will communicate better than prose, emit a typed fenced ```<kind>\\n<json>\\n``` block. The chat renderer turns it into a @crema/*-ui component inline at that position.",
|
||||
"",
|
||||
"Available kinds:",
|
||||
...lines,
|
||||
"",
|
||||
"Before emitting a block for the FIRST time in a thread, call get_block_schema(kind) to fetch the exact JSON shape and field rules. Once you've seen a schema in this conversation, reuse it from memory.",
|
||||
"Always lead with one short sentence of prose, then the block. Don't repeat block data in prose.",
|
||||
"JSON must be valid (double quotes, no trailing commas). If unsure of the schema, fetch it.",
|
||||
].join("\n")
|
||||
}
|
||||
168
app/lib/capabilities.ts
Normal file
168
app/lib/capabilities.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// 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
|
||||
// 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",
|
||||
]
|
||||
|
||||
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",
|
||||
"/activity": "tenant.activity",
|
||||
"/settings": "tenant.settings",
|
||||
"/apps": "tenant.apps",
|
||||
"/plan": "tenant.plan",
|
||||
"/entitlements": "tenant.entitlements",
|
||||
|
||||
"/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",
|
||||
|
||||
"/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
|
||||
}
|
||||
38
app/lib/gateway.ts
Normal file
38
app/lib/gateway.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Arcadia LLM-gateway client.
|
||||
//
|
||||
// The integration registry lives on arcadia-llm-gateway, not arcadia-app, so
|
||||
// it needs its own ArcadiaClient pointed at a different base URL. Everything
|
||||
// else is identical to the arcadia-app client: the same access token (the
|
||||
// gateway validates arcadia-app JWTs via the shared Guardian secret) and the
|
||||
// same 401 cleanup. The gateway's CORS already allows localhost + any
|
||||
// *.sky-ai.com origin, so the browser calls it directly.
|
||||
|
||||
import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015"
|
||||
|
||||
const ACCESS_TOKEN_KEY = "arcadia_access_token"
|
||||
const REFRESH_TOKEN_KEY = "arcadia_refresh_token"
|
||||
|
||||
let client: ArcadiaClient | null = null
|
||||
|
||||
export function gatewayClient(): ArcadiaClient {
|
||||
if (!client) {
|
||||
client = createArcadiaClient({
|
||||
baseUrl: GATEWAY_URL,
|
||||
getToken: () =>
|
||||
typeof window === "undefined" ? null : sessionStorage.getItem(ACCESS_TOKEN_KEY),
|
||||
onUnauthorized: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
export function useGatewayClient(): ArcadiaClient {
|
||||
return gatewayClient()
|
||||
}
|
||||
49
app/lib/jwt.ts
Normal file
49
app/lib/jwt.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Tiny JWT helpers — we never *verify* tokens client-side (the server
|
||||
// is the only authority), we just decode the payload to read claims
|
||||
// the UI uses for nav gating + tenant context.
|
||||
|
||||
export type ArcadiaClaims = {
|
||||
sub?: string
|
||||
email?: string
|
||||
tenant_id?: string
|
||||
tenant_slug?: string
|
||||
roles?: string[]
|
||||
available_tenants?: AvailableTenantClaim[]
|
||||
exp?: number
|
||||
iat?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export type AvailableTenantClaim = {
|
||||
id?: string
|
||||
slug?: string
|
||||
name?: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): string {
|
||||
const pad = "=".repeat((4 - (s.length % 4)) % 4)
|
||||
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/")
|
||||
if (typeof atob === "function") return atob(b64)
|
||||
// Node fallback (SSR / tests)
|
||||
return Buffer.from(b64, "base64").toString("binary")
|
||||
}
|
||||
|
||||
export function decodeJwt(token: string): ArcadiaClaims | null {
|
||||
if (!token) return null
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
try {
|
||||
const raw = b64urlDecode(parts[1])
|
||||
// Handle UTF-8: atob returns binary string; reconstruct UTF-8.
|
||||
const utf8 =
|
||||
typeof TextDecoder !== "undefined"
|
||||
? new TextDecoder().decode(
|
||||
Uint8Array.from(raw, (c) => c.charCodeAt(0)),
|
||||
)
|
||||
: raw
|
||||
return JSON.parse(utf8) as ArcadiaClaims
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
91
app/lib/llm-config-bootstrap.tsx
Normal file
91
app/lib/llm-config-bootstrap.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// One-time bootstrap of the active LLM settings from arcadia.
|
||||
//
|
||||
// On mount (and again whenever the session changes), if the operator has no
|
||||
// active LLM settings in localStorage, fetch the tenant's enabled
|
||||
// configurations and seed the active settings from the preferred row:
|
||||
//
|
||||
// 1. Any row with `metadata.default === true` (operator-marked default).
|
||||
// 2. Otherwise the first enabled row.
|
||||
//
|
||||
// Once active settings exist, this component does nothing — the settings
|
||||
// panel remains the place to switch between configs.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useArcadiaClient } from "@crema/arcadia-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
|
||||
}
|
||||
90
app/lib/profile-bootstrap.tsx
Normal file
90
app/lib/profile-bootstrap.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
// Fetches the arcadia profile on app boot (and after login) and caches
|
||||
// the resolved avatar URL in localStorage so the appbar's <Avatar> shows
|
||||
// immediately, without waiting for the user to navigate to /profile.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
import { fetchDigitalObjectAsBlobUrl } from "~/lib/arcadia/digital-objects"
|
||||
import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles"
|
||||
import { loadProfile, saveProfile } from "~/lib/profile"
|
||||
|
||||
export function ProfileBootstrap() {
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const tryBootstrap = async () => {
|
||||
const token =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("arcadia_access_token")
|
||||
: null
|
||||
if (!token) return
|
||||
try {
|
||||
const p = await getProfile(arcadia)
|
||||
if (cancelled) return
|
||||
|
||||
const persistentUrl = pickAvatarUrl(p)
|
||||
const current = loadProfile()
|
||||
const cachedIsStaleBlob = current.avatarUrl?.startsWith("blob:") ?? false
|
||||
|
||||
if (persistentUrl) {
|
||||
if (current.avatarUrl !== persistentUrl) {
|
||||
saveProfile({ ...current, avatarUrl: persistentUrl })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No persistent variant yet but the user has an avatar — fetch
|
||||
// the raw bytes as a blob URL. This also covers the "stale blob
|
||||
// URL from previous session" case: replace it with a fresh one.
|
||||
if (p.avatar_digital_object_id) {
|
||||
if (cachedIsStaleBlob) {
|
||||
// Clear the stale URL immediately so the appbar drops back
|
||||
// to initials while we refetch (better than a broken image).
|
||||
saveProfile({ ...current, avatarUrl: "" })
|
||||
}
|
||||
try {
|
||||
const baseUrl =
|
||||
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ??
|
||||
"http://localhost:4000"
|
||||
const tenantId =
|
||||
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ??
|
||||
"default"
|
||||
const blobUrl = await fetchDigitalObjectAsBlobUrl(
|
||||
baseUrl,
|
||||
p.avatar_digital_object_id,
|
||||
token,
|
||||
tenantId,
|
||||
)
|
||||
if (cancelled) return
|
||||
const fresh = loadProfile()
|
||||
saveProfile({ ...fresh, avatarUrl: blobUrl })
|
||||
} catch {
|
||||
// Best-effort; appbar will show initials until processing completes.
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No avatar at all — clear any stale URL the cache might still hold.
|
||||
if (current.avatarUrl) {
|
||||
saveProfile({ ...current, avatarUrl: "" })
|
||||
}
|
||||
} catch {
|
||||
// 401 / network — silently skip; will retry on next session change.
|
||||
}
|
||||
}
|
||||
|
||||
void tryBootstrap()
|
||||
|
||||
const onSessionChange = () => void tryBootstrap()
|
||||
window.addEventListener("crema:session-change", onSessionChange)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener("crema:session-change", onSessionChange)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
// User profile — name, email, title, bio, signature, default agent.
|
||||
// Persisted in localStorage; reactive across tabs.
|
||||
// Local mirror of the resolved avatar URL, so the appbar can render the
|
||||
// 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"
|
||||
|
||||
export type Profile = {
|
||||
name: string
|
||||
email: string
|
||||
title: string
|
||||
bio: string
|
||||
signature: string
|
||||
avatarUrl: string
|
||||
defaultAgentId: string
|
||||
}
|
||||
|
||||
export const DEFAULT_PROFILE: Profile = {
|
||||
name: "Signed-in user",
|
||||
email: "user@example.com",
|
||||
title: "",
|
||||
bio: "",
|
||||
signature: "",
|
||||
avatarUrl: "",
|
||||
defaultAgentId: "",
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.profile"
|
||||
@@ -33,27 +23,10 @@ function readFromStorage(): Profile {
|
||||
if (!raw) return DEFAULT_PROFILE
|
||||
const parsed = JSON.parse(raw) as Partial<Profile>
|
||||
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:
|
||||
typeof parsed.avatarUrl === "string"
|
||||
? parsed.avatarUrl
|
||||
: DEFAULT_PROFILE.avatarUrl,
|
||||
defaultAgentId:
|
||||
typeof parsed.defaultAgentId === "string"
|
||||
? parsed.defaultAgentId
|
||||
: DEFAULT_PROFILE.defaultAgentId,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_PROFILE
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest"
|
||||
|
||||
import {
|
||||
createResource,
|
||||
deleteResource,
|
||||
listResources,
|
||||
updateResource,
|
||||
} from "./resources"
|
||||
|
||||
describe("resources", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("creates, updates, and deletes", () => {
|
||||
expect(listResources()).toEqual([])
|
||||
const r = createResource({ name: "Test", owner: "Atlas" })
|
||||
expect(r.status).toBe("active")
|
||||
expect(listResources()).toHaveLength(1)
|
||||
|
||||
const updated = updateResource(r.id, { status: "paused" })
|
||||
expect(updated?.status).toBe("paused")
|
||||
expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt)
|
||||
|
||||
deleteResource(r.id)
|
||||
expect(listResources()).toEqual([])
|
||||
})
|
||||
|
||||
it("ignores updates for unknown ids", () => {
|
||||
expect(updateResource("missing", { name: "x" })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,157 +0,0 @@
|
||||
// Resource store — example domain entity.
|
||||
// Backed by localStorage today, but written so each call is a single function
|
||||
// you can swap with `api.get/post/put/del` once you have a real backend.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
export type Resource = {
|
||||
id: string
|
||||
name: string
|
||||
status: "active" | "paused" | "archived"
|
||||
owner: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.resources"
|
||||
const CHANGE_EVENT = "crema:resources-change"
|
||||
|
||||
function newId() {
|
||||
return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
function readFromStorage(): Resource[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(r): r is Resource =>
|
||||
r &&
|
||||
typeof r.id === "string" &&
|
||||
typeof r.name === "string" &&
|
||||
["active", "paused", "archived"].includes(r.status) &&
|
||||
typeof r.owner === "string" &&
|
||||
typeof r.createdAt === "number" &&
|
||||
typeof r.updatedAt === "number",
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function write(items: Resource[]) {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD — these mirror what `api.get/post/put/del` would look like.
|
||||
export function listResources(): Resource[] {
|
||||
return readFromStorage()
|
||||
}
|
||||
|
||||
export function createResource(input: {
|
||||
name: string
|
||||
owner: string
|
||||
status?: Resource["status"]
|
||||
}): Resource {
|
||||
const now = Date.now()
|
||||
const r: Resource = {
|
||||
id: newId(),
|
||||
name: input.name,
|
||||
owner: input.owner,
|
||||
status: input.status ?? "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
write([r, ...readFromStorage()])
|
||||
return r
|
||||
}
|
||||
|
||||
export function updateResource(
|
||||
id: string,
|
||||
patch: Partial<Omit<Resource, "id" | "createdAt">>,
|
||||
): Resource | null {
|
||||
const items = readFromStorage()
|
||||
let updated: Resource | null = null
|
||||
const next = items.map((r) => {
|
||||
if (r.id !== id) return r
|
||||
updated = { ...r, ...patch, updatedAt: Date.now() }
|
||||
return updated
|
||||
})
|
||||
if (updated) write(next)
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteResource(id: string) {
|
||||
write(readFromStorage().filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
let cached: Resource[] | null = null
|
||||
function subscribe(cb: () => void) {
|
||||
const onChange = () => {
|
||||
cached = null
|
||||
cb()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEY) onChange()
|
||||
})
|
||||
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
}
|
||||
function getSnapshot(): Resource[] {
|
||||
if (!cached) cached = readFromStorage()
|
||||
return cached
|
||||
}
|
||||
function getServerSnapshot(): Resource[] {
|
||||
return []
|
||||
}
|
||||
|
||||
export function useResources(): Resource[] {
|
||||
const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
useEffect(() => {
|
||||
cached = null
|
||||
}, [])
|
||||
return v
|
||||
}
|
||||
|
||||
/** Seed a few rows on first load so the table isn't empty. */
|
||||
export function seedResourcesIfEmpty() {
|
||||
if (typeof window === "undefined") return
|
||||
if (localStorage.getItem(STORAGE_KEY)) return
|
||||
const now = Date.now()
|
||||
const seed: Resource[] = [
|
||||
{
|
||||
id: newId(),
|
||||
name: "Acme dashboard",
|
||||
status: "active",
|
||||
owner: "Atlas",
|
||||
createdAt: now - 86_400_000 * 3,
|
||||
updatedAt: now - 3600_000,
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
name: "Onboarding pipeline",
|
||||
status: "paused",
|
||||
owner: "Forge",
|
||||
createdAt: now - 86_400_000 * 7,
|
||||
updatedAt: now - 86_400_000,
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
name: "Q1 report draft",
|
||||
status: "archived",
|
||||
owner: "Inkwell",
|
||||
createdAt: now - 86_400_000 * 30,
|
||||
updatedAt: now - 86_400_000 * 14,
|
||||
},
|
||||
]
|
||||
write(seed)
|
||||
}
|
||||
113
app/lib/search-admin.ts
Normal file
113
app/lib/search-admin.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Client for the arcadia-search admin sidecar (`/admin/*` on the
|
||||
// search box, default :7801). Used by the Search route to manage
|
||||
// tenants, corpora, and trigger rebuilds.
|
||||
//
|
||||
// Auth: static bearer token from VITE_ARCADIA_SEARCH_ADMIN_TOKEN,
|
||||
// matched constant-time against ADMIN_TOKEN on the sidecar. The token
|
||||
// ships in the client bundle — fine for an internal admin tool on a
|
||||
// trusted network; in production, proxy through arcadia-core.
|
||||
|
||||
const BASE_URL =
|
||||
import.meta.env.VITE_ARCADIA_SEARCH_ADMIN_URL ?? "http://127.0.0.1:7801"
|
||||
const TOKEN = import.meta.env.VITE_ARCADIA_SEARCH_ADMIN_TOKEN ?? ""
|
||||
|
||||
export class SearchAdminError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = "SearchAdminError"
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (TOKEN) headers["Authorization"] = `Bearer ${TOKEN}`
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "")
|
||||
throw new SearchAdminError(
|
||||
text || `${res.status} ${res.statusText}`,
|
||||
res.status,
|
||||
)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export type TenantSummary = { id: string; corpus_count: number }
|
||||
export type CorpusSummary = {
|
||||
tenant: string
|
||||
corpus: string
|
||||
indexed: boolean
|
||||
num_docs: number | null
|
||||
live_path: string | null
|
||||
}
|
||||
export type CorpusDetail = {
|
||||
config: Record<string, unknown>
|
||||
status: CorpusSummary
|
||||
}
|
||||
export type RebuildResult = {
|
||||
tenant: string
|
||||
corpus: string
|
||||
chunk_count: number
|
||||
live_path: string
|
||||
built_at: string
|
||||
}
|
||||
|
||||
export const searchAdmin = {
|
||||
baseUrl: BASE_URL,
|
||||
hasToken: !!TOKEN,
|
||||
listTenants: () =>
|
||||
call<{ tenants: TenantSummary[] }>("GET", "/admin/tenants"),
|
||||
createTenant: (id: string) =>
|
||||
call<TenantSummary>("POST", "/admin/tenants", { id }),
|
||||
deleteTenant: (id: string) =>
|
||||
call<void>("DELETE", `/admin/tenants/${encodeURIComponent(id)}`),
|
||||
listCorpora: (tenant: string) =>
|
||||
call<{ corpora: CorpusSummary[] }>(
|
||||
"GET",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora`,
|
||||
),
|
||||
createCorpus: (tenant: string, body: Record<string, unknown>) =>
|
||||
call<CorpusSummary>(
|
||||
"POST",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora`,
|
||||
body,
|
||||
),
|
||||
getCorpus: (tenant: string, corpus: string) =>
|
||||
call<CorpusDetail>(
|
||||
"GET",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
),
|
||||
updateCorpus: (
|
||||
tenant: string,
|
||||
corpus: string,
|
||||
body: Record<string, unknown>,
|
||||
) =>
|
||||
call<CorpusSummary>(
|
||||
"PUT",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
body,
|
||||
),
|
||||
deleteCorpus: (tenant: string, corpus: string) =>
|
||||
call<void>(
|
||||
"DELETE",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}`,
|
||||
),
|
||||
rebuild: (tenant: string, corpus: string) =>
|
||||
call<RebuildResult>(
|
||||
"POST",
|
||||
`/admin/tenants/${encodeURIComponent(tenant)}/corpora/${encodeURIComponent(corpus)}/rebuild`,
|
||||
),
|
||||
restart: () => call<void>("POST", "/admin/restart"),
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest"
|
||||
|
||||
import { hasSession, loadSession, signIn, signOut } from "./session"
|
||||
import {
|
||||
hasSession,
|
||||
loadSession,
|
||||
persistFromArcadiaLogin,
|
||||
signOut,
|
||||
updateSessionUser,
|
||||
} from "./session"
|
||||
|
||||
describe("session", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it("starts unauthenticated", () => {
|
||||
@@ -12,20 +19,31 @@ describe("session", () => {
|
||||
expect(hasSession()).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty credentials", async () => {
|
||||
await expect(signIn("", "")).rejects.toThrow(/required/i)
|
||||
await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i)
|
||||
expect(hasSession()).toBe(false)
|
||||
})
|
||||
|
||||
it("creates a session on sign-in and clears on sign-out", async () => {
|
||||
const session = await signIn("alice@example.com", "hunter2")
|
||||
it("persists from an arcadia login and clears on sign-out", () => {
|
||||
const session = persistFromArcadiaLogin(
|
||||
{ access_token: "tok-123", refresh_token: "ref-456" },
|
||||
{ id: "u1", email: "alice@example.com", full_name: "Alice" },
|
||||
)
|
||||
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(sessionStorage.getItem("arcadia_access_token")).toBe("tok-123")
|
||||
|
||||
signOut()
|
||||
expect(loadSession()).toBeNull()
|
||||
expect(hasSession()).toBe(false)
|
||||
expect(sessionStorage.getItem("arcadia_access_token")).toBeNull()
|
||||
})
|
||||
|
||||
it("updates the stored session identity in place", () => {
|
||||
persistFromArcadiaLogin(
|
||||
{ access_token: "tok" },
|
||||
{ id: "u1", email: "a@x.com", full_name: "Alice" },
|
||||
)
|
||||
updateSessionUser({ name: "Alice Smith", email: "alice@x.com" })
|
||||
const s = loadSession()
|
||||
expect(s?.name).toBe("Alice Smith")
|
||||
expect(s?.email).toBe("alice@x.com")
|
||||
expect(s?.token).toBe("tok")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
// Session — minimal auth scaffold backed by localStorage.
|
||||
// Swap loadSession/signIn/signOut for real calls (cookies + server) when you
|
||||
// wire a backend. The shape here matches what AppShell + useUser expect.
|
||||
// Sign-in is owned by `persistFromArcadiaLogin`, which is called by the auth
|
||||
// routes after a successful arcadia API exchange. The shape here matches what
|
||||
// AppShell + useUser expect.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
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 = {
|
||||
userId: string
|
||||
@@ -13,6 +22,11 @@ export type Session = {
|
||||
token: string
|
||||
// Issued at, ms since epoch.
|
||||
issuedAt: number
|
||||
// Active membership context — derived from the JWT.
|
||||
tenantId?: string
|
||||
tenantSlug?: string
|
||||
roles: string[]
|
||||
availableTenants: AvailableTenant[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.session"
|
||||
@@ -40,6 +54,18 @@ function readFromStorage(): Session | null {
|
||||
token: parsed.token,
|
||||
issuedAt:
|
||||
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[])
|
||||
: [],
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -50,35 +76,6 @@ export function loadSession(): Session | null {
|
||||
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() {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
@@ -100,12 +97,31 @@ export function persistFromArcadiaLogin(
|
||||
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
|
||||
user?.email ||
|
||||
"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 = {
|
||||
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
|
||||
name,
|
||||
email: user?.email ?? "",
|
||||
token: tokens.access_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 : [],
|
||||
availableTenants,
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
|
||||
@@ -116,6 +132,26 @@ export function persistFromArcadiaLogin(
|
||||
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. */
|
||||
export function hasSession(): boolean {
|
||||
return !!readFromStorage()
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
import type { Route } from "./+types/root"
|
||||
import "./app.css"
|
||||
|
||||
import { ToastProvider } from "@crema/notification-ui"
|
||||
import { ToastProvider, Toaster } from "@crema/notification-ui"
|
||||
import { CommandBusProvider } from "@crema/action-bus"
|
||||
import { ArcadiaProvider } from "@crema/arcadia-client"
|
||||
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
|
||||
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
|
||||
// CREMA:PROVIDERS-IMPORTS
|
||||
|
||||
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
|
||||
@@ -28,7 +30,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<Links />
|
||||
<script
|
||||
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>
|
||||
@@ -61,7 +63,10 @@ export default function App() {
|
||||
}}
|
||||
>
|
||||
<CommandBusProvider>
|
||||
<LlmConfigBootstrap />
|
||||
<ProfileBootstrap />
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
</CommandBusProvider>
|
||||
</ArcadiaProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"
|
||||
|
||||
export default [
|
||||
index("routes/home.tsx"),
|
||||
route("resources", "routes/resources.tsx"),
|
||||
route("activity", "routes/activity.tsx"),
|
||||
route("assistant", "routes/assistant.tsx"),
|
||||
route("ai", "routes/ai.tsx"),
|
||||
@@ -10,11 +9,28 @@ export default [
|
||||
route("settings", "routes/settings.tsx"),
|
||||
route("profile", "routes/profile.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("storage", "routes/storage.tsx"),
|
||||
route("users", "routes/users.tsx"),
|
||||
route("secrets", "routes/secrets.tsx"),
|
||||
route("webhooks", "routes/webhooks.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("apps", "routes/apps.tsx"),
|
||||
route("plan", "routes/plan.tsx"),
|
||||
route("entitlements", "routes/entitlements.tsx"),
|
||||
route("integrations", "routes/integrations.tsx"),
|
||||
// CREMA:ROUTES
|
||||
] satisfies RouteConfig
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Activity, Eye, RefreshCw } from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
@@ -48,7 +47,7 @@ import {
|
||||
} from "~/lib/arcadia/audit-logs"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Audit log")
|
||||
|
||||
@@ -188,7 +187,7 @@ export default function ActivityRoute() {
|
||||
}),
|
||||
[logs],
|
||||
)
|
||||
useRegisterAdminContext("audit_log", summary)
|
||||
useRegisterContext("audit_log", summary)
|
||||
|
||||
const table = useTable<AuditLog>({
|
||||
data: logs,
|
||||
@@ -201,29 +200,9 @@ export default function ActivityRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Audit log">
|
||||
<div className="p-8">
|
||||
<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">
|
||||
<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">Audit log</h1>
|
||||
|
||||
1045
app/routes/ai.tsx
1045
app/routes/ai.tsx
File diff suppressed because it is too large
Load Diff
823
app/routes/announcements.tsx
Normal file
823
app/routes/announcements.tsx
Normal file
@@ -0,0 +1,823 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Megaphone,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
ActionsCell,
|
||||
BadgeCell,
|
||||
DataTable,
|
||||
DateCell,
|
||||
Pagination,
|
||||
useTable,
|
||||
type ActionItem,
|
||||
type BadgeTone,
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
|
||||
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 { 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 [items, setItems] = useState<Announcement[]>([])
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
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),
|
||||
listTenants(arcadia).catch(() => [] as Tenant[]),
|
||||
])
|
||||
setItems(a)
|
||||
setTenants(t)
|
||||
setRefreshedAt(Date.now())
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
|
||||
} 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 })
|
||||
setInfo(a.active ? "Announcement deactivated." : "Announcement activated.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Toggle failed.")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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],
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
<SearchInput
|
||||
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">
|
||||
<LoadingOverlay
|
||||
active={loading && items.length === 0}
|
||||
label="Loading announcements…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<div
|
||||
className="grid size-14 place-items-center rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at center, color-mix(in oklch, var(--primary) 22%, transparent), transparent 70%)",
|
||||
}}
|
||||
>
|
||||
<Megaphone
|
||||
className="size-6"
|
||||
style={{ color: "var(--primary)" }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
title={search ? "No announcements match." : "No announcements yet."}
|
||||
description={
|
||||
search
|
||||
? "Try a different search."
|
||||
: "Post your first banner. Show it to everyone, or scope it to a single tenant."
|
||||
}
|
||||
action={
|
||||
search ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSearch("")}
|
||||
data-action="announcements-clear-search"
|
||||
>
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
try {
|
||||
await deleteAnnouncement(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
setInfo("Announcement deleted.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AnnouncementEditorDialog
|
||||
state={editor}
|
||||
tenants={tenants}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</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,
|
||||
onError,
|
||||
}: {
|
||||
state: Editor
|
||||
tenants: Tenant[]
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => 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)
|
||||
const [localError, setLocalError] = useState<string | null>(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 () => {
|
||||
onError(null)
|
||||
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("Announcement updated.")
|
||||
} else {
|
||||
await createAnnouncement(arcadia, input)
|
||||
await onSaved("Announcement posted.")
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ArcadiaError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed."
|
||||
setLocalError(msg)
|
||||
} 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>
|
||||
|
||||
{localError ? (
|
||||
<AlertBanner
|
||||
variant="error"
|
||||
dismissible
|
||||
onDismiss={() => setLocalError(null)}
|
||||
>
|
||||
{localError}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-title">Title</Label>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
44
app/routes/apps.tsx
Normal file
44
app/routes/apps.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// Tenant-scoped "Apps" — placeholder. Real surface is the apps this
|
||||
// tenant publishes (and their per-app users/grants on the personal
|
||||
// cloud side). Wired into the nav so tenant admins see the route they
|
||||
// expect; data layer follows.
|
||||
|
||||
import { LayoutGrid } from "lucide-react"
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
|
||||
export default function AppsRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutGrid className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Apps</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apps this tenant publishes — and the users that have granted them
|
||||
access to their personal clouds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming soon</CardTitle>
|
||||
<CardDescription>
|
||||
App authoring lives in arcadia-agents-manager today. This view will
|
||||
surface published apps + per-app grants once the catalog endpoint
|
||||
is wired.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,15 @@ const PROBE_TIMEOUT_MS = 3000
|
||||
// "Available actions" in the system prompt only lists what's on screen NOW;
|
||||
// this catalog tells the model what exists elsewhere so it can plan
|
||||
// multi-step flows (navigate → wait_for → fill → click) in a single block.
|
||||
// Rich-output protocol: typed fenced blocks the chat renderer turns into UI
|
||||
// from @crema/*-ui. The system prompt only carries a thin INDEX (kind →
|
||||
// one-line purpose) — full schemas live in app/lib/block-schemas.ts and are
|
||||
// fetched on demand via the get_block_schema tool. Adding a new block kind
|
||||
// = edit block-schemas.ts + the renderer; no prompt edit required.
|
||||
import { blockIndexForPrompt } from "~/lib/block-schemas"
|
||||
const RICH_OUTPUT_PREFACE = blockIndexForPrompt()
|
||||
|
||||
|
||||
const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
|
||||
|
||||
You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
||||
@@ -44,7 +53,7 @@ Rules:
|
||||
|
||||
Known action ids across the app (use these even if not in "Available actions" — the page may not be mounted yet):
|
||||
|
||||
Sidebar / nav: nav-overview, nav-tenants, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
||||
Sidebar / nav: nav-overview, nav-tenants, nav-resources, nav-activity, nav-search, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
|
||||
Appbar: appbar-search (input), appbar-scripts, appbar-font-size, appbar-surface, appbar-background, theme-toggle, appbar-notifications, appbar-avatar
|
||||
Account menu (after click appbar-avatar): avatar-profile (→ /profile), avatar-settings, avatar-help, avatar-signout
|
||||
Profile page: profile-avatar-upload, profile-avatar-remove, profile-name, profile-email, profile-title, profile-bio, profile-signature, profile-default-agent, profile-save, profile-revert, profile-reset
|
||||
@@ -57,6 +66,7 @@ Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-th
|
||||
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
||||
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
|
||||
Tenants page: tenants-refresh, tenants-search (input), tenants-create. Per-row (use the tenant's slug — see the "tenants" surface in Admin context for available slugs): tenant-<slug>-actions (open the kebab first), tenant-<slug>-suspend, tenant-<slug>-activate, tenant-<slug>-deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant-<slug>-actions, wait_for tenant-<slug>-suspend, click tenant-<slug>-suspend.
|
||||
Search page (/search — manage arcadia-search tenants and corpora): search-refresh, search-restart (with confirm), search-new-tenant, search-new-corpus, corpora-search (input). Per-tenant chip: tenant-<id>-delete. Per-corpus row (id is "<tenant>-<corpus>"): corpus-<tenant>-<corpus>-actions (kebab), corpus-<tenant>-<corpus>-rebuild, corpus-<tenant>-<corpus>-edit, corpus-<tenant>-<corpus>-delete. New-tenant dialog: tenant-form-id (input), tenant-form-cancel, tenant-form-save. New/edit-corpus dialog: corpus-form-tenant (select, only when creating), corpus-form-config (textarea, JSON), corpus-form-cancel, corpus-form-save. Recipe to rebuild a corpus: click nav-search, wait_for search-refresh, click corpus-<tenant>-<corpus>-actions, wait_for corpus-<tenant>-<corpus>-rebuild, click corpus-<tenant>-<corpus>-rebuild. (NOTE: prefer the \`rebuild_search_corpus\` tool over UI-driving for rebuilds — it's atomic and gives a structured result; UI-drive only when the user explicitly wants to see it happen.)
|
||||
Login page: login-email, login-password, login-submit
|
||||
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
|
||||
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
|
||||
@@ -80,7 +90,7 @@ wait_for assistant-ui-control
|
||||
\`\`\`"`
|
||||
|
||||
|
||||
import { formatAdminContextForPrompt } from "~/lib/admin-context"
|
||||
import { formatContextForPrompt } from "@crema/aifirst-ui/context"
|
||||
import {
|
||||
buildDenialMessages,
|
||||
classifyCalls,
|
||||
@@ -101,9 +111,11 @@ function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean):
|
||||
const persona = activeAgent
|
||||
? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
|
||||
: ""
|
||||
const ctx = formatAdminContextForPrompt()
|
||||
const ctx = formatContextForPrompt()
|
||||
const parts = [
|
||||
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
|
||||
"Two retrieval surfaces exist for documentation/knowledge: `search_docs` (browser-side, BM25 over the bundled arcadia docs — fast, always available, small corpus) and `search_kb` (server-side, BM25 over arcadia-search — `docs` (arcadia parity), `operator-tools` (arcadia-search + arcadia-admin admin docs), `files` (uploaded files), plus any custom corpora the operator adds via /search). For questions about the bundled arcadia docs either is fine; prefer `search_kb` for richer hits or for content outside the bundled docs (uploaded files, the admin tooling itself, tenant-specific knowledge). If unsure what corpora exist, call `list_search_corpora`. When `search_kb` returns a chunk_id you want to expand, call `read_chunk(chunk_id, corpus)`. When the operator says results look stale or after they've uploaded new files, call `rebuild_search_corpus(tenant, corpus)`.",
|
||||
RICH_OUTPUT_PREFACE,
|
||||
ARCADIA_KNOWLEDGE,
|
||||
persona,
|
||||
ctx,
|
||||
@@ -136,7 +148,6 @@ function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise
|
||||
import {
|
||||
LLMProvider,
|
||||
MockLLM,
|
||||
OpenAICompatibleAdapter,
|
||||
listModels,
|
||||
useChat,
|
||||
useCompletion,
|
||||
@@ -154,7 +165,11 @@ import {
|
||||
runActionBlocks,
|
||||
trimMessages,
|
||||
} from "@crema/action-bus"
|
||||
import { useLLMSettings } from "~/lib/llm-settings"
|
||||
import {
|
||||
buildAdapter,
|
||||
getProvider,
|
||||
useSettings as useProviderSettings,
|
||||
} from "@crema/llm-providers-ui"
|
||||
import {
|
||||
composeSystemPrompt,
|
||||
loadActiveAgentId,
|
||||
@@ -219,13 +234,13 @@ const mockAdapter = new MockLLM({
|
||||
},
|
||||
{
|
||||
matches: (req) =>
|
||||
/(take me to|open|navigate|go to).*(resources|library|settings|activity|assistant|overview|home)/i.test(
|
||||
/(take me to|open|navigate|go to).*(tenants|users|library|settings|activity|assistant|overview|home)/i.test(
|
||||
req.messages.at(-1)?.content ?? "",
|
||||
),
|
||||
response: [
|
||||
"On it.\n\n",
|
||||
"```action\n",
|
||||
"navigate /resources\n",
|
||||
"navigate /tenants\n",
|
||||
"```\n",
|
||||
],
|
||||
},
|
||||
@@ -233,7 +248,9 @@ const mockAdapter = new MockLLM({
|
||||
})
|
||||
|
||||
export default function AssistantRoute() {
|
||||
const settings = useLLMSettings()
|
||||
const settings = useProviderSettings()
|
||||
const arcadia = useArcadiaClient()
|
||||
const provider = getProvider(settings.providerId)
|
||||
const agents = useAgents()
|
||||
const threads = useThreads()
|
||||
const [status, setStatus] = useState<Status>({ kind: "probing" })
|
||||
@@ -282,32 +299,108 @@ export default function AssistantRoute() {
|
||||
updateThread(id, { title })
|
||||
}, [])
|
||||
|
||||
const [adapter, setAdapter] = useState<LLMAdapter>(mockAdapter)
|
||||
|
||||
// When the user switches providers in /settings, follow.
|
||||
useEffect(() => {
|
||||
if (settings.model) setModel(settings.model)
|
||||
}, [settings.providerId, settings.model])
|
||||
|
||||
const probe = useCallback(() => {
|
||||
const ac = new AbortController()
|
||||
setStatus({ kind: "probing" })
|
||||
withTimeout(
|
||||
listModels({ baseURL: settings.baseURL, signal: ac.signal }),
|
||||
PROBE_TIMEOUT_MS,
|
||||
ac.signal,
|
||||
)
|
||||
.then((rows) => {
|
||||
|
||||
const resolveSecret = async (name: string): Promise<string> => {
|
||||
const res = await arcadia.GET<{ data: { value: string } }>(
|
||||
`/api/v1/secrets/${encodeURIComponent(name)}`,
|
||||
)
|
||||
return res.data.value
|
||||
}
|
||||
const arcadiaBaseURL =
|
||||
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
|
||||
const arcadiaTenantId =
|
||||
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
|
||||
const arcadiaAuthToken =
|
||||
typeof window !== "undefined"
|
||||
? sessionStorage.getItem("arcadia_access_token") ?? undefined
|
||||
: undefined
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const a = await buildAdapter({
|
||||
settings,
|
||||
resolveSecret,
|
||||
arcadiaBaseURL,
|
||||
arcadiaAuthToken,
|
||||
arcadiaTenantId,
|
||||
})
|
||||
setAdapter(a)
|
||||
} catch {
|
||||
setAdapter(mockAdapter)
|
||||
}
|
||||
|
||||
// Anthropic has no /v1/models endpoint — use the catalog defaults.
|
||||
if (provider.transport === "anthropic") {
|
||||
const ids = provider.defaultModels.length
|
||||
? provider.defaultModels
|
||||
: ["claude-opus-4-7"]
|
||||
setStatus({ kind: "live", models: ids })
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
|
||||
return
|
||||
}
|
||||
|
||||
const baseURL = settings.baseURL || provider.baseURL
|
||||
let apiKey: string | undefined
|
||||
if (provider.requiresKey && settings.secretName) {
|
||||
try {
|
||||
apiKey = await resolveSecret(settings.secretName)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await withTimeout(
|
||||
listModels({ baseURL, apiKey, signal: ac.signal }),
|
||||
PROBE_TIMEOUT_MS,
|
||||
ac.signal,
|
||||
)
|
||||
const ids = rows.map((m) => m.id)
|
||||
if (ids.length === 0) {
|
||||
setStatus({ kind: "mock", reason: "LM Studio returned no models" })
|
||||
setStatus({ kind: "mock", reason: "endpoint returned no models" })
|
||||
return
|
||||
}
|
||||
setStatus({ kind: "live", models: ids })
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0]))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
|
||||
} catch (err: unknown) {
|
||||
if ((err as DOMException)?.name === "AbortError") return
|
||||
setStatus({
|
||||
kind: "mock",
|
||||
reason: err instanceof Error ? err.message : "LM Studio unreachable",
|
||||
})
|
||||
})
|
||||
if (provider.defaultModels.length) {
|
||||
setStatus({ kind: "live", models: provider.defaultModels })
|
||||
setModel((cur) =>
|
||||
cur && provider.defaultModels.includes(cur)
|
||||
? cur
|
||||
: settings.model || provider.defaultModels[0],
|
||||
)
|
||||
} else {
|
||||
setStatus({
|
||||
kind: "mock",
|
||||
reason: err instanceof Error ? err.message : "endpoint unreachable",
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => ac.abort()
|
||||
}, [settings.baseURL])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
arcadia,
|
||||
settings.providerId,
|
||||
settings.baseURL,
|
||||
settings.secretName,
|
||||
settings.mode,
|
||||
settings.model,
|
||||
provider.transport,
|
||||
provider.baseURL,
|
||||
provider.requiresKey,
|
||||
])
|
||||
|
||||
useEffect(() => probe(), [probe])
|
||||
|
||||
@@ -315,19 +408,11 @@ export default function AssistantRoute() {
|
||||
if (model && model !== "mock") localStorage.setItem(STORAGE_KEY, model)
|
||||
}, [model])
|
||||
|
||||
const adapter: LLMAdapter = useMemo(
|
||||
() =>
|
||||
status.kind === "live"
|
||||
? new OpenAICompatibleAdapter({ baseURL: settings.baseURL })
|
||||
: mockAdapter,
|
||||
[status.kind, settings.baseURL],
|
||||
)
|
||||
|
||||
const activeModel =
|
||||
status.kind === "live" ? model || status.models[0] : "mock"
|
||||
|
||||
return (
|
||||
<AppShell title="Assistant">
|
||||
<AppShell>
|
||||
<LLMProvider adapter={adapter} model={activeModel}>
|
||||
<AssistantSurface
|
||||
key={`${activeThreadId}-${compactNonce}`}
|
||||
@@ -339,7 +424,7 @@ export default function AssistantRoute() {
|
||||
onModelChange={setModel}
|
||||
contextTokens={settings.contextTokens}
|
||||
responseBudget={settings.responseBudget}
|
||||
baseURL={settings.baseURL}
|
||||
baseURL={settings.baseURL || provider.baseURL}
|
||||
basePrompt={settings.systemPrompt}
|
||||
onRetryProbe={probe}
|
||||
onRemount={remount}
|
||||
|
||||
1470
app/routes/buckets.tsx
Normal file
1470
app/routes/buckets.tsx
Normal file
File diff suppressed because it is too large
Load Diff
42
app/routes/entitlements.tsx
Normal file
42
app/routes/entitlements.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// Tenant entitlements — placeholder. Lists the metered allowances
|
||||
// (AI tokens, storage GB, etc.) granted to the active tenant and how
|
||||
// much of each has been consumed. Data source not wired yet.
|
||||
|
||||
import { Gauge } from "lucide-react"
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
|
||||
export default function EntitlementsRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Gauge className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Entitlements</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Metered allowances for this tenant — included units and usage to
|
||||
date per meter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming soon</CardTitle>
|
||||
<CardDescription>
|
||||
Personal-cloud entitlements are tracked per account today. A
|
||||
tenant-rollup endpoint is pending.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
HeartPulse,
|
||||
RefreshCw,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import { AlertBanner } from "@crema/feedback-ui"
|
||||
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,88 +25,406 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 { useSession } from "~/lib/session"
|
||||
|
||||
export const meta = () => pageTitle("Overview")
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
to: "/assistant",
|
||||
icon: Sparkles,
|
||||
title: "Assistant",
|
||||
body: "AI-first surface — chat, suggestions, and full UI control.",
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
]
|
||||
interface DashboardData {
|
||||
tenants: Tenant[]
|
||||
users: User[]
|
||||
audit: AuditLog[]
|
||||
health: OverallHealth | null
|
||||
}
|
||||
|
||||
const EMPTY: DashboardData = { tenants: [], users: [], audit: [], health: null }
|
||||
|
||||
export default function HomeRoute() {
|
||||
return (
|
||||
<AppShell title="Overview">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome</CardTitle>
|
||||
<CardDescription>
|
||||
A hybrid traditional + AI-first scaffold. Use the rail to navigate;
|
||||
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>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
const [data, setData] = useState<DashboardData>(EMPTY)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 instanceof ArcadiaError ? err.message : "Failed to load overview.")
|
||||
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: data.health?.status ?? "unconfigured",
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useRegisterContext("overview", stats)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
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)}>
|
||||
{error}
|
||||
</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>
|
||||
Latest audit events across the platform.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
data-action={`home-tile-${t.title.toLowerCase()}`}
|
||||
className="group block"
|
||||
to="/activity"
|
||||
data-action="overview-activity-all"
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Card
|
||||
className={[
|
||||
"h-full transition-colors",
|
||||
t.accent
|
||||
? "border-primary/30 bg-primary/5 hover:border-primary/50"
|
||||
: "hover:border-foreground/20",
|
||||
].join(" ")}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t.title}
|
||||
<ArrowRight className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</CardTitle>
|
||||
<CardDescription>{t.body}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
View all →
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecentActivity logs={data.audit} loading={loading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subsystems</CardTitle>
|
||||
<CardDescription>
|
||||
Live probe of each platform subsystem.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SubsystemList health={data.health} loading={loading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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"
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
632
app/routes/integrations.tsx
Normal file
632
app/routes/integrations.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
// 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-client"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import { useGatewayClient } from "~/lib/gateway"
|
||||
import {
|
||||
addCredential,
|
||||
createIntegration,
|
||||
credentialHealth,
|
||||
deleteIntegration,
|
||||
formatUsd,
|
||||
listIntegrations,
|
||||
testIntegration,
|
||||
updateIntegration,
|
||||
usageSummary,
|
||||
type AuthKind,
|
||||
type Integration,
|
||||
type Scope,
|
||||
type UsageEntry,
|
||||
} from "~/lib/arcadia/integrations"
|
||||
|
||||
const AUTH_KINDS: AuthKind[] = ["bearer_static", "api_key_header", "basic", "oauth2"]
|
||||
const SCOPES: Scope[] = ["platform", "tenant", "app", "user", "agent"]
|
||||
const SCOPE_FILTERS: Array<Scope | "all"> = ["all", ...SCOPES]
|
||||
|
||||
type Form = {
|
||||
scope: Scope
|
||||
scope_id: string
|
||||
provider: string
|
||||
capability: string
|
||||
display_name: string
|
||||
unit: string
|
||||
price_usd: string
|
||||
monthly_budget_usd: string
|
||||
secret_name: string
|
||||
auth_kind: AuthKind
|
||||
secret: string
|
||||
pooled: boolean
|
||||
}
|
||||
|
||||
const emptyForm: Form = {
|
||||
scope: "platform",
|
||||
scope_id: "",
|
||||
provider: "",
|
||||
capability: "",
|
||||
display_name: "",
|
||||
unit: "call",
|
||||
price_usd: "",
|
||||
monthly_budget_usd: "",
|
||||
secret_name: "",
|
||||
auth_kind: "bearer_static",
|
||||
secret: "",
|
||||
pooled: true,
|
||||
}
|
||||
|
||||
export default function IntegrationsRoute() {
|
||||
const gw = useGatewayClient()
|
||||
const [items, setItems] = useState<Integration[]>([])
|
||||
const [usage, setUsage] = useState<UsageEntry[]>([])
|
||||
const [scopeFilter, setScopeFilter] = useState<Scope | "all">("all")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<Integration | "new" | null>(null)
|
||||
const [tests, setTests] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
const filter = scopeFilter === "all" ? {} : { scope: scopeFilter }
|
||||
try {
|
||||
const [list, use] = await Promise.all([
|
||||
listIntegrations(gw, filter),
|
||||
usageSummary(gw, filter).catch(() => [] as UsageEntry[]),
|
||||
])
|
||||
setItems(list)
|
||||
setUsage(use)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load integrations.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [gw, scopeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const usageById = useMemo(
|
||||
() => new Map(usage.map((u) => [u.integration_id, u] as const)),
|
||||
[usage],
|
||||
)
|
||||
|
||||
const runTest = useCallback(
|
||||
async (it: Integration) => {
|
||||
setTests((t) => ({ ...t, [it.id]: { ok: true, message: "Testing…" } }))
|
||||
try {
|
||||
const verdict = await testIntegration(gw, it.id)
|
||||
const remaining = verdict.policy?.remaining_budget_usd
|
||||
setTests((t) => ({
|
||||
...t,
|
||||
[it.id]: {
|
||||
ok: true,
|
||||
message:
|
||||
verdict.status === "ok"
|
||||
? `OK — within budget & rate${remaining ? ` (${formatUsd(remaining)} left)` : ""}`
|
||||
: verdict.status,
|
||||
},
|
||||
}))
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof ArcadiaError
|
||||
? e.status === 409
|
||||
? "Credential expired — rotate it"
|
||||
: e.status === 429
|
||||
? "Over budget / rate limit"
|
||||
: e.status === 404
|
||||
? "No credential to test"
|
||||
: e.message
|
||||
: "Test failed"
|
||||
setTests((t) => ({ ...t, [it.id]: { ok: false, message: msg } }))
|
||||
}
|
||||
},
|
||||
[gw],
|
||||
)
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
async (it: Integration, enabled: boolean) => {
|
||||
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled } : x)))
|
||||
try {
|
||||
await updateIntegration(gw, it.id, { enabled })
|
||||
} catch {
|
||||
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled: !enabled } : x)))
|
||||
}
|
||||
},
|
||||
[gw],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (it: Integration) => {
|
||||
if (!window.confirm(`Delete ${it.display_name || it.provider} and its credentials?`)) return
|
||||
await deleteIntegration(gw, it.id)
|
||||
await refresh()
|
||||
},
|
||||
[gw, refresh],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Plug className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Integrations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Platform & pooled external-API credentials across every scope.
|
||||
Keys are stored encrypted and never shown; usage is metadata only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={scopeFilter} onValueChange={(v) => setScopeFilter((v as Scope | "all") ?? "all")}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_FILTERS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s === "all" ? "All scopes" : s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> Add integration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Couldn’t load integrations</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No integrations in this scope</CardTitle>
|
||||
<CardDescription>
|
||||
Register a platform/pooled arrangement — a shared key the platform
|
||||
meters and bills to tenants who opt in.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> Add integration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{items.map((it) => {
|
||||
const u = usageById.get(it.id)
|
||||
const test = tests[it.id]
|
||||
return (
|
||||
<Card key={it.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{it.display_name || it.provider}
|
||||
<Badge>{it.scope}</Badge>
|
||||
{it.scope_id ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{it.scope_id}
|
||||
</span>
|
||||
) : null}
|
||||
{it.capability ? (
|
||||
<Badge variant="secondary">{it.capability}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{it.provider}
|
||||
{it.cost_model?.price_usd
|
||||
? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}`
|
||||
: ""}
|
||||
{it.constraints?.monthly_budget_usd
|
||||
? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo`
|
||||
: ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`en-${it.id}`} className="text-xs text-muted-foreground">
|
||||
{it.enabled ? "Enabled" : "Disabled"}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`en-${it.id}`}
|
||||
checked={it.enabled}
|
||||
onCheckedChange={(v) => toggleEnabled(it, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
{it.credentials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No credential set.</p>
|
||||
) : (
|
||||
it.credentials.map((cred) => {
|
||||
const health = credentialHealth(cred)
|
||||
return (
|
||||
<div key={cred.id} className="flex items-center gap-2 text-sm">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
<span className="font-mono">{cred.secret_name}</span>
|
||||
<Badge variant="outline">{cred.source}</Badge>
|
||||
<HealthBadge health={health} />
|
||||
{cred.expires_at ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
expires {new Date(cred.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"}
|
||||
</p>
|
||||
|
||||
{test ? (
|
||||
<p
|
||||
className={`text-sm ${test.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}
|
||||
>
|
||||
{test.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" onClick={() => runTest(it)}>
|
||||
<FlaskConical className="size-4" /> Test
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(it)}>
|
||||
<Pencil className="size-4" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => remove(it)}
|
||||
>
|
||||
<Trash2 className="size-4" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<IntegrationDialog
|
||||
mode={editing === "new" ? "new" : "edit"}
|
||||
initial={editing === "new" ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={async () => {
|
||||
setEditing(null)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthBadge({ health }: { health: ReturnType<typeof credentialHealth> }) {
|
||||
if (health === "ok")
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle2 className="size-3" /> healthy
|
||||
</Badge>
|
||||
)
|
||||
const label = health === "missing" ? "no secret" : health
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="size-3" /> {label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationDialog({
|
||||
mode,
|
||||
initial,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
mode: "new" | "edit"
|
||||
initial: Integration | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const gw = useGatewayClient()
|
||||
const [form, setForm] = useState<Form>(() =>
|
||||
initial
|
||||
? {
|
||||
...emptyForm,
|
||||
scope: initial.scope,
|
||||
scope_id: initial.scope_id ?? "",
|
||||
provider: initial.provider,
|
||||
capability: initial.capability ?? "",
|
||||
display_name: initial.display_name ?? "",
|
||||
unit: initial.cost_model?.unit ?? "call",
|
||||
price_usd: initial.cost_model?.price_usd?.toString() ?? "",
|
||||
monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "",
|
||||
}
|
||||
: emptyForm,
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const set = (patch: Partial<Form>) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const needsScopeId = form.scope !== "platform"
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const cost_model = form.price_usd
|
||||
? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" }
|
||||
: undefined
|
||||
const constraints = form.monthly_budget_usd
|
||||
? { monthly_budget_usd: form.monthly_budget_usd }
|
||||
: undefined
|
||||
|
||||
if (mode === "edit" && initial) {
|
||||
await updateIntegration(gw, initial.id, {
|
||||
provider: form.provider.trim(),
|
||||
capability: form.capability.trim() || undefined,
|
||||
display_name: form.display_name.trim() || undefined,
|
||||
cost_model,
|
||||
constraints,
|
||||
})
|
||||
} else {
|
||||
const created = await createIntegration(gw, {
|
||||
scope: form.scope,
|
||||
scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined,
|
||||
provider: form.provider.trim(),
|
||||
capability: form.capability.trim() || undefined,
|
||||
display_name: form.display_name.trim() || undefined,
|
||||
cost_model,
|
||||
constraints,
|
||||
})
|
||||
if (form.secret_name.trim() && form.secret.trim()) {
|
||||
await addCredential(gw, created.id, {
|
||||
secret_name: form.secret_name.trim(),
|
||||
auth_kind: form.auth_kind,
|
||||
secret: form.secret,
|
||||
source: form.pooled ? "pooled" : "byo",
|
||||
})
|
||||
}
|
||||
}
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Save failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => (!o ? onClose() : undefined)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "new" ? "Add integration" : "Edit integration"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Register an external-API arrangement. Platform scope = a pooled key
|
||||
the platform meters and bills.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{mode === "new" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Scope">
|
||||
<Select value={form.scope} onValueChange={(v) => set({ scope: (v as Scope) ?? "platform" })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Scope ID" hint={needsScopeId ? "tenant/app/user/agent id" : "n/a for platform"}>
|
||||
<Input
|
||||
value={form.scope_id}
|
||||
onChange={(e) => set({ scope_id: e.target.value })}
|
||||
disabled={!needsScopeId}
|
||||
placeholder={needsScopeId ? "acme" : "—"}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field label="Provider" hint="e.g. tavily, google_maps, duffel">
|
||||
<Input
|
||||
value={form.provider}
|
||||
onChange={(e) => set({ provider: e.target.value })}
|
||||
placeholder="tavily"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Capability (optional)" hint="e.g. web_search, geocode">
|
||||
<Input
|
||||
value={form.capability}
|
||||
onChange={(e) => set({ capability: e.target.value })}
|
||||
placeholder="web_search"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Display name (optional)">
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => set({ display_name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Price (USD)" hint="per unit, for metering">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={form.price_usd}
|
||||
onChange={(e) => set({ price_usd: e.target.value })}
|
||||
placeholder="0.01"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Unit">
|
||||
<Select value={form.unit} onValueChange={(v) => set({ unit: v ?? "call" })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="call">call</SelectItem>
|
||||
<SelectItem value="search">search</SelectItem>
|
||||
<SelectItem value="1k_tokens">1k_tokens</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Monthly budget (USD, optional)" hint="resolve is refused past this">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={form.monthly_budget_usd}
|
||||
onChange={(e) => set({ monthly_budget_usd: e.target.value })}
|
||||
placeholder="500"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{mode === "new" ? (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<p className="text-sm font-medium">Credential (optional)</p>
|
||||
<Field label="Secret name" hint="the stable handle tools resolve by">
|
||||
<Input
|
||||
value={form.secret_name}
|
||||
onChange={(e) => set({ secret_name: e.target.value })}
|
||||
placeholder="tavily_default"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Auth kind">
|
||||
<Select
|
||||
value={form.auth_kind}
|
||||
onValueChange={(v) => set({ auth_kind: (v as AuthKind) ?? "bearer_static" })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTH_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{k}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Source">
|
||||
<div className="flex h-9 items-center gap-2">
|
||||
<Switch
|
||||
id="pooled"
|
||||
checked={form.pooled}
|
||||
onCheckedChange={(v) => set({ pooled: v })}
|
||||
/>
|
||||
<Label htmlFor="pooled" className="text-sm">
|
||||
{form.pooled ? "pooled (billed)" : "BYO key"}
|
||||
</Label>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Secret value" hint="stored encrypted, never shown again">
|
||||
<Input
|
||||
type="password"
|
||||
value={form.secret}
|
||||
onChange={(e) => set({ secret: e.target.value })}
|
||||
placeholder="sk-…"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving || !form.provider.trim()}>
|
||||
{saving ? "Saving…" : mode === "new" ? "Create" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function LibraryRoute() {
|
||||
const open = items.find((x) => x.id === openId) ?? null
|
||||
|
||||
return (
|
||||
<AppShell title="Library">
|
||||
<AppShell>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Library</CardTitle>
|
||||
|
||||
61
app/routes/login.2fa.tsx
Normal file
61
app/routes/login.2fa.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate, useSearchParams } from "react-router"
|
||||
|
||||
import { TwoFactorChallengeForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { persistFromArcadiaLogin } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Two-factor verification")
|
||||
|
||||
export default function TwoFactorRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const challenge = params.get("challenge") ?? ""
|
||||
const next = params.get("next") || "/"
|
||||
const [mode, setMode] = useState<"totp" | "recovery">("totp")
|
||||
|
||||
if (!challenge) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<h1 className="text-base font-semibold">Challenge missing</h1>
|
||||
<p className="text-muted-foreground">
|
||||
This page is only reachable after a sign-in attempt. Start over.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="2fa-back-to-login"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<TwoFactorChallengeForm
|
||||
brand={<AuthBrand />}
|
||||
challenge={challenge}
|
||||
mode={mode}
|
||||
onUseRecoveryCode={mode === "totp" ? () => setMode("recovery") : undefined}
|
||||
onBack={
|
||||
mode === "recovery"
|
||||
? () => setMode("totp")
|
||||
: () => navigate("/login")
|
||||
}
|
||||
onSuccess={({ tokens, user }) => {
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
navigate(next, { replace: true })
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
50
app/routes/login.forgot.tsx
Normal file
50
app/routes/login.forgot.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { PasswordResetRequestForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Reset password")
|
||||
|
||||
export default function ForgotPasswordRoute() {
|
||||
const navigate = useNavigate()
|
||||
const [sentTo, setSentTo] = useState<string | null>(null)
|
||||
|
||||
if (sentTo) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<CheckCircle2 className="size-8 text-emerald-500" />
|
||||
<h1 className="text-base font-semibold">Check your email</h1>
|
||||
<p className="text-muted-foreground">
|
||||
If an account exists for <strong>{sentTo}</strong>, we've sent a link
|
||||
to reset your password.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="forgot-back-to-login"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<PasswordResetRequestForm
|
||||
brand={<AuthBrand />}
|
||||
onBack={() => navigate("/login")}
|
||||
onSuccess={(email) => setSentTo(email)}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
47
app/routes/login.reset.tsx
Normal file
47
app/routes/login.reset.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useNavigate, useSearchParams } from "react-router"
|
||||
|
||||
import { PasswordResetConfirmForm } from "@crema/arcadia-auth-ui"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Set new password")
|
||||
|
||||
export default function ResetPasswordRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = params.get("token") ?? ""
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-xl border bg-card p-6 text-center text-sm"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<h1 className="text-base font-semibold">Reset link invalid</h1>
|
||||
<p className="text-muted-foreground">
|
||||
No token in the URL. Request a fresh password reset email.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login/forgot")}
|
||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||||
data-action="reset-request-new"
|
||||
>
|
||||
Request a new link
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<PasswordResetConfirmForm
|
||||
brand={<AuthBrand />}
|
||||
token={token}
|
||||
onSuccess={() => navigate("/login?reset=ok", { replace: true })}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LoginForm } from "@crema/arcadia-auth-ui"
|
||||
import { useBrand } from "~/lib/identity"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession, persistFromArcadiaLogin } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Sign in")
|
||||
|
||||
@@ -13,37 +14,24 @@ export default function LoginRoute() {
|
||||
const [params] = useSearchParams()
|
||||
const session = useSession()
|
||||
const brand = useBrand()
|
||||
const BrandIcon = brand.icon
|
||||
|
||||
const next = params.get("next") || "/"
|
||||
|
||||
// Already signed in? Bounce.
|
||||
useEffect(() => {
|
||||
if (session) navigate(next, { replace: true })
|
||||
}, [session, next, navigate])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative isolate flex min-h-svh items-center justify-center p-4"
|
||||
style={{ background: "var(--background)" }}
|
||||
>
|
||||
<AuthShell>
|
||||
<LoginForm
|
||||
brand={
|
||||
<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>
|
||||
}
|
||||
brand={<AuthBrand />}
|
||||
heading={`Sign in to ${brand.name}`}
|
||||
subhead="Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
|
||||
onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
|
||||
if (twoFactorRequired && twoFactorChallenge) {
|
||||
navigate(`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`)
|
||||
navigate(
|
||||
`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
@@ -52,6 +40,6 @@ export default function LoginRoute() {
|
||||
onForgotPassword={() => navigate("/login/forgot")}
|
||||
onSignup={() => navigate("/signup")}
|
||||
/>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
647
app/routes/memberships.tsx
Normal file
647
app/routes/memberships.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Network,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
ActionsCell,
|
||||
BadgeCell,
|
||||
DataTable,
|
||||
DateCell,
|
||||
Pagination,
|
||||
useTable,
|
||||
type ActionItem,
|
||||
type BadgeTone,
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
export default function MembershipsRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [memberships, setMemberships] = useState<Membership[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
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 {
|
||||
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 instanceof ArcadiaError ? err.message : "Failed to load memberships.")
|
||||
} 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 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)
|
||||
setInfo("Membership suspended.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Suspend failed.")
|
||||
}
|
||||
},
|
||||
}
|
||||
: {
|
||||
id: "activate",
|
||||
label: "Activate",
|
||||
icon: <Play className="size-4" />,
|
||||
dataAction: `membership-${m.id}-activate`,
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await activateMembership(arcadia, m.id)
|
||||
setInfo("Membership activated.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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],
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
<SearchInput
|
||||
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">
|
||||
<LoadingOverlay
|
||||
active={loading && memberships.length === 0}
|
||||
label="Loading memberships…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<Network className="size-6" />}
|
||||
title={
|
||||
search || statusFilter !== "all"
|
||||
? "No memberships match those filters."
|
||||
: "No memberships yet."
|
||||
}
|
||||
description={
|
||||
search || statusFilter !== "all"
|
||||
? "Loosen the filter set."
|
||||
: "Add a user to a tenant to create the first membership."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||
title="Remove membership?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `${pendingDelete.user?.email ?? pendingDelete.user_id} will lose access to ${pendingDelete.tenant?.name ?? "this tenant"}.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
try {
|
||||
await deleteMembership(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
setInfo("Membership removed.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Remove failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<MembershipEditorDialog
|
||||
state={editor}
|
||||
users={users}
|
||||
roles={roles}
|
||||
existingUserIds={new Set(memberships.map((m) => m.user_id))}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</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,
|
||||
onError,
|
||||
}: {
|
||||
state: Editor
|
||||
users: User[]
|
||||
roles: Role[]
|
||||
existingUserIds: Set<string>
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => 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)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
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 () => {
|
||||
onError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const input = {
|
||||
user_id: userId,
|
||||
status,
|
||||
role_ids: Array.from(selectedRoles),
|
||||
}
|
||||
if (isEdit && initial) {
|
||||
await updateMembership(arcadia, initial.id, input)
|
||||
await onSaved("Membership updated.")
|
||||
} else {
|
||||
await createMembership(arcadia, input)
|
||||
await onSaved("Member added.")
|
||||
}
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed.",
|
||||
)
|
||||
} finally {
|
||||
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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
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
|
||||
}, {})
|
||||
}
|
||||
|
||||
// File-local alias just to keep the Editor type narrowable inside the dialog.
|
||||
type Editor =
|
||||
| { kind: "create" }
|
||||
| { kind: "edit"; membership: Membership }
|
||||
| null
|
||||
1212
app/routes/monitoring.tsx
Normal file
1212
app/routes/monitoring.tsx
Normal file
File diff suppressed because it is too large
Load Diff
837
app/routes/networking.tsx
Normal file
837
app/routes/networking.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Trash2,
|
||||
Wifi,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
|
||||
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 { 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)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const [f, v, d, fi, dr] = await Promise.all([
|
||||
listFirewalls(arcadia),
|
||||
listVpcs(arcadia),
|
||||
listDomains(arcadia),
|
||||
listFloatingIps(arcadia),
|
||||
listDroplets(arcadia),
|
||||
])
|
||||
setFirewalls(f)
|
||||
setVpcs(v)
|
||||
setDomains(d)
|
||||
setFloatingIps(fi)
|
||||
setDroplets(dr)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load networking.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [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>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="firewalls">
|
||||
<TabsList>
|
||||
<TabsTrigger value="firewalls" data-action="networking-tab-firewalls">
|
||||
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}
|
||||
onChanged={refresh}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vpcs" className="pt-4">
|
||||
<VpcsPanel vpcs={vpcs} loading={loading} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="domains" className="pt-4">
|
||||
<DomainsPanel
|
||||
domains={domains}
|
||||
loading={loading}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="floating-ips" className="pt-4">
|
||||
<FloatingIpsPanel
|
||||
ips={floatingIps}
|
||||
droplets={droplets}
|
||||
loading={loading}
|
||||
onChanged={refresh}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Firewalls panel ---------------------------------------------------
|
||||
|
||||
function FirewallsPanel({
|
||||
firewalls,
|
||||
loading,
|
||||
onChanged,
|
||||
onError,
|
||||
onInfo,
|
||||
}: {
|
||||
firewalls: Firewall[]
|
||||
loading: boolean
|
||||
onChanged: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [pendingDelete, setPendingDelete] = useState<Firewall | null>(null)
|
||||
|
||||
if (loading && firewalls.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="relative py-8">
|
||||
<LoadingOverlay active label="Loading firewalls…" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (firewalls.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Shield className="size-6" />}
|
||||
title="No firewalls."
|
||||
description="Create a firewall on your provider, or configure DigitalOcean access in arcadia's .env to see existing ones."
|
||||
className="py-8"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{firewalls.map((f) => (
|
||||
<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>
|
||||
|
||||
<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
|
||||
try {
|
||||
await deleteFirewall(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
onInfo("Firewall deleted.")
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- VPCs panel --------------------------------------------------------
|
||||
|
||||
function VpcsPanel({ vpcs, loading }: { vpcs: Vpc[]; loading: boolean }) {
|
||||
if (loading && vpcs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="relative py-8">
|
||||
<LoadingOverlay active label="Loading VPCs…" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (vpcs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Network className="size-6" />}
|
||||
title="No VPCs."
|
||||
description="Read-only view; create VPCs on your provider directly."
|
||||
className="py-8"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{vpcs.map((v) => (
|
||||
<Card key={v.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="size-4 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{v.name}</CardTitle>
|
||||
{v.default ? <Badge>default</Badge> : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
<div>
|
||||
Region: <code className="font-mono">{v.region ?? "—"}</code>
|
||||
</div>
|
||||
<div>
|
||||
IP range: <code className="font-mono">{v.ip_range ?? "—"}</code>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Domains + DNS records panel ---------------------------------------
|
||||
|
||||
function DomainsPanel({
|
||||
domains,
|
||||
loading,
|
||||
onError,
|
||||
onInfo,
|
||||
onChanged,
|
||||
}: {
|
||||
domains: Domain[]
|
||||
loading: boolean
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
onChanged: () => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [selectedName, setSelectedName] = useState<string>(() => domains[0]?.name ?? "")
|
||||
const [records, setRecords] = useState<DnsRecord[]>([])
|
||||
const [loadingRecords, setLoadingRecords] = useState(false)
|
||||
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
|
||||
}
|
||||
setLoadingRecords(true)
|
||||
try {
|
||||
setRecords(await listDnsRecords(arcadia, name))
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Failed to load DNS records.")
|
||||
} finally {
|
||||
setLoadingRecords(false)
|
||||
}
|
||||
},
|
||||
[arcadia, onError],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords(selectedName)
|
||||
}, [selectedName, loadRecords])
|
||||
|
||||
if (loading && domains.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="relative py-8">
|
||||
<LoadingOverlay active label="Loading domains…" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Globe className="size-6" />}
|
||||
title="No domains."
|
||||
description="Add a domain on your provider; arcadia surfaces it here for record management."
|
||||
className="py-8"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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="p-0">
|
||||
{records.length === 0 && !loadingRecords ? (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<DnsCreateDialog
|
||||
open={createOpen}
|
||||
domainName={selectedName}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={async () => {
|
||||
setCreateOpen(false)
|
||||
onInfo("DNS record created.")
|
||||
await loadRecords(selectedName)
|
||||
await onChanged()
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<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
|
||||
try {
|
||||
await deleteDnsRecord(arcadia, selectedName, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
onInfo("Record deleted.")
|
||||
await loadRecords(selectedName)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DnsCreateDialog({
|
||||
open,
|
||||
domainName,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
domainName: string
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void>
|
||||
onError: (msg: string | null) => 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)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setType("A")
|
||||
setName("@")
|
||||
setData("")
|
||||
setTtl("3600")
|
||||
setPriority("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
await createDnsRecord(arcadia, domainName, {
|
||||
type,
|
||||
name,
|
||||
data,
|
||||
ttl: ttl ? Number(ttl) : undefined,
|
||||
priority: priority ? Number(priority) : undefined,
|
||||
})
|
||||
await onCreated()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Create failed.")
|
||||
} 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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
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,
|
||||
onChanged,
|
||||
onError,
|
||||
onInfo,
|
||||
}: {
|
||||
ips: FloatingIp[]
|
||||
droplets: Droplet[]
|
||||
loading: boolean
|
||||
onChanged: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [assigning, setAssigning] = useState<{ ip: string; dropletId: string } | null>(null)
|
||||
|
||||
if (loading && ips.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="relative py-8">
|
||||
<LoadingOverlay active label="Loading floating IPs…" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (ips.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={<Wifi className="size-6" />}
|
||||
title="No floating IPs."
|
||||
description="Reserve a floating IP on your provider to surface it here."
|
||||
className="py-8"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<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)
|
||||
onInfo("Floating IP unassigned.")
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError ? err.message : "Unassign failed.",
|
||||
)
|
||||
}
|
||||
}}
|
||||
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
|
||||
try {
|
||||
await assignFloatingIp(arcadia, ip.ip, assigning.dropletId)
|
||||
setAssigning(null)
|
||||
onInfo("Floating IP assigned.")
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError ? err.message : "Assign failed.",
|
||||
)
|
||||
}
|
||||
}}
|
||||
data-action={`fip-${ip.ip}-assign`}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
885
app/routes/organizations.tsx
Normal file
885
app/routes/organizations.tsx
Normal file
@@ -0,0 +1,885 @@
|
||||
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 { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
ActionsCell,
|
||||
BadgeCell,
|
||||
DataTable,
|
||||
DateCell,
|
||||
Pagination,
|
||||
useTable,
|
||||
type ActionItem,
|
||||
type BadgeTone,
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
|
||||
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 {
|
||||
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",
|
||||
}
|
||||
|
||||
export default function OrganizationsRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [orgs, setOrgs] = useState<Organization[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(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 instanceof ArcadiaError ? err.message : "Failed to load organizations.")
|
||||
} 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} />
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
<SearchInput
|
||||
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">
|
||||
<LoadingOverlay
|
||||
active={loading && orgs.length === 0}
|
||||
label="Loading organizations…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<Building className="size-6" />}
|
||||
title={
|
||||
search || statusFilter !== "all"
|
||||
? "No organizations match those filters."
|
||||
: "No organizations yet."
|
||||
}
|
||||
description={
|
||||
search || statusFilter !== "all"
|
||||
? "Loosen the filter set."
|
||||
: "End-users create these from inside the app; nothing to do here yet."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<MembersDialog
|
||||
state={membersDialog}
|
||||
onClose={() => setMembersDialog(null)}
|
||||
onInfo={setInfo}
|
||||
onError={setError}
|
||||
/>
|
||||
<SettingsDialog
|
||||
state={settingsDialog}
|
||||
onClose={() => setSettingsDialog(null)}
|
||||
onSaved={async (msg) => {
|
||||
setSettingsDialog(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</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,
|
||||
onInfo,
|
||||
onError,
|
||||
}: {
|
||||
state: MembersDialogState
|
||||
onClose: () => void
|
||||
onInfo: (msg: string | null) => void
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
const org = state?.org
|
||||
|
||||
const [members, setMembers] = useState<OrgMembership[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
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
|
||||
setLoading(true)
|
||||
try {
|
||||
setMembers(await listMembers(arcadia, org.id))
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Failed to load members.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia, org, onError])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) 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 (msg) => {
|
||||
setPane("none")
|
||||
onInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
) : (
|
||||
<AddRestrictedForm
|
||||
orgId={org!.id}
|
||||
onCancel={() => setPane("none")}
|
||||
onSaved={async (msg) => {
|
||||
setPane("none")
|
||||
onInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<LoadingOverlay active={loading && members.length === 0} label="Loading members…" />
|
||||
{members.length === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<UsersIcon className="size-6" />}
|
||||
title="No members yet."
|
||||
description="Invite someone or add a restricted sub-user to get started."
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<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">{m.user_id.slice(0, 8)}…</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={async (msg) => {
|
||||
onInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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 is the owner. Removal will follow the org's owner-removal policy."
|
||||
: "They will lose access to this organization."
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingRemove || !org) return
|
||||
try {
|
||||
await removeMember(arcadia, org.id, pendingRemove.user_id)
|
||||
setPendingRemove(null)
|
||||
onInfo("Member removed.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Remove failed.")
|
||||
setPendingRemove(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={transferTarget !== null}
|
||||
onOpenChange={(o) => !o && setTransferTarget(null)}
|
||||
title="Transfer ownership?"
|
||||
description={
|
||||
transferTarget
|
||||
? `The current owner will be demoted to admin. ${transferTarget.user_id.slice(0, 8)}… will become owner.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Transfer"
|
||||
variant="default"
|
||||
onConfirm={async () => {
|
||||
if (!transferTarget || !org) return
|
||||
try {
|
||||
await transferOwnership(arcadia, org.id, transferTarget.user_id)
|
||||
setTransferTarget(null)
|
||||
onInfo("Ownership transferred.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Transfer failed.")
|
||||
setTransferTarget(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberRowActions({
|
||||
member,
|
||||
orgId,
|
||||
onTransfer,
|
||||
onRemove,
|
||||
onRoleChanged,
|
||||
onError,
|
||||
}: {
|
||||
member: OrgMembership
|
||||
orgId: string
|
||||
onTransfer: () => void
|
||||
onRemove: () => void
|
||||
onRoleChanged: (msg: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
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(`Role set to ${next}.`)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Role change failed.")
|
||||
}
|
||||
},
|
||||
})
|
||||
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} />
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invite-by-email and add-restricted forms
|
||||
// ============================================================================
|
||||
|
||||
function InviteByEmailForm({
|
||||
orgId,
|
||||
onCancel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
orgId: string
|
||||
onCancel: () => void
|
||||
onSaved: (msg: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [email, setEmail] = useState("")
|
||||
const [role, setRole] = useState<OrgRole>("member")
|
||||
const [saving, setSaving] = useState(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)}
|
||||
/>
|
||||
<Select value={role} onValueChange={(v) => setRole(v as OrgRole)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!email || saving}
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await inviteMember(arcadia, orgId, { email, role })
|
||||
await onSaved(
|
||||
res.type === "membership"
|
||||
? "Invited existing user."
|
||||
: "Email invitation sent.",
|
||||
)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Invite failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send invite
|
||||
</Button>
|
||||
</div>
|
||||
<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,
|
||||
onError,
|
||||
}: {
|
||||
orgId: string
|
||||
onCancel: () => void
|
||||
onSaved: (msg: string) => Promise<void>
|
||||
onError: (msg: string | null) => 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)
|
||||
|
||||
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)} />
|
||||
</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)}
|
||||
/>
|
||||
</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)} />
|
||||
</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)} />
|
||||
</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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!email || !password || !firstName || !lastName || saving}
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await addRestrictedMember(arcadia, orgId, {
|
||||
email,
|
||||
password,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
role,
|
||||
})
|
||||
await onSaved("Restricted user added.")
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Add failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
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,
|
||||
onError,
|
||||
}: {
|
||||
state: SettingsDialogState
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => 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)
|
||||
|
||||
useEffect(() => {
|
||||
if (org) {
|
||||
setName(org.name)
|
||||
setStatus(org.status)
|
||||
setOnOwnerRemoval(org.on_owner_removal)
|
||||
}
|
||||
}, [org])
|
||||
|
||||
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)} />
|
||||
</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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
if (!org) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateOrganization(arcadia, org.id, {
|
||||
name,
|
||||
status,
|
||||
on_owner_removal: onOwnerRemoval,
|
||||
})
|
||||
await onSaved("Organization updated.")
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
40
app/routes/plan.tsx
Normal file
40
app/routes/plan.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// Tenant subscription + billing — placeholder. Real surface lists the
|
||||
// active plan, renewal date, invoices, and payment method for the
|
||||
// active tenant. Data source not wired yet.
|
||||
|
||||
import { CreditCard } from "lucide-react"
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
|
||||
export default function PlanRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<CreditCard className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Plan</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your tenant's subscription, billing details, and invoice history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming soon</CardTitle>
|
||||
<CardDescription>
|
||||
Billing is not yet wired to a payment provider on this deployment.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Check, Trash2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Check, RefreshCw, Trash2 } from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import { AlertBanner } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -11,253 +15,441 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import { useAgents } from "~/lib/agents"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import {
|
||||
DEFAULT_PROFILE,
|
||||
profileInitials,
|
||||
resetProfile,
|
||||
saveProfile,
|
||||
useProfile,
|
||||
type 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 default function ProfileRoute() {
|
||||
const profile = useProfile()
|
||||
const agents = useAgents()
|
||||
const [draft, setDraft] = useState<Profile>(profile)
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
interface AccountDraft {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
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(() => {
|
||||
setDraft(profile)
|
||||
setPrefs(profile)
|
||||
}, [profile])
|
||||
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(profile)
|
||||
const initials = profileInitials(draft.name || DEFAULT_PROFILE.name)
|
||||
// Arcadia account.
|
||||
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) {
|
||||
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
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result === "string")
|
||||
setDraft((d) => ({ ...d, avatarUrl: result }))
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setAvatarError("Avatar must be an image (PNG, JPG, GIF, WebP).")
|
||||
return
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
setAvatarUploading(true)
|
||||
try {
|
||||
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)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
saveProfile(draft)
|
||||
setSavedAt(Date.now())
|
||||
// Mirror the avatar URL into localStorage so it survives reloads.
|
||||
const savePrefsLocal = (next: Profile) => {
|
||||
saveProfile(next)
|
||||
}
|
||||
|
||||
const defaultAgent =
|
||||
agents.find((a) => a.id === draft.defaultAgentId) ?? null
|
||||
|
||||
return (
|
||||
<AppShell title="Profile">
|
||||
<AppShell>
|
||||
<Card>
|
||||
<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>
|
||||
Personal info shown across the app — appbar avatar, signatures, and
|
||||
anywhere the assistant references you.
|
||||
Your arcadia identity. Changes are saved to the platform and reflected
|
||||
anywhere your name or email appears.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<Avatar className="size-20 ring-2 ring-primary/30">
|
||||
{draft.avatarUrl ? (
|
||||
<AvatarImage src={draft.avatarUrl} alt={draft.name} />
|
||||
{prefs.avatarUrl ? (
|
||||
<AvatarImage
|
||||
key={prefs.avatarUrl}
|
||||
src={prefs.avatarUrl}
|
||||
alt={accountDraft.email}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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">
|
||||
<input
|
||||
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.
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">
|
||||
{account?.full_name || accountDraft.email || "—"}
|
||||
</span>
|
||||
{account ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Tenant <code className="font-mono">{account.tenant_id}</code> ·
|
||||
ID <code className="font-mono">{account.id}</code>
|
||||
</span>
|
||||
<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 className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Name">
|
||||
<Field label="First name">
|
||||
<Input
|
||||
data-action="profile-name"
|
||||
value={draft.name}
|
||||
data-action="profile-first-name"
|
||||
value={accountDraft.first_name}
|
||||
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 label="Email">
|
||||
<Field label="Last name">
|
||||
<Input
|
||||
data-action="profile-email"
|
||||
type="email"
|
||||
value={draft.email}
|
||||
data-action="profile-last-name"
|
||||
value={accountDraft.last_name}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, email: e.target.value }))
|
||||
setAccountDraft((d) => ({ ...d, last_name: e.target.value }))
|
||||
}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</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"
|
||||
autoComplete="family-name"
|
||||
disabled={accountLoading || accountSaving}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Default agent"
|
||||
hint="Used as the active persona on first load."
|
||||
label="Email"
|
||||
hint="Updating your email may require re-verification."
|
||||
>
|
||||
<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>
|
||||
<Input
|
||||
data-action="profile-email"
|
||||
type="email"
|
||||
value={accountDraft.email}
|
||||
onChange={(e) =>
|
||||
setAccountDraft((d) => ({ ...d, email: e.target.value }))
|
||||
}
|
||||
autoComplete="email"
|
||||
disabled={accountLoading || accountSaving}
|
||||
/>
|
||||
</Field>
|
||||
</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">
|
||||
<Button
|
||||
data-action="profile-save"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
data-action="profile-account-save"
|
||||
onClick={saveAccount}
|
||||
disabled={!accountDirty || accountSaving || accountLoading}
|
||||
>
|
||||
Save
|
||||
{accountSaving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : null}
|
||||
Save account
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-revert"
|
||||
data-action="profile-account-revert"
|
||||
variant="ghost"
|
||||
onClick={() => setDraft(profile)}
|
||||
disabled={!dirty}
|
||||
onClick={() => {
|
||||
if (!account) return
|
||||
setAccountDraft({
|
||||
first_name: account.first_name ?? "",
|
||||
last_name: account.last_name ?? "",
|
||||
email: account.email,
|
||||
})
|
||||
}}
|
||||
disabled={!accountDirty || accountSaving}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
<Button
|
||||
data-action="profile-reset"
|
||||
data-action="profile-account-refresh"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
resetProfile()
|
||||
setSavedAt(Date.now())
|
||||
}}
|
||||
onClick={loadAccount}
|
||||
disabled={accountLoading}
|
||||
>
|
||||
Reset to defaults
|
||||
<RefreshCw
|
||||
className={accountLoading ? "size-4 animate-spin" : "size-4"}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
{savedAt && !dirty && (
|
||||
{accountSavedAt && !accountDirty && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<Check className="size-4" /> Saved.
|
||||
</span>
|
||||
@@ -265,6 +457,149 @@ export default function ProfileRoute() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Plus, Search, Trash2 } from "lucide-react"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import {
|
||||
createResource,
|
||||
deleteResource,
|
||||
seedResourcesIfEmpty,
|
||||
updateResource,
|
||||
useResources,
|
||||
type Resource,
|
||||
} from "~/lib/resources"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
|
||||
export const meta = () => pageTitle("Resources")
|
||||
|
||||
const statuses: Resource["status"][] = ["active", "paused", "archived"]
|
||||
|
||||
export default function ResourcesRoute() {
|
||||
const items = useResources()
|
||||
const [query, setQuery] = useState("")
|
||||
const [draftName, setDraftName] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
seedResourcesIfEmpty()
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return q
|
||||
? items.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.owner.toLowerCase().includes(q) ||
|
||||
r.status.includes(q),
|
||||
)
|
||||
: items
|
||||
}, [items, query])
|
||||
|
||||
const create = () => {
|
||||
const name = draftName.trim()
|
||||
if (!name) return
|
||||
createResource({ name, owner: "You" })
|
||||
setDraftName("")
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Resources">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resources</CardTitle>
|
||||
<CardDescription>
|
||||
Example domain entity. CRUD goes through{" "}
|
||||
<code className="font-mono text-xs">~/lib/resources.ts</code> —
|
||||
swap that file's calls for{" "}
|
||||
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
|
||||
from <code className="font-mono text-xs">~/lib/api.ts</code> when
|
||||
you have a backend.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
data-action="resources-search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search name, owner, status…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
data-action="resources-new-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") create()
|
||||
}}
|
||||
placeholder="New resource name…"
|
||||
className="max-w-64"
|
||||
/>
|
||||
<Button
|
||||
data-action="resources-create"
|
||||
onClick={create}
|
||||
disabled={!draftName.trim()}
|
||||
>
|
||||
<Plus className="size-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-card/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Owner</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Updated</th>
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-3 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{items.length === 0
|
||||
? "No resources yet — add one above."
|
||||
: "No matches."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="border-t transition-colors hover:bg-accent/30"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{r.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{r.owner}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
data-action={`resources-status-${r.id}`}
|
||||
value={r.status}
|
||||
onChange={(e) =>
|
||||
updateResource(r.id, {
|
||||
status: e.target.value as Resource["status"],
|
||||
})
|
||||
}
|
||||
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{statuses.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
|
||||
{new Date(r.updatedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button
|
||||
data-action={`resources-delete-${r.id}`}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${r.name}"?`))
|
||||
deleteResource(r.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{items.length} total · {filtered.length} shown
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
@@ -73,7 +72,7 @@ import {
|
||||
} from "~/lib/arcadia/scheduled-tasks"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Scheduled tasks")
|
||||
|
||||
@@ -228,7 +227,7 @@ export default function ScheduledTasksRoute() {
|
||||
}),
|
||||
[tasks],
|
||||
)
|
||||
useRegisterAdminContext("scheduled_tasks", summary)
|
||||
useRegisterContext("scheduled_tasks", summary)
|
||||
|
||||
const table = useTable<ScheduledTask>({
|
||||
data: tasks,
|
||||
@@ -241,31 +240,9 @@ export default function ScheduledTasksRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Scheduled tasks">
|
||||
<div className="p-8">
|
||||
<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">
|
||||
<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">Scheduled tasks</h1>
|
||||
|
||||
893
app/routes/search.tsx
Normal file
893
app/routes/search.tsx
Normal file
@@ -0,0 +1,893 @@
|
||||
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 {
|
||||
AlertBanner,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
LoadingOverlay,
|
||||
} from "@crema/feedback-ui"
|
||||
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
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,
|
||||
SearchAdminError,
|
||||
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 [tenants, setTenants] = useState<TenantSummary[]>([])
|
||||
const [corpora, setCorpora] = useState<Row[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
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 reportError = useCallback((err: unknown, fallback: string) => {
|
||||
setError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: fallback,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const tRes = await searchAdmin.listTenants()
|
||||
setTenants(tRes.tenants)
|
||||
// Fan out per-tenant corpus lookups in parallel.
|
||||
const cByT = await Promise.all(
|
||||
tRes.tenants.map(async (t) => {
|
||||
try {
|
||||
const r = await searchAdmin.listCorpora(t.id)
|
||||
return r.corpora
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}),
|
||||
)
|
||||
const flat: Row[] = cByT.flat().map((c) => ({
|
||||
...c,
|
||||
rowId: `${c.tenant}/${c.corpus}`,
|
||||
}))
|
||||
setCorpora(flat)
|
||||
} catch (err) {
|
||||
reportError(err, "Failed to load search admin state.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [reportError])
|
||||
|
||||
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)
|
||||
setError(null)
|
||||
try {
|
||||
const out = await searchAdmin.rebuild(tenant, corpus)
|
||||
setInfo(
|
||||
`Rebuilt ${tenant}/${corpus} — ${out.chunk_count} chunks indexed.`,
|
||||
)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Rebuild failed.")
|
||||
} finally {
|
||||
setRebuilding(null)
|
||||
}
|
||||
},
|
||||
[refresh, reportError],
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
{!searchAdmin.hasToken ? (
|
||||
<AlertBanner variant="warning">
|
||||
VITE_ARCADIA_SEARCH_ADMIN_TOKEN is unset. The Search section will
|
||||
return 401 until the bearer token is configured. Endpoint:{" "}
|
||||
<code className="font-mono">{searchAdmin.baseUrl}</code>
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{error ? (
|
||||
<AlertBanner
|
||||
variant="error"
|
||||
dismissible
|
||||
onDismiss={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner
|
||||
variant="success"
|
||||
dismissible
|
||||
onDismiss={() => setInfo(null)}
|
||||
>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-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}
|
||||
onDelete={(id) => setPendingDeleteTenant(id)}
|
||||
/>
|
||||
|
||||
<CorporaCard
|
||||
corpora={corpora}
|
||||
loading={loading}
|
||||
rebuildingId={rebuilding}
|
||||
onRebuild={rebuild}
|
||||
onEdit={(t, c) => setEditor({ kind: "edit-corpus", tenant: t, corpus: c })}
|
||||
onDelete={(t, c) => setPendingDeleteCorpus({ tenant: t, corpus: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* New tenant */}
|
||||
<NewTenantDialog
|
||||
open={editor?.kind === "new-tenant"}
|
||||
onClose={() => setEditor(null)}
|
||||
onCreated={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={(msg) => setError(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)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={(msg) => setError(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
|
||||
try {
|
||||
await searchAdmin.deleteTenant(pendingDeleteTenant)
|
||||
setInfo(`Tenant ${pendingDeleteTenant} deleted.`)
|
||||
setPendingDeleteTenant(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Delete failed.")
|
||||
setPendingDeleteTenant(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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)
|
||||
setInfo(`Deleted ${tenant}/${corpus}.`)
|
||||
setPendingDeleteCorpus(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
reportError(err, "Delete failed.")
|
||||
setPendingDeleteCorpus(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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()
|
||||
setInfo("Restart requested.")
|
||||
} catch (err) {
|
||||
reportError(err, "Restart request failed.")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tenants card --------------------------------------------------------
|
||||
|
||||
function TenantsCard({
|
||||
tenants,
|
||||
onDelete,
|
||||
}: {
|
||||
tenants: TenantSummary[]
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-base font-semibold">Tenants</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{tenants.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No tenants yet."
|
||||
description="Create one to start adding corpora."
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Corpora table -------------------------------------------------------
|
||||
|
||||
function CorporaCard({
|
||||
corpora,
|
||||
loading,
|
||||
rebuildingId,
|
||||
onRebuild,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
corpora: Row[]
|
||||
loading: boolean
|
||||
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">
|
||||
<LoadingOverlay
|
||||
active={loading && corpora.length === 0}
|
||||
label="Loading corpora…"
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<Database className="size-6" />}
|
||||
title={search ? "No matches." : "No corpora yet."}
|
||||
description={
|
||||
search ? "Try a different search." : "Create one above."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Dialogs -------------------------------------------------------------
|
||||
|
||||
function NewTenantDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (msg?: string) => Promise<void>
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [id, setId] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setId("")
|
||||
}, [open])
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await searchAdmin.createTenant(id)
|
||||
await onCreated(`Tenant ${id} created.`)
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: "Create failed.",
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New tenant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Creates an empty config dir at{" "}
|
||||
<code className="font-mono text-xs">
|
||||
$INDEX_CONFIG_DIR/<id>/
|
||||
</code>
|
||||
. Add corpora separately. Names are alphanumeric, dash, or
|
||||
underscore.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="new-tenant-id">Tenant id</Label>
|
||||
<Input
|
||||
id="new-tenant-id"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="acme"
|
||||
data-action="tenant-form-id"
|
||||
/>
|
||||
</div>
|
||||
<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,
|
||||
onError,
|
||||
}: {
|
||||
editor:
|
||||
| { kind: "new-corpus"; tenant: string }
|
||||
| { kind: "edit-corpus"; tenant: string; corpus: string }
|
||||
| null
|
||||
tenants: TenantSummary[]
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [tenant, setTenant] = useState("")
|
||||
const [text, setText] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
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)
|
||||
if (editor.kind === "edit-corpus") {
|
||||
setLoading(true)
|
||||
searchAdmin
|
||||
.getCorpus(editor.tenant, editor.corpus)
|
||||
.then((res) => {
|
||||
setText(JSON.stringify(res.config, null, 2))
|
||||
})
|
||||
.catch((err) => {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: "Load failed.",
|
||||
)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
setText(CORPUS_CONFIG_TEMPLATE)
|
||||
}
|
||||
}, [editor, onError])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
const submit = async () => {
|
||||
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) {
|
||||
onError(
|
||||
err instanceof SearchAdminError
|
||||
? `${err.status}: ${err.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed.",
|
||||
)
|
||||
} 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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
@@ -78,7 +77,7 @@ import {
|
||||
} from "~/lib/arcadia/secrets"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Secrets")
|
||||
|
||||
@@ -235,7 +234,7 @@ export default function SecretsRoute() {
|
||||
}),
|
||||
[secrets],
|
||||
)
|
||||
useRegisterAdminContext("secrets", summary)
|
||||
useRegisterContext("secrets", summary)
|
||||
|
||||
const table = useTable<Secret>({
|
||||
data: filtered,
|
||||
@@ -248,31 +247,9 @@ export default function SecretsRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Secrets">
|
||||
<div className="p-8">
|
||||
<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">
|
||||
<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">Secrets</h1>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from "@crema/llm-providers-ui"
|
||||
import { useArcadiaClient } from "@crema/arcadia-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 { Button } from "~/components/ui/button"
|
||||
import {
|
||||
@@ -98,15 +100,15 @@ export default function SettingsRoute() {
|
||||
arcadiaTenantId,
|
||||
})
|
||||
|
||||
// In proxy mode the adapter just being built is the strongest signal we
|
||||
// can get without actually firing a chat request — the proxy endpoint
|
||||
// doesn't exist on the backend yet, so any /models probe would 404.
|
||||
// Proxy mode: round-trip a 1-token chat to verify auth → secret
|
||||
// resolution → upstream dispatch end-to-end. Maps the contract's
|
||||
// specific error codes to user-facing messages.
|
||||
if (s.mode === "proxy") {
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
"Adapter built. Note: the backend proxy (/api/v1/ai/llm/chat) isn't deployed yet — see docs/LLM_PROXY_CONTRACT.md.",
|
||||
}
|
||||
return probeProxy(arcadia, {
|
||||
provider: s.providerId as LLMProxyProvider,
|
||||
model: s.model || (s.providerId === "anthropic" ? "claude-opus-4-7" : "gpt-4o-mini"),
|
||||
secretName: s.secretName || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Direct mode — for OpenAI-compatible endpoints, /models is a cheap probe.
|
||||
@@ -118,7 +120,7 @@ export default function SettingsRoute() {
|
||||
: s.providerId === "openai"
|
||||
? "https://api.openai.com/v1"
|
||||
: s.providerId === "deepseek"
|
||||
? "https://api.deepseek.com/v1"
|
||||
? "https://api.deepseek.com"
|
||||
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
|
||||
// Resolve key for the probe (lmstudio doesn't need one).
|
||||
let apiKey: string | undefined
|
||||
@@ -168,11 +170,11 @@ export default function SettingsRoute() {
|
||||
}, [section])
|
||||
|
||||
return (
|
||||
<AppShell title="Settings">
|
||||
<div className="grid gap-6 md:grid-cols-[14rem_1fr]">
|
||||
<AppShell>
|
||||
<div className="grid gap-6 pt-10 md:grid-cols-[14rem_1fr] md:pt-0">
|
||||
<nav
|
||||
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) => {
|
||||
const Icon = s.icon
|
||||
@@ -211,21 +213,20 @@ export default function SettingsRoute() {
|
||||
<div className="min-w-0">
|
||||
{section === "llm" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>LLM</CardTitle>
|
||||
<CardDescription>
|
||||
Pick a provider, model, and the arcadia-vault secret holding the API key. Settings
|
||||
auto-save as you type. The Assistant picks them up on the next message.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LlmConfigurationsPanel />
|
||||
|
||||
<details className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Advanced: tweak the active session settings (transport, system prompt,
|
||||
context budget) directly
|
||||
</summary>
|
||||
<div className="pt-3">
|
||||
<LLMProvidersSettingsCard
|
||||
onTest={testConnection}
|
||||
hideTransportToggle={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
44
app/routes/signup.tsx
Normal file
44
app/routes/signup.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
|
||||
import { SignupForm } from "@crema/arcadia-auth-ui"
|
||||
import { useBrand } from "~/lib/identity"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { persistFromArcadiaLogin, useSession } from "~/lib/session"
|
||||
import { AuthBrand, AuthShell } from "~/components/auth/auth-shell"
|
||||
|
||||
export const meta = () => pageTitle("Create account")
|
||||
|
||||
export default function SignupRoute() {
|
||||
const navigate = useNavigate()
|
||||
const session = useSession()
|
||||
const brand = useBrand()
|
||||
|
||||
useEffect(() => {
|
||||
if (session) navigate("/", { replace: true })
|
||||
}, [session, navigate])
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<SignupForm
|
||||
brand={<AuthBrand />}
|
||||
heading={`Join ${brand.name}`}
|
||||
onSignin={() => navigate("/login")}
|
||||
onSuccess={async ({ tokens, user, emailVerificationSent }) => {
|
||||
if (tokens) {
|
||||
persistFromArcadiaLogin(tokens, user)
|
||||
navigate("/", { replace: true })
|
||||
return
|
||||
}
|
||||
// No tokens returned — verification email gating. Bounce to login.
|
||||
navigate(
|
||||
emailVerificationSent
|
||||
? "/login?verify=sent"
|
||||
: "/login",
|
||||
{ replace: true },
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
655
app/routes/sso.tsx
Normal file
655
app/routes/sso.tsx
Normal file
@@ -0,0 +1,655 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
ActionsCell,
|
||||
BadgeCell,
|
||||
DataTable,
|
||||
DateCell,
|
||||
Pagination,
|
||||
useTable,
|
||||
type ActionItem,
|
||||
type BadgeTone,
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
|
||||
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 { Switch } from "~/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
||||
import { Textarea } from "~/components/ui/textarea"
|
||||
import {
|
||||
createIdentityProvider,
|
||||
deleteIdentityProvider,
|
||||
destroySamlSession,
|
||||
listIdentityProviders,
|
||||
listSamlSessions,
|
||||
updateIdentityProvider,
|
||||
type IdentityProvider,
|
||||
type IdentityProviderInput,
|
||||
type SamlSession,
|
||||
} from "~/lib/arcadia/sso"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("SSO")
|
||||
|
||||
type Editor =
|
||||
| { kind: "create" }
|
||||
| { kind: "edit"; idp: IdentityProvider }
|
||||
| null
|
||||
|
||||
export default function SsoRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [idps, setIdps] = useState<IdentityProvider[]>([])
|
||||
const [sessions, setSessions] = useState<SamlSession[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
const [editor, setEditor] = useState<Editor>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<IdentityProvider | null>(null)
|
||||
const [pendingSessionDestroy, setPendingSessionDestroy] = useState<SamlSession | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const [i, s] = await Promise.all([
|
||||
listIdentityProviders(arcadia).catch(() => [] as IdentityProvider[]),
|
||||
listSamlSessions(arcadia).catch(() => [] as SamlSession[]),
|
||||
])
|
||||
setIdps(i)
|
||||
setSessions(s)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load SSO data.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
|
||||
useRegisterContext("sso", {
|
||||
identity_providers: idps.length,
|
||||
enabled_idps: idps.filter((i) => i.enabled).length,
|
||||
active_sessions: sessions.length,
|
||||
})
|
||||
|
||||
const idpColumns = useMemo<Column<IdentityProvider>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
accessor: "name",
|
||||
sortable: true,
|
||||
cell: (i) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{i.name}</span>
|
||||
<code className="font-mono text-[10px] text-muted-foreground">{i.entity_id}</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
header: "Enabled",
|
||||
accessor: "enabled",
|
||||
sortable: true,
|
||||
cell: (i) => (
|
||||
<BadgeCell label={i.enabled ? "enabled" : "disabled"} tone={i.enabled ? "success" : "default"} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cert",
|
||||
header: "Certificate",
|
||||
cell: (i) =>
|
||||
i.has_certificate ? (
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
<ShieldCheck className="mr-1 size-3" /> set
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
missing
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sso_url",
|
||||
header: "SSO URL",
|
||||
cell: (i) => (
|
||||
<code className="font-mono text-xs text-muted-foreground">{i.sso_url}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "Updated",
|
||||
accessor: "updated_at",
|
||||
sortable: true,
|
||||
cell: (i) => <DateCell value={i.updated_at} format="short" />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (i) => {
|
||||
const items: ActionItem[] = [
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
dataAction: `idp-${i.id}-edit`,
|
||||
onSelect: () => setEditor({ kind: "edit", idp: i }),
|
||||
},
|
||||
{
|
||||
id: i.enabled ? "disable" : "enable",
|
||||
label: i.enabled ? "Disable" : "Enable",
|
||||
dataAction: `idp-${i.id}-toggle`,
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await updateIdentityProvider(arcadia, i.id, { enabled: !i.enabled })
|
||||
setInfo(`${i.name} ${i.enabled ? "disabled" : "enabled"}.`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Toggle failed.")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
destructive: true,
|
||||
dataAction: `idp-${i.id}-delete`,
|
||||
onSelect: () => setPendingDelete(i),
|
||||
},
|
||||
]
|
||||
return <ActionsCell items={items} triggerDataAction={`idp-${i.id}-actions`} />
|
||||
},
|
||||
},
|
||||
],
|
||||
[arcadia, refresh],
|
||||
)
|
||||
|
||||
const idpTable = useTable<IdentityProvider>({
|
||||
data: idps,
|
||||
columns: idpColumns,
|
||||
getRowId: (i) => i.id,
|
||||
initialPageSize: 25,
|
||||
})
|
||||
|
||||
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">Single sign-on</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SAML identity providers configured for the current tenant, plus the active SAML
|
||||
session pool.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={loading} data-action="sso-refresh">
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditor({ kind: "create" })} data-action="sso-create">
|
||||
<Plus className="size-4" />
|
||||
New IdP
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="idps">
|
||||
<TabsList>
|
||||
<TabsTrigger value="idps" data-action="sso-tab-idps">
|
||||
Identity providers ({idps.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" data-action="sso-tab-sessions">
|
||||
Active sessions ({sessions.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="idps" className="pt-4">
|
||||
<Card>
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay active={loading && idps.length === 0} label="Loading IdPs…" />
|
||||
{idpTable.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<KeyRound className="size-6" />}
|
||||
title="No identity providers."
|
||||
description="Connect a SAML IdP (Okta, Azure AD, Google Workspace, etc.) to enable SSO for this tenant."
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={idpColumns}
|
||||
rows={idpTable.pageRows}
|
||||
getRowId={(i) => i.id}
|
||||
sort={idpTable.sort}
|
||||
onSortToggle={idpTable.toggleSort}
|
||||
loading={loading && idps.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
<Pagination
|
||||
page={idpTable.page}
|
||||
pageSize={idpTable.pageSize}
|
||||
total={idpTable.total}
|
||||
onPageChange={idpTable.setPage}
|
||||
onPageSizeChange={idpTable.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions" className="pt-4">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No active SAML sessions."
|
||||
description="Sessions appear here once users authenticate via the IdP."
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y border-y">
|
||||
{sessions.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<code className="font-mono text-xs">{s.name_id ?? s.user_id}</code>
|
||||
{s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? (
|
||||
<Badge variant="destructive">expired</Badge>
|
||||
) : (
|
||||
<Badge>active</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
session_index: {s.session_index ?? "—"} · idp:{" "}
|
||||
{s.idp_id.slice(0, 8)}… · started{" "}
|
||||
{new Date(s.inserted_at).toLocaleString()}
|
||||
{s.expires_at
|
||||
? ` · expires ${new Date(s.expires_at).toLocaleString()}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingSessionDestroy(s)}
|
||||
data-action={`sso-session-${s.id}-destroy`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Destroy
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||
title="Delete identity provider?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `${pendingDelete.name} will be removed. Existing SAML sessions remain valid until they expire.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
try {
|
||||
await deleteIdentityProvider(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
setInfo("Identity provider deleted.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingSessionDestroy !== null}
|
||||
onOpenChange={(o) => !o && setPendingSessionDestroy(null)}
|
||||
title="Destroy SAML session?"
|
||||
description={
|
||||
pendingSessionDestroy
|
||||
? `Session for ${pendingSessionDestroy.name_id ?? pendingSessionDestroy.user_id} will be revoked.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Destroy"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingSessionDestroy) return
|
||||
try {
|
||||
await destroySamlSession(arcadia, pendingSessionDestroy.id)
|
||||
setPendingSessionDestroy(null)
|
||||
setInfo("Session destroyed.")
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Destroy failed.")
|
||||
setPendingSessionDestroy(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<IdpEditorDialog
|
||||
state={editor}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async (msg) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function IdpEditorDialog({
|
||||
state,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: Editor
|
||||
onClose: () => void
|
||||
onSaved: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
const isEdit = state?.kind === "edit"
|
||||
const initial = isEdit ? state.idp : null
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [entityId, setEntityId] = useState("")
|
||||
const [ssoUrl, setSsoUrl] = useState("")
|
||||
const [sloUrl, setSloUrl] = useState("")
|
||||
const [metadataUrl, setMetadataUrl] = useState("")
|
||||
const [callbackUrl, setCallbackUrl] = useState("")
|
||||
const [signRequests, setSignRequests] = useState(false)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [certificate, setCertificate] = useState("")
|
||||
const [attrJson, setAttrJson] = useState("{}")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (initial) {
|
||||
setName(initial.name)
|
||||
setEntityId(initial.entity_id)
|
||||
setSsoUrl(initial.sso_url)
|
||||
setSloUrl(initial.slo_url ?? "")
|
||||
setMetadataUrl(initial.metadata_url ?? "")
|
||||
setCallbackUrl(initial.callback_url ?? "")
|
||||
setSignRequests(initial.sign_requests)
|
||||
setEnabled(initial.enabled)
|
||||
setCertificate("") // never pre-fill
|
||||
setAttrJson(JSON.stringify(initial.attribute_mapping ?? {}, null, 2))
|
||||
} else {
|
||||
setName("")
|
||||
setEntityId("")
|
||||
setSsoUrl("")
|
||||
setSloUrl("")
|
||||
setMetadataUrl("")
|
||||
setCallbackUrl("")
|
||||
setSignRequests(false)
|
||||
setEnabled(true)
|
||||
setCertificate("")
|
||||
setAttrJson('{\n "email": "email",\n "first_name": "givenName",\n "last_name": "surname"\n}')
|
||||
}
|
||||
}, [open, initial])
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
let attribute_mapping: Record<string, string> = {}
|
||||
try {
|
||||
attribute_mapping = attrJson.trim() === "" ? {} : JSON.parse(attrJson)
|
||||
} catch {
|
||||
throw new Error("Attribute mapping must be valid JSON (key→value strings).")
|
||||
}
|
||||
const input: IdentityProviderInput = {
|
||||
name,
|
||||
entity_id: entityId,
|
||||
sso_url: ssoUrl,
|
||||
slo_url: sloUrl || null,
|
||||
metadata_url: metadataUrl || null,
|
||||
callback_url: callbackUrl || null,
|
||||
sign_requests: signRequests,
|
||||
enabled,
|
||||
attribute_mapping,
|
||||
}
|
||||
if (certificate.trim()) input.certificate = certificate
|
||||
|
||||
if (isEdit && initial) {
|
||||
await updateIdentityProvider(arcadia, initial.id, input)
|
||||
await onSaved("Identity provider updated.")
|
||||
} else {
|
||||
await createIdentityProvider(arcadia, input)
|
||||
await onSaved("Identity provider created.")
|
||||
}
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed.",
|
||||
)
|
||||
} 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 ${initial?.name}` : "New identity provider"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Leave the certificate field blank to keep the existing one."
|
||||
: "Paste values from the IdP metadata XML, or supply the metadata URL and let arcadia fetch the rest."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-name">Name</Label>
|
||||
<Input
|
||||
id="idp-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Okta — Production"
|
||||
data-action="idp-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-entity">Entity ID</Label>
|
||||
<Input
|
||||
id="idp-entity"
|
||||
value={entityId}
|
||||
onChange={(e) => setEntityId(e.target.value)}
|
||||
placeholder="https://idp.example.com/saml"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-entity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-sso">SSO URL</Label>
|
||||
<Input
|
||||
id="idp-sso"
|
||||
value={ssoUrl}
|
||||
onChange={(e) => setSsoUrl(e.target.value)}
|
||||
placeholder="https://idp.example.com/saml/sso"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-sso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-slo">SLO URL (optional)</Label>
|
||||
<Input
|
||||
id="idp-slo"
|
||||
value={sloUrl}
|
||||
onChange={(e) => setSloUrl(e.target.value)}
|
||||
placeholder="https://idp.example.com/saml/slo"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-slo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-metadata">Metadata URL (optional)</Label>
|
||||
<Input
|
||||
id="idp-metadata"
|
||||
value={metadataUrl}
|
||||
onChange={(e) => setMetadataUrl(e.target.value)}
|
||||
placeholder="https://idp.example.com/metadata.xml"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-callback">Callback URL (SP ACS, optional override)</Label>
|
||||
<Input
|
||||
id="idp-callback"
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder="https://your-arcadia-app/api/v1/auth/saml/callback"
|
||||
className="font-mono text-xs"
|
||||
data-action="idp-form-callback"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-cert">
|
||||
Certificate (PEM){" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{isEdit ? (initial?.has_certificate ? " · current cert kept if blank" : " · required") : " · required"}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="idp-cert"
|
||||
value={certificate}
|
||||
onChange={(e) => setCertificate(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="-----BEGIN CERTIFICATE-----..."
|
||||
className="font-mono text-[11px]"
|
||||
spellCheck={false}
|
||||
data-action="idp-form-certificate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="idp-attrs">Attribute mapping (JSON: arcadia field → SAML attribute)</Label>
|
||||
<Textarea
|
||||
id="idp-attrs"
|
||||
value={attrJson}
|
||||
onChange={(e) => setAttrJson(e.target.value)}
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
spellCheck={false}
|
||||
data-action="idp-form-attrs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label className="text-sm">Sign requests</Label>
|
||||
<Switch
|
||||
checked={signRequests}
|
||||
onCheckedChange={setSignRequests}
|
||||
data-action="idp-form-sign-requests"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label className="text-sm">Enabled</Label>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
data-action="idp-form-enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !name || !entityId || !ssoUrl}
|
||||
data-action="idp-form-save"
|
||||
>
|
||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
{isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
1027
app/routes/status-page.tsx
Normal file
1027
app/routes/status-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
HardDrive,
|
||||
@@ -78,7 +77,7 @@ import {
|
||||
} from "~/lib/arcadia/storage-configs"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Storage")
|
||||
|
||||
@@ -251,7 +250,7 @@ export default function StorageRoute() {
|
||||
}),
|
||||
[configs],
|
||||
)
|
||||
useRegisterAdminContext("storage", summary)
|
||||
useRegisterContext("storage", summary)
|
||||
|
||||
const table = useTable<StorageConfig>({
|
||||
data: configs,
|
||||
@@ -264,31 +263,9 @@ export default function StorageRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Storage">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>
|
||||
Storage administration requires an admin session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/storage">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Storage">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<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">Storage</h1>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"
|
||||
import { Pause, Play, Plus, RefreshCw } from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
@@ -18,6 +17,7 @@ import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { PageHeader } from "~/components/layout/page-header"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -26,17 +26,28 @@ import {
|
||||
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 {
|
||||
activateTenant,
|
||||
deactivateTenant,
|
||||
listTenants,
|
||||
provisionTenant,
|
||||
suspendTenant,
|
||||
type Tenant,
|
||||
type TenantStatus,
|
||||
} from "~/lib/arcadia/tenants"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Tenants")
|
||||
|
||||
@@ -54,6 +65,7 @@ export default function TenantsRoute() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<PendingAction>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
@@ -160,7 +172,7 @@ export default function TenantsRoute() {
|
||||
}),
|
||||
[tenants],
|
||||
)
|
||||
useRegisterAdminContext("tenants", tenantSummary)
|
||||
useRegisterContext("tenants", tenantSummary)
|
||||
|
||||
const table = useTable<Tenant>({
|
||||
data: tenants,
|
||||
@@ -174,39 +186,13 @@ export default function TenantsRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Tenants">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>
|
||||
Tenant administration requires an admin session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/tenants">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Tenants">
|
||||
<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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Tenants</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Multi-tenant workspaces on this arcadia deployment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
title="Tenants"
|
||||
description="Multi-tenant workspaces on this arcadia deployment."
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -217,12 +203,17 @@ export default function TenantsRoute() {
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" disabled data-action="tenants-create">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
data-action="tenants-create"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New tenant
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
@@ -276,8 +267,16 @@ export default function TenantsRoute() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<TenantCreateDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={async () => {
|
||||
setCreateOpen(false)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={pending?.kind === "suspend"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
@@ -356,3 +355,218 @@ function rowActions(
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
function formatArcadiaError(err: unknown, fallback: string): string {
|
||||
if (!(err instanceof ArcadiaError)) return fallback
|
||||
// 422 validation errors carry per-field reasons in `details`. Shape from
|
||||
// Ecto's FallbackController is typically `{ field: ["msg1", "msg2"] }` or
|
||||
// nested `{ tenant: { slug: ["has already been taken"] } }`. Flatten so
|
||||
// the user sees what to fix instead of a generic "validation failed".
|
||||
if (err.isValidation && err.details) {
|
||||
const lines: string[] = []
|
||||
const walk = (obj: unknown, prefix: string) => {
|
||||
if (Array.isArray(obj)) {
|
||||
lines.push(`${prefix}: ${obj.join(", ")}`)
|
||||
} else if (obj && typeof obj === "object") {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
walk(v, prefix ? `${prefix}.${k}` : k)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(err.details, "")
|
||||
if (lines.length) return `${err.message} — ${lines.join("; ")}`
|
||||
}
|
||||
return err.message
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
function TenantCreateDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void> | void
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [name, setName] = useState("")
|
||||
const [slug, setSlug] = useState("")
|
||||
const [slugDirty, setSlugDirty] = useState(false)
|
||||
const [firstName, setFirstName] = useState("")
|
||||
const [lastName, setLastName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName("")
|
||||
setSlug("")
|
||||
setSlugDirty(false)
|
||||
setFirstName("")
|
||||
setLastName("")
|
||||
setEmail("")
|
||||
setPassword("")
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const slugInvalid = slug.length > 0 && !/^[a-z0-9-]+$/.test(slug)
|
||||
const canSubmit =
|
||||
!submitting &&
|
||||
name.trim().length > 0 &&
|
||||
slug.length > 0 &&
|
||||
!slugInvalid &&
|
||||
firstName.trim().length > 0 &&
|
||||
lastName.trim().length > 0 &&
|
||||
email.trim().length > 0 &&
|
||||
password.length >= 8
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await provisionTenant(arcadia, {
|
||||
tenant: { name: name.trim(), slug },
|
||||
admin_user: {
|
||||
email: email.trim(),
|
||||
password,
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
},
|
||||
})
|
||||
await onCreated()
|
||||
} catch (err) {
|
||||
onError(formatArcadiaError(err, "Failed to create tenant."))
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New tenant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Provisions the tenant with default roles, quotas, and an initial admin user.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Tenant name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (!slugDirty) setSlug(slugify(e.target.value))
|
||||
}}
|
||||
placeholder="Acme Corp"
|
||||
autoFocus
|
||||
data-action="tenants-create-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Slug</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlugDirty(true)
|
||||
setSlug(e.target.value)
|
||||
}}
|
||||
placeholder="acme"
|
||||
data-action="tenants-create-slug"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{slugInvalid
|
||||
? "Lowercase letters, digits, and hyphens only."
|
||||
: "Lowercase letters, digits, and hyphens. Used in URLs and the X-Tenant-ID header."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-first-name">Admin first name</Label>
|
||||
<Input
|
||||
id="tenant-admin-first-name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="Jane"
|
||||
data-action="tenants-create-admin-first-name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-last-name">Admin last name</Label>
|
||||
<Input
|
||||
id="tenant-admin-last-name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder="Doe"
|
||||
data-action="tenants-create-admin-last-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-email">Admin email</Label>
|
||||
<Input
|
||||
id="tenant-admin-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@acme.com"
|
||||
data-action="tenants-create-admin-email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-password">Admin password</Label>
|
||||
<Input
|
||||
id="tenant-admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
data-action="tenants-create-admin-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
data-action="tenants-create-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
data-action="tenants-create-submit"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create tenant"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ import {
|
||||
} from "~/lib/arcadia/users"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
import { UserDetailSheet } from "~/components/users/user-detail-sheet"
|
||||
|
||||
export const meta = () => pageTitle("Users")
|
||||
@@ -167,31 +167,11 @@ export default function UsersRoute() {
|
||||
}),
|
||||
[users, invitations, roles],
|
||||
)
|
||||
useRegisterAdminContext("users", summary)
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Users">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>User administration requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/users">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
useRegisterContext("users", summary)
|
||||
|
||||
return (
|
||||
<AppShell title="Users">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Users</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
@@ -78,7 +77,7 @@ import {
|
||||
} from "~/lib/arcadia/webhooks"
|
||||
import { pageTitle } from "~/lib/page-meta"
|
||||
import { useSession } from "~/lib/session"
|
||||
import { useRegisterAdminContext } from "~/lib/admin-context"
|
||||
import { useRegisterContext } from "@crema/aifirst-ui/context"
|
||||
|
||||
export const meta = () => pageTitle("Webhooks")
|
||||
|
||||
@@ -229,7 +228,7 @@ export default function WebhooksRoute() {
|
||||
}),
|
||||
[webhooks],
|
||||
)
|
||||
useRegisterAdminContext("webhooks", summary)
|
||||
useRegisterContext("webhooks", summary)
|
||||
|
||||
const table = useTable<Webhook>({
|
||||
data: webhooks,
|
||||
@@ -242,29 +241,9 @@ export default function WebhooksRoute() {
|
||||
table.setSearch(search)
|
||||
}, [search, table])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppShell title="Webhooks">
|
||||
<div className="p-8">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in required</CardTitle>
|
||||
<CardDescription>Webhook administration requires an admin session.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link to="/login?next=/webhooks">Sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Webhooks">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<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">Webhooks</h1>
|
||||
|
||||
372
app/themes/console.css
Normal file
372
app/themes/console.css
Normal file
@@ -0,0 +1,372 @@
|
||||
/* ============================================================================
|
||||
* Theme: console
|
||||
*
|
||||
* Mission-control operator surface for the /ai route. Phosphor-amber on a
|
||||
* deep-ink ground; everything system-y is monospace, the agent's prose
|
||||
* lifts into a literary serif. The whole thing is intentionally sparse and
|
||||
* dense at once — vertical rules, hairline borders, turn numbers in the
|
||||
* gutter, a vim-style modeline at the foot of the page.
|
||||
*
|
||||
* Scoped to [data-theme="console"]. Do not import unscoped — would clash
|
||||
* with skyrise / vibespace tokens.
|
||||
* ============================================================================ */
|
||||
|
||||
/* Fonts (JetBrains Mono + Newsreader) are loaded by app.css's top-level
|
||||
* @import url() — keep this file free of @import statements so it can sit
|
||||
* inside the bundle without violating "@import before any rule". */
|
||||
|
||||
[data-theme="console"] {
|
||||
/* ── Palette ─────────────────────────────────────────────────────────── */
|
||||
--console-ink: oklch(0.13 0.02 240); /* page ground */
|
||||
--console-deck: oklch(0.16 0.02 240); /* primary surface */
|
||||
--console-deck-2: oklch(0.20 0.02 240); /* raised surface */
|
||||
--console-rule: oklch(0.30 0.04 240); /* hairline */
|
||||
--console-rule-soft: oklch(0.22 0.02 240);
|
||||
--console-text: oklch(0.93 0.01 80); /* warm off-white */
|
||||
--console-text-2: oklch(0.78 0.02 80);
|
||||
--console-muted: oklch(0.55 0.02 80);
|
||||
--console-muted-2: oklch(0.42 0.02 80);
|
||||
|
||||
--console-amber: oklch(0.81 0.16 65); /* phosphor — operator */
|
||||
--console-amber-deep: oklch(0.65 0.16 55);
|
||||
--console-cyan: oklch(0.78 0.12 205); /* cool — agent */
|
||||
--console-cyan-deep: oklch(0.55 0.10 205);
|
||||
--console-rose: oklch(0.66 0.21 12); /* destructive */
|
||||
--console-mint: oklch(0.78 0.14 155); /* ok / success */
|
||||
|
||||
/* ── Typography ──────────────────────────────────────────────────────── */
|
||||
--console-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--console-font-serif: "Newsreader", "Iowan Old Style", Georgia, serif;
|
||||
|
||||
/* Override theme tokens consumed by AppShell + lib components, so even
|
||||
* pieces we don't restyle inherit the console palette. */
|
||||
--background: var(--console-ink);
|
||||
--foreground: var(--console-text);
|
||||
--card: var(--console-deck);
|
||||
--card-foreground: var(--console-text);
|
||||
--popover: var(--console-deck-2);
|
||||
--popover-foreground: var(--console-text);
|
||||
--primary: var(--console-amber);
|
||||
--primary-foreground: var(--console-ink);
|
||||
--secondary: var(--console-deck-2);
|
||||
--secondary-foreground: var(--console-text);
|
||||
--muted: var(--console-deck-2);
|
||||
--muted-foreground: var(--console-muted);
|
||||
--accent: var(--console-deck-2);
|
||||
--accent-foreground: var(--console-text);
|
||||
--destructive: var(--console-rose);
|
||||
--destructive-foreground: var(--console-text);
|
||||
--border: var(--console-rule-soft);
|
||||
--input: var(--console-rule);
|
||||
--ring: var(--console-amber);
|
||||
|
||||
--font-sans: var(--console-font-mono);
|
||||
--font-heading: var(--console-font-mono);
|
||||
--font-ai-prose: var(--console-font-serif);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ── Page atmosphere ───────────────────────────────────────────────────── */
|
||||
[data-theme="console"] {
|
||||
background:
|
||||
/* faint scanline */
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0) 0,
|
||||
rgba(255, 255, 255, 0) 2px,
|
||||
rgba(255, 255, 255, 0.012) 2px,
|
||||
rgba(255, 255, 255, 0.012) 3px
|
||||
),
|
||||
/* corner phosphor bloom */
|
||||
radial-gradient(
|
||||
1200px 800px at 100% 0%,
|
||||
oklch(0.81 0.16 65 / 0.05),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 700px at 0% 100%,
|
||||
oklch(0.55 0.10 205 / 0.04),
|
||||
transparent 60%
|
||||
),
|
||||
var(--console-ink);
|
||||
}
|
||||
|
||||
/* Wrapper must be a positioning context so the grain overlay (and any
|
||||
* other absolutely-positioned atmosphere) doesn't escape to the viewport. */
|
||||
[data-theme="console"] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Grain — single SVG turbulence, low opacity. Doesn't ship as an asset. */
|
||||
[data-theme="console"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.035;
|
||||
z-index: 1;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.6 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* ── Console primitives ────────────────────────────────────────────────── */
|
||||
|
||||
[data-theme="console"] .console-mono {
|
||||
font-family: var(--console-font-mono);
|
||||
font-feature-settings: "ss01", "ss02", "calt", "cv01";
|
||||
}
|
||||
[data-theme="console"] .console-serif {
|
||||
font-family: var(--console-font-serif);
|
||||
font-feature-settings: "ss01", "kern";
|
||||
}
|
||||
|
||||
/* Turn-number gutter label */
|
||||
[data-theme="console"] .console-turn-num {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--console-muted-2);
|
||||
}
|
||||
|
||||
/* Hairline rule */
|
||||
[data-theme="console"] .console-rule {
|
||||
border-color: var(--console-rule-soft);
|
||||
}
|
||||
|
||||
/* Operator badge — sodium amber */
|
||||
[data-theme="console"] .console-pill-amber {
|
||||
background: oklch(0.81 0.16 65 / 0.10);
|
||||
color: var(--console-amber);
|
||||
border: 1px solid oklch(0.81 0.16 65 / 0.30);
|
||||
}
|
||||
[data-theme="console"] .console-pill-cyan {
|
||||
background: oklch(0.78 0.12 205 / 0.10);
|
||||
color: var(--console-cyan);
|
||||
border: 1px solid oklch(0.78 0.12 205 / 0.30);
|
||||
}
|
||||
[data-theme="console"] .console-pill-mint {
|
||||
background: oklch(0.78 0.14 155 / 0.10);
|
||||
color: var(--console-mint);
|
||||
border: 1px solid oklch(0.78 0.14 155 / 0.30);
|
||||
}
|
||||
[data-theme="console"] .console-pill-rose {
|
||||
background: oklch(0.66 0.21 12 / 0.12);
|
||||
color: var(--console-rose);
|
||||
border: 1px solid oklch(0.66 0.21 12 / 0.30);
|
||||
}
|
||||
|
||||
/* Vertical rule that runs the length of the transcript */
|
||||
[data-theme="console"] .console-spine {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
var(--console-rule) 8%,
|
||||
var(--console-rule) 92%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Composer prompt cursor */
|
||||
@keyframes consoleBlink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
[data-theme="console"] .console-cursor {
|
||||
display: inline-block;
|
||||
width: 0.5ch;
|
||||
height: 1.1em;
|
||||
background: var(--console-amber);
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 1px;
|
||||
animation: consoleBlink 1.05s step-end infinite;
|
||||
}
|
||||
|
||||
/* Empty-state oversize text — letter-spacing tracking is the whole point */
|
||||
[data-theme="console"] .console-empty-headline {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: clamp(1.5rem, 3.2vw, 2.5rem);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
color: var(--console-text);
|
||||
}
|
||||
|
||||
[data-theme="console"] .console-empty-headline em {
|
||||
font-family: var(--console-font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
color: var(--console-amber);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Stagger the empty-state lines on first paint. */
|
||||
@keyframes consoleEmptyIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
[data-theme="console"] .console-empty-line {
|
||||
opacity: 0;
|
||||
animation: consoleEmptyIn 600ms cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
[data-theme="console"] .console-empty-line:nth-child(1) { animation-delay: 0ms; }
|
||||
[data-theme="console"] .console-empty-line:nth-child(2) { animation-delay: 90ms; }
|
||||
[data-theme="console"] .console-empty-line:nth-child(3) { animation-delay: 180ms; }
|
||||
[data-theme="console"] .console-empty-line:nth-child(4) { animation-delay: 280ms; }
|
||||
|
||||
/* Modeline (status bar at the foot) */
|
||||
[data-theme="console"] .console-modeline {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--console-muted);
|
||||
border-top: 1px solid var(--console-rule-soft);
|
||||
background: linear-gradient(to bottom, transparent, oklch(0.13 0.02 240 / 0.6));
|
||||
}
|
||||
[data-theme="console"] .console-modeline-key {
|
||||
color: var(--console-muted-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
margin-right: 0.4ch;
|
||||
}
|
||||
[data-theme="console"] .console-modeline-val {
|
||||
color: var(--console-text-2);
|
||||
}
|
||||
|
||||
/* Operator-row — monospace, tight, with an accent column */
|
||||
[data-theme="console"] .console-op-line {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--console-text);
|
||||
}
|
||||
[data-theme="console"] .console-op-prompt {
|
||||
color: var(--console-amber);
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Agent prose — set in serif, larger leading */
|
||||
[data-theme="console"] .console-agent-prose {
|
||||
font-family: var(--console-font-serif);
|
||||
font-size: 17px;
|
||||
line-height: 1.55;
|
||||
color: var(--console-text);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
[data-theme="console"] .console-agent-prose em {
|
||||
color: var(--console-cyan);
|
||||
font-style: italic;
|
||||
}
|
||||
[data-theme="console"] .console-agent-prose code {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 0.86em;
|
||||
background: var(--console-deck-2);
|
||||
border: 1px solid var(--console-rule-soft);
|
||||
border-radius: 2px;
|
||||
padding: 0.05em 0.4em;
|
||||
color: var(--console-amber);
|
||||
}
|
||||
[data-theme="console"] .console-agent-prose strong {
|
||||
font-weight: 600;
|
||||
color: var(--console-text);
|
||||
}
|
||||
|
||||
/* Signature line under each agent turn */
|
||||
[data-theme="console"] .console-sig {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--console-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
[data-theme="console"] .console-sig-name {
|
||||
color: var(--console-cyan);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Streaming activity indicator — a single phosphor block that pulses */
|
||||
@keyframes consolePulse {
|
||||
0%, 100% { opacity: 0.35; transform: scaleX(0.6); }
|
||||
50% { opacity: 1; transform: scaleX(1); }
|
||||
}
|
||||
[data-theme="console"] .console-streaming-bar {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 8px;
|
||||
background: var(--console-amber);
|
||||
vertical-align: middle;
|
||||
transform-origin: left center;
|
||||
animation: consolePulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Composer chrome */
|
||||
[data-theme="console"] .console-composer {
|
||||
background: var(--console-deck);
|
||||
border: 1px solid var(--console-rule);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
}
|
||||
[data-theme="console"] .console-composer:focus-within {
|
||||
border-color: var(--console-amber);
|
||||
box-shadow: 0 0 0 1px oklch(0.81 0.16 65 / 0.30);
|
||||
}
|
||||
[data-theme="console"] .console-composer textarea {
|
||||
font-family: var(--console-font-mono) !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.55 !important;
|
||||
color: var(--console-text) !important;
|
||||
}
|
||||
[data-theme="console"] .console-composer textarea::placeholder {
|
||||
color: var(--console-muted-2);
|
||||
}
|
||||
|
||||
/* Header strip — session card. Solid background so messages scrolling past
|
||||
* don't bleed through the sticky bar. */
|
||||
[data-theme="console"] .console-header {
|
||||
border-bottom: 1px solid var(--console-rule-soft);
|
||||
background: var(--console-ink);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
}
|
||||
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
[data-theme="console"] .console-header {
|
||||
background: oklch(0.13 0.02 240 / 0.82);
|
||||
}
|
||||
}
|
||||
[data-theme="console"] .console-session-id {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: clamp(1.5rem, 3vw, 2.25rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--console-text);
|
||||
}
|
||||
[data-theme="console"] .console-session-id span {
|
||||
color: var(--console-amber);
|
||||
}
|
||||
[data-theme="console"] .console-meta-key {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--console-muted-2);
|
||||
}
|
||||
[data-theme="console"] .console-meta-val {
|
||||
font-family: var(--console-font-mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--console-text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Tool-call wrapper — keep lib's internals, restyle the frame */
|
||||
[data-theme="console"] [data-slot="tool-call-card"] {
|
||||
background: var(--console-deck) !important;
|
||||
border: 1px solid var(--console-rule) !important;
|
||||
border-radius: 2px !important;
|
||||
font-family: var(--console-font-mono) !important;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# LLM Proxy Contract
|
||||
|
||||
> **Status: not yet implemented on the backend.** This document is the contract that `lib-llm-providers-ui` expects from arcadia. Implement `POST /api/v1/ai/llm/chat` server-side to make `mode: "proxy"` work in the client.
|
||||
> **Status: implemented.** Backend lives in `arcadia-app` at `apps/arcadia_core/lib/arcadia/ai/llm_proxy*` (see commit `75669f1`). This document remains the contract that `lib-llm-providers-ui` and `app/lib/arcadia/llm-proxy.ts` expect from arcadia — keep it in sync if either side changes.
|
||||
|
||||
## Why a proxy?
|
||||
|
||||
|
||||
173
docs/RAG.md
Normal file
173
docs/RAG.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Retrieval-Augmented Generation in arcadia-admin
|
||||
|
||||
This app exposes **two** lexical RAG surfaces to the assistant. They
|
||||
share a contract (`search` + `read`) but live at different layers and
|
||||
serve different content. The agent picks between them based on tool
|
||||
descriptions; the operator chooses which to deploy based on corpus
|
||||
shape.
|
||||
|
||||
---
|
||||
|
||||
## At a glance
|
||||
|
||||
| | Browser RAG | Server RAG |
|
||||
|---|---|---|
|
||||
| Lib / service | `@crema/lexical-rag-ui` | `arcadia-search` (Rust) |
|
||||
| Engine | MiniSearch (BM25, JS) | Tantivy (BM25, Rust) |
|
||||
| Where it runs | In the user's browser | Sibling of arcadia-app |
|
||||
| Index storage | Static JSON, fetched once | mmap'd disk, ~30–80MB resident |
|
||||
| Practical corpus size | ~5–10MB / ~50–100k chunks | GB-scale, no hard cap |
|
||||
| Update cadence | Static — rebuilt at app build time | Live — cron, webhook, or admin trigger |
|
||||
| Auth | None (bundled with the app) | JWT (via arcadia's Guardian) |
|
||||
| Tool the agent calls | `search_docs(query, limit)` | `search_kb(query, corpus, limit?, tags?)` + `read_chunk(chunk_id, corpus)` |
|
||||
| Source content lives in | `arcadia-admin/public/docs-index.json` | `arcadia-search`'s data dir, ingested from disk or arcadia API |
|
||||
| What's it best for | Static reference docs that ship with the app | Tenant-uploaded files, audit log search, anything that grows |
|
||||
|
||||
---
|
||||
|
||||
## When the agent picks each
|
||||
|
||||
The system prompt in `app/routes/assistant.tsx::buildAdminPreface` tells
|
||||
the model:
|
||||
|
||||
> Two retrieval surfaces exist for documentation/knowledge:
|
||||
> `search_docs` (browser-side, BM25 over the bundled arcadia docs —
|
||||
> fast, always available, small corpus) and `search_kb` (server-side,
|
||||
> BM25 over arcadia-search — same docs as `corpus=docs` for parity,
|
||||
> plus larger and additional corpora as the operator adds them). For
|
||||
> questions about the bundled arcadia docs either is fine; prefer
|
||||
> `search_kb` when you want richer hits or when the user is asking
|
||||
> about content that wouldn't be in the bundled docs (uploaded files,
|
||||
> tenant-specific knowledge). When `search_kb` returns a `chunk_id`
|
||||
> you want to expand, call `read_chunk(chunk_id, corpus)`.
|
||||
|
||||
In practice DeepSeek + V3 picks `search_kb` for anything that mentions
|
||||
"the kb" or sounds dynamic, and `search_docs` for quick lookups against
|
||||
the bundled docs. Neither pick is wrong for content that exists in
|
||||
both.
|
||||
|
||||
---
|
||||
|
||||
## Browser RAG (`@crema/lexical-rag-ui`)
|
||||
|
||||
**What it is.** A small React + MiniSearch wrapper. The lib provides
|
||||
`RAGProvider`, `useRAG`, and a headless `createRAGClient(indexUrl)`.
|
||||
The index is a single JSON file built offline by
|
||||
`scripts/build-docs-index.mjs` and shipped in the app's `public/`.
|
||||
|
||||
**How it's wired here.**
|
||||
|
||||
- Build script: `arcadia-admin/scripts/build-docs-index.mjs` reads
|
||||
markdown from `../reference/arcadia-app/`, chunks at H1–H3,
|
||||
produces `public/docs-index.json`. Runs on `npm run build:docs`
|
||||
(and as the `prebuild` step before `npm run build`).
|
||||
- Tool wrapper: `app/lib/admin-tools.ts` constructs a singleton
|
||||
`createRAGClient("/docs-index.json")` and exposes it as the
|
||||
`search_docs` tool. The tool returns hits with the legacy
|
||||
`category` field collapsed back from `tags[0]` so the agent's
|
||||
prior expectations stay stable.
|
||||
- Storage: just the static JSON. No state, no auth, no indexer
|
||||
process.
|
||||
|
||||
**Limits.** Practical ceiling is ~5–10MB index. Past that, first-load
|
||||
parse and browser memory get painful (200MB+ heap on a 50MB index;
|
||||
mobile breaks). Updates require a build step + redeploy.
|
||||
|
||||
**Why it exists.** Static reference content that ships with the app —
|
||||
arcadia's own docs, in this case. Always available even if the search
|
||||
service is down. Zero infrastructure.
|
||||
|
||||
For the lib itself see `lib-lexical-rag-ui/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Server RAG (`arcadia-search`)
|
||||
|
||||
**What it is.** A standalone Rust HTTP service (Tantivy + axum). Single
|
||||
static binary, ~30–80MB resident. Per-tenant per-corpus indexes on
|
||||
disk. JWT auth, HMAC webhook intake, atomic rebuild swap, systemd
|
||||
timer cron.
|
||||
|
||||
**How it's wired here.**
|
||||
|
||||
- Tools: `app/lib/admin-tools.ts` exposes `search_kb` and
|
||||
`read_chunk`. The fetch URL is `KB_BASE_URL` (default
|
||||
`http://127.0.0.1:7800`, override via `window.__ARCADIA_SEARCH_URL`
|
||||
or `VITE_ARCADIA_SEARCH_URL`). The bearer token is the user's
|
||||
arcadia JWT from `sessionStorage["arcadia_access_token"]`, with a
|
||||
`"dev"` fallback when no login.
|
||||
- Reindex button: `app/routes/ai.tsx::reindexKB` calls
|
||||
`POST /index/:corpus/build` and toasts the result. Lives in the
|
||||
AI page's empty state next to the block-preview button.
|
||||
- System prompt: see the snippet in `assistant.tsx::buildAdminPreface`
|
||||
above.
|
||||
|
||||
**Storage.** `<INDEX_DIR>/<tenant>/<corpus>/current/` per index;
|
||||
`previous-<stamp>/` for the last few rebuilds (rollback). Sources can
|
||||
be on-disk markdown, or pulled from arcadia's `/api/v1/digital_objects`
|
||||
API (see `arcadia-search/MULTI_TENANT.md` and `ARCADIA_INTEGRATION.md`).
|
||||
|
||||
**Update cadence.** Three triggers, layered so each compensates for
|
||||
the others' failure modes:
|
||||
- **Cron** (systemd timer, hourly default) — always-on safety net.
|
||||
- **Admin button** — one-click rebuild from the AI page.
|
||||
- **Webhook** — arcadia POSTs `/events/changed` on file create/delete;
|
||||
search debounces (2-min default) and rebuilds.
|
||||
|
||||
**Why it exists.** Anything that doesn't fit the browser ceiling:
|
||||
tenant-uploaded files, audit-log-ish content, multi-tenant knowledge
|
||||
bases, anything that grows over time.
|
||||
|
||||
For the service see `arcadia-search/README.md`.
|
||||
For multi-tenant config see `arcadia-search/MULTI_TENANT.md`.
|
||||
For the upstream arcadia integration story (file content fetch,
|
||||
text extraction, webhook signature, service tokens) see
|
||||
`arcadia-search/ARCADIA_INTEGRATION.md`.
|
||||
|
||||
---
|
||||
|
||||
## How they coexist
|
||||
|
||||
The default deploy runs **both**:
|
||||
|
||||
- `search_docs` indexes the same arcadia-app docs the parity corpus
|
||||
on `arcadia-search` indexes. Same content, two engines.
|
||||
- This is intentional — it means the assistant always has *something*
|
||||
to search, even if `arcadia-search` is down or unreachable. The
|
||||
failure mode is "no `search_kb`, but `search_docs` still works."
|
||||
- It also gives a permanent A/B regression test: query both, compare
|
||||
hits, catch relevance regressions in either engine.
|
||||
|
||||
---
|
||||
|
||||
## Picking ONE for a new corpus
|
||||
|
||||
Use this checklist when adding new content:
|
||||
|
||||
| Question | Answer → use |
|
||||
|---|---|
|
||||
| Is the corpus < 5MB and basically static? | Browser |
|
||||
| Does it need to update without a redeploy? | Server |
|
||||
| Is it per-tenant content (uploaded files, tenant-specific KB)? | Server |
|
||||
| Are you OK shipping it in the JS bundle? | Browser |
|
||||
| Does it need agentic `read_chunk` follow-up? | Server (browser doesn't expose `read` over the tool surface) |
|
||||
| Does it need to work offline / with no backend? | Browser |
|
||||
| Is it growing > 10MB? | Server |
|
||||
|
||||
Most "knowledge base" content lives in the server side. The browser
|
||||
side is reserved for the always-bundled reference material that ships
|
||||
with the app.
|
||||
|
||||
---
|
||||
|
||||
## What lives where (cheat sheet)
|
||||
|
||||
| Want to | Look at |
|
||||
|---|---|
|
||||
| Add a doc to the bundled browser RAG | `arcadia-admin/scripts/build-docs-index.mjs` (extend `SOURCES`) |
|
||||
| Add a tool to the agent | `arcadia-admin/app/lib/admin-tools.ts` |
|
||||
| Change the LLM's tool-picking guidance | `arcadia-admin/app/routes/assistant.tsx::buildAdminPreface` |
|
||||
| Add a corpus to arcadia-search | `arcadia-search/deploy/<tenant>/<corpus>.config.json` + new systemd timer |
|
||||
| Add a tenant to arcadia-search | `arcadia-search/MULTI_TENANT.md` |
|
||||
| Wire an arcadia file → search ingest | `arcadia-search/ARCADIA_INTEGRATION.md` (needs upstream changes first) |
|
||||
| Reindex the server-side corpus right now | "reindex kb (docs)" button on `/ai` empty state, or `POST /index/docs/build` |
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"d3-geo": "^3.1.1",
|
||||
"isbot": "^5.1.36",
|
||||
"lucide-react": "^1.8.0",
|
||||
"minisearch": "^7.2.0",
|
||||
"motion": "^12.38.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"phoenix": "^1.8.5",
|
||||
@@ -7264,6 +7265,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minisearch": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz",
|
||||
"integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prebuild": "npm run build:docs",
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"sync-libs": "node scripts/sync-libs.mjs"
|
||||
"sync-libs": "node scripts/sync-libs.mjs",
|
||||
"build:docs": "node scripts/build-docs-index.mjs",
|
||||
"mint:search-token": "node scripts/mint-search-token.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
@@ -21,6 +24,7 @@
|
||||
"d3-geo": "^3.1.1",
|
||||
"isbot": "^5.1.36",
|
||||
"lucide-react": "^1.8.0",
|
||||
"minisearch": "^7.2.0",
|
||||
"motion": "^12.38.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"phoenix": "^1.8.5",
|
||||
|
||||
50
scripts/build-docs-index.mjs
Normal file
50
scripts/build-docs-index.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
// Build the /docs-index.json bundle consumed by @crema/lexical-rag-ui at
|
||||
// runtime. Thin wrapper — all engine logic lives in the lib's builder.ts;
|
||||
// this file owns the per-app config (which arcadia-app docs to index and
|
||||
// how to tag them).
|
||||
//
|
||||
// Run: npm run build:docs
|
||||
//
|
||||
// Allowlist is intentional. Excluded files are aspirational/stale and
|
||||
// would poison answers (TODO lists, design docs for unshipped features,
|
||||
// sub-app READMEs that aren't part of arcadia-core). To add a file,
|
||||
// append to SOURCES below — don't auto-discover.
|
||||
|
||||
import { resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import MiniSearch from "minisearch"
|
||||
import { buildIndex } from "../../lib-lexical-rag-ui/src/builder.mjs"
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const ARCADIA = resolve(ROOT, "../reference/arcadia-app")
|
||||
const OUT = resolve(ROOT, "public/docs-index.json")
|
||||
|
||||
const SOURCES = [
|
||||
// Arcadia platform docs (resolved against ARCADIA = ../reference/arcadia-app).
|
||||
{ path: "README.md", tags: ["core"] },
|
||||
{ path: "docs/ARCADIA.md", tags: ["core"] },
|
||||
{ path: "docs/MODULAR_MONOLITH.md", tags: ["core"] },
|
||||
{ path: "apps/arcadia_core/README.md", tags: ["core"] },
|
||||
{ path: "DEPLOY.md", tags: ["ops"] },
|
||||
{ path: "DEV_DEPLOY.md", tags: ["ops"] },
|
||||
{ path: "DEV_SETUP.md", tags: ["ops"] },
|
||||
|
||||
// RAG ecosystem docs — pulled from sibling repos via per-source
|
||||
// rootDir override. Lets the assistant answer "how do I add a
|
||||
// tenant to arcadia-search" or "what does the browser RAG do"
|
||||
// without leaving the chat.
|
||||
{ rootDir: "../../arcadia-search", path: "README.md", tags: ["rag", "search-service"] },
|
||||
{ rootDir: "../../arcadia-search", path: "MULTI_TENANT.md", tags: ["rag", "search-service"] },
|
||||
{ rootDir: "../../arcadia-search", path: "ARCADIA_INTEGRATION.md", tags: ["rag", "integration"] },
|
||||
{ rootDir: "../../lib-lexical-rag-ui", path: "README.md", tags: ["rag", "browser"] },
|
||||
{ rootDir: "../../arcadia-admin", path: "docs/RAG.md", tags: ["rag", "overview"] },
|
||||
]
|
||||
|
||||
buildIndex({
|
||||
miniSearch: MiniSearch,
|
||||
rootDir: ARCADIA,
|
||||
outPath: OUT,
|
||||
sources: SOURCES,
|
||||
})
|
||||
84
scripts/mint-search-token.mjs
Executable file
84
scripts/mint-search-token.mjs
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
// Mint an HMAC-signed JWT for arcadia-search and (optionally) write it
|
||||
// into .env.local as VITE_ARCADIA_SEARCH_TOKEN.
|
||||
//
|
||||
// arcadia-search expects:
|
||||
// - HS512 by default when only JWT_HMAC_SECRET is set on the server
|
||||
// (no PEM). HS256/HS384 work too if JWT_ALGORITHM is overridden.
|
||||
// - a `tenant_id` claim (configurable server-side via JWT_TENANT_CLAIM).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/mint-search-token.mjs # uses defaults, writes .env.local
|
||||
// node scripts/mint-search-token.mjs --print # print only, don't write
|
||||
// node scripts/mint-search-token.mjs --tenant=foo --days=30
|
||||
// JWT_HMAC_SECRET=xyz node scripts/mint-search-token.mjs
|
||||
|
||||
import { createHmac } from "node:crypto"
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { dirname, resolve } from "node:path"
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = resolve(HERE, "..")
|
||||
const ENV_LOCAL = resolve(PROJECT_ROOT, ".env.local")
|
||||
|
||||
function arg(name, fallback) {
|
||||
const prefix = `--${name}=`
|
||||
const hit = process.argv.find((a) => a.startsWith(prefix))
|
||||
return hit ? hit.slice(prefix.length) : fallback
|
||||
}
|
||||
|
||||
const flag = (name) => process.argv.includes(`--${name}`)
|
||||
|
||||
const SECRET = process.env.JWT_HMAC_SECRET ?? "test-secret-change-me"
|
||||
const TENANT = arg("tenant", process.env.VITE_ARCADIA_TENANT ?? "platform-admin")
|
||||
const SUBJECT = arg("sub", "arcadia-admin")
|
||||
const ALGORITHM = (arg("alg", "HS512")).toUpperCase()
|
||||
const DAYS = Number(arg("days", "365"))
|
||||
const PRINT_ONLY = flag("print")
|
||||
|
||||
const HMAC_ALG = { HS256: "sha256", HS384: "sha384", HS512: "sha512" }[ALGORITHM]
|
||||
if (!HMAC_ALG) {
|
||||
console.error(`Unsupported algorithm "${ALGORITHM}". Use HS256, HS384, or HS512.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const b64 = (v) =>
|
||||
Buffer.from(typeof v === "string" ? v : JSON.stringify(v)).toString("base64url")
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = b64({ alg: ALGORITHM, typ: "JWT" })
|
||||
const payload = b64({
|
||||
sub: SUBJECT,
|
||||
tenant_id: TENANT,
|
||||
iat: now,
|
||||
exp: now + DAYS * 86400,
|
||||
})
|
||||
const signature = createHmac(HMAC_ALG, SECRET)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest("base64url")
|
||||
const token = `${header}.${payload}.${signature}`
|
||||
|
||||
if (PRINT_ONLY) {
|
||||
console.log(token)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Upsert VITE_ARCADIA_SEARCH_TOKEN in .env.local without disturbing other keys.
|
||||
const KEY = "VITE_ARCADIA_SEARCH_TOKEN"
|
||||
let existing = ""
|
||||
if (existsSync(ENV_LOCAL)) existing = readFileSync(ENV_LOCAL, "utf8")
|
||||
const line = `${KEY}=${token}`
|
||||
const next = new RegExp(`^${KEY}=.*$`, "m").test(existing)
|
||||
? existing.replace(new RegExp(`^${KEY}=.*$`, "m"), line)
|
||||
: (existing.endsWith("\n") || existing === "" ? existing : existing + "\n") + line + "\n"
|
||||
writeFileSync(ENV_LOCAL, next)
|
||||
|
||||
console.log(`Wrote ${KEY} to ${ENV_LOCAL}`)
|
||||
console.log(` alg: ${ALGORITHM}`)
|
||||
console.log(` tenant: ${TENANT}`)
|
||||
console.log(` sub: ${SUBJECT}`)
|
||||
console.log(` expires: ${new Date((now + DAYS * 86400) * 1000).toISOString()}`)
|
||||
console.log(` secret: ${SECRET === "test-secret-change-me" ? "(default — override with JWT_HMAC_SECRET=)" : "(from JWT_HMAC_SECRET)"}`)
|
||||
console.log(``)
|
||||
console.log(`Restart 'npm run dev' so Vite picks up the new env var.`)
|
||||
4
start.sh
4
start.sh
@@ -9,7 +9,7 @@ LOG_FILE=".demo.log"
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
existing="$(cat "$PID_FILE")"
|
||||
if [ -n "$existing" ] && kill -0 "$existing" 2>/dev/null; then
|
||||
echo "crema-app-aifirst-template already running (pid $existing)"
|
||||
echo "arcadia-admin already running (pid $existing)"
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
@@ -20,4 +20,4 @@ pid=$!
|
||||
echo "$pid" >"$PID_FILE"
|
||||
disown "$pid" 2>/dev/null || true
|
||||
|
||||
echo "crema-app-aifirst-template started (pid $pid) — logs: $LOG_FILE"
|
||||
echo "arcadia-admin started (pid $pid) — logs: $LOG_FILE"
|
||||
|
||||
6
stop.sh
6
stop.sh
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
|
||||
PID_FILE=".demo.pid"
|
||||
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "crema-app-aifirst-template not running (no .demo.pid)"
|
||||
echo "arcadia-admin not running (no .demo.pid)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -15,9 +15,9 @@ pid="$(cat "$PID_FILE")"
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
pkill -P "$pid" 2>/dev/null || true
|
||||
kill "$pid" 2>/dev/null || true
|
||||
echo "crema-app-aifirst-template stopped (pid $pid)"
|
||||
echo "arcadia-admin stopped (pid $pid)"
|
||||
else
|
||||
echo "crema-app-aifirst-template pid $pid not alive, cleaning up"
|
||||
echo "arcadia-admin pid $pid not alive, cleaning up"
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"@crema/action-bus/*": ["../lib-action-bus/src/*"],
|
||||
"@crema/arcadia-client": ["../lib-arcadia-client/src/index.tsx"],
|
||||
"@crema/arcadia-client/*": ["../lib-arcadia-client/src/*"],
|
||||
"@crema/integration-registry-client": ["../lib-integration-registry-client/src/index.tsx"],
|
||||
"@crema/integration-registry-client/*": ["../lib-integration-registry-client/src/*"],
|
||||
"@crema/arcadia-auth-ui": ["../lib-arcadia-auth-ui/src/index.tsx"],
|
||||
"@crema/arcadia-auth-ui/*": ["../lib-arcadia-auth-ui/src/*"],
|
||||
"@crema/table-ui": ["../lib-table-ui/src/index.tsx"],
|
||||
@@ -42,6 +44,28 @@
|
||||
"@crema/agent-ui/*": ["../lib-agent-ui/src/*"],
|
||||
"@crema/llm-providers-ui": ["../lib-llm-providers-ui/src/index.tsx"],
|
||||
"@crema/llm-providers-ui/*": ["../lib-llm-providers-ui/src/*"],
|
||||
"@crema/file-ui": ["../lib-file-ui/src/index.tsx"],
|
||||
"@crema/file-ui/*": ["../lib-file-ui/src/*"],
|
||||
"@crema/card-ui": ["../lib-card-ui/src/index.tsx"],
|
||||
"@crema/card-ui/*": ["../lib-card-ui/src/*"],
|
||||
"@crema/dashboard-ui": ["../lib-dashboard-ui/src/index.tsx"],
|
||||
"@crema/dashboard-ui/*": ["../lib-dashboard-ui/src/*"],
|
||||
"@crema/chart-ui": ["../lib-chart-ui/src/index.tsx"],
|
||||
"@crema/chart-ui/*": ["../lib-chart-ui/src/*"],
|
||||
"@crema/map-ui": ["../lib-map-ui/src/index.tsx"],
|
||||
"@crema/map-ui/*": ["../lib-map-ui/src/*"],
|
||||
"@crema/status-ui": ["../lib-status-ui/src/index.tsx"],
|
||||
"@crema/status-ui/*": ["../lib-status-ui/src/*"],
|
||||
"@crema/data-ui": ["../lib-data-ui/src/index.tsx"],
|
||||
"@crema/data-ui/*": ["../lib-data-ui/src/*"],
|
||||
"@crema/code-ui": ["../lib-code-ui/src/index.tsx"],
|
||||
"@crema/code-ui/*": ["../lib-code-ui/src/*"],
|
||||
"@crema/diagram-ui": ["../lib-diagram-ui/src/index.tsx"],
|
||||
"@crema/diagram-ui/*": ["../lib-diagram-ui/src/*"],
|
||||
"@crema/onboarding-ui": ["../lib-onboarding-ui/src/index.tsx"],
|
||||
"@crema/onboarding-ui/*": ["../lib-onboarding-ui/src/*"],
|
||||
"@crema/lexical-rag-ui": ["../lib-lexical-rag-ui/src/index.tsx"],
|
||||
"@crema/lexical-rag-ui/*": ["../lib-lexical-rag-ui/src/*"],
|
||||
"// CREMA:PATHS": [""],
|
||||
"react": ["./node_modules/@types/react"],
|
||||
"react/*": ["./node_modules/@types/react/*"],
|
||||
@@ -49,6 +73,7 @@
|
||||
"react-dom/*": ["./node_modules/@types/react-dom/*"],
|
||||
"clsx": ["./node_modules/clsx"],
|
||||
"tailwind-merge": ["./node_modules/tailwind-merge"],
|
||||
"minisearch": ["./node_modules/minisearch"],
|
||||
"lucide-react": ["./node_modules/lucide-react"],
|
||||
"openapi-fetch": ["./node_modules/openapi-fetch"],
|
||||
"openapi-fetch/*": ["./node_modules/openapi-fetch/*"],
|
||||
|
||||
122
vite.config.ts
122
vite.config.ts
@@ -62,6 +62,9 @@ const searchUiSrc = fileURLToPath(
|
||||
const arcadiaClientSrc = fileURLToPath(
|
||||
new URL("../lib-arcadia-client/src", import.meta.url),
|
||||
)
|
||||
const integrationRegistryClientSrc = fileURLToPath(
|
||||
new URL("../lib-integration-registry-client/src", import.meta.url),
|
||||
)
|
||||
const arcadiaAuthUiSrc = fileURLToPath(
|
||||
new URL("../lib-arcadia-auth-ui/src", import.meta.url),
|
||||
)
|
||||
@@ -71,6 +74,39 @@ const llmUiSrc = fileURLToPath(
|
||||
const llmProvidersUiSrc = fileURLToPath(
|
||||
new URL("../lib-llm-providers-ui/src", import.meta.url),
|
||||
)
|
||||
const fileUiSrc = fileURLToPath(
|
||||
new URL("../lib-file-ui/src", import.meta.url),
|
||||
)
|
||||
const cardUiSrc = fileURLToPath(
|
||||
new URL("../lib-card-ui/src", import.meta.url),
|
||||
)
|
||||
const dashboardUiSrc = fileURLToPath(
|
||||
new URL("../lib-dashboard-ui/src", import.meta.url),
|
||||
)
|
||||
const chartUiSrc = fileURLToPath(
|
||||
new URL("../lib-chart-ui/src", import.meta.url),
|
||||
)
|
||||
const statusUiSrc = fileURLToPath(
|
||||
new URL("../lib-status-ui/src", import.meta.url),
|
||||
)
|
||||
const actionBusSrc = fileURLToPath(
|
||||
new URL("../lib-action-bus/src", import.meta.url),
|
||||
)
|
||||
const agentUiSrc = fileURLToPath(
|
||||
new URL("../lib-agent-ui/src", import.meta.url),
|
||||
)
|
||||
const aifirstUiSrc = fileURLToPath(
|
||||
new URL("../lib-aifirst-ui/src", import.meta.url),
|
||||
)
|
||||
const lexicalRagUiSrc = fileURLToPath(
|
||||
new URL("../lib-lexical-rag-ui/src", import.meta.url),
|
||||
)
|
||||
const notificationUiSrc = fileURLToPath(
|
||||
new URL("../lib-notification-ui/src", import.meta.url),
|
||||
)
|
||||
const onboardingUiSrc = fileURLToPath(
|
||||
new URL("../lib-onboarding-ui/src", import.meta.url),
|
||||
)
|
||||
|
||||
// Sibling lib packages (lib-content-ui, lib-content-editor-ui) import bare
|
||||
// deps like clsx and @tiptap/* but have no node_modules of their own. Pin
|
||||
@@ -89,6 +125,9 @@ const aliasedDeps = [
|
||||
"@tiptap/extension-link",
|
||||
"@tiptap/extension-placeholder",
|
||||
"@tiptap/extension-image",
|
||||
"minisearch",
|
||||
"react-markdown",
|
||||
"remark-gfm",
|
||||
]
|
||||
const sharedDepAliases = Object.fromEntries(
|
||||
aliasedDeps.map((name) => [name, resolvePath(nodeModules, name)]),
|
||||
@@ -103,31 +142,64 @@ const dedupeDeps = [
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@crema/content-ui": `${contentUiSrc}/index.ts`,
|
||||
"@crema/content-editor-ui": `${contentEditorUiSrc}/index.ts`,
|
||||
"@crema/content-media-ui": `${contentMediaUiSrc}/index.tsx`,
|
||||
"@crema/color-ui": `${colorUiSrc}/index.tsx`,
|
||||
"@crema/typography-ui": `${typographyUiSrc}/index.tsx`,
|
||||
"@crema/data-ui": `${dataUiSrc}/index.tsx`,
|
||||
"@crema/layout-ui": `${layoutUiSrc}/index.tsx`,
|
||||
"@crema/map-ui": `${mapUiSrc}/index.tsx`,
|
||||
"@crema/form-ui": `${formUiSrc}/index.tsx`,
|
||||
"@crema/feedback-ui": `${feedbackUiSrc}/index.tsx`,
|
||||
"@crema/diagram-ui": `${diagramUiSrc}/index.tsx`,
|
||||
"@crema/chat-ui": `${chatUiSrc}/index.tsx`,
|
||||
"@crema/calendar-ui": `${calendarUiSrc}/index.tsx`,
|
||||
"@crema/code-ui": `${codeUiSrc}/index.tsx`,
|
||||
"@crema/ai-ui": `${aiUiSrc}/index.tsx`,
|
||||
"@crema/auth-ui": `${authUiSrc}/index.tsx`,
|
||||
"@crema/table-ui": `${tableUiSrc}/index.tsx`,
|
||||
"@crema/search-ui": `${searchUiSrc}/index.tsx`,
|
||||
"@crema/arcadia-client": `${arcadiaClientSrc}/index.tsx`,
|
||||
"@crema/arcadia-auth-ui": `${arcadiaAuthUiSrc}/index.tsx`,
|
||||
"@crema/llm-ui": `${llmUiSrc}/index.tsx`,
|
||||
"@crema/llm-providers-ui": `${llmProvidersUiSrc}/index.tsx`,
|
||||
...sharedDepAliases,
|
||||
},
|
||||
// Array form so we can express both the bare-specifier alias
|
||||
// (`@crema/agent-ui` -> src/index.tsx) and a subpath prefix alias
|
||||
// (`@crema/agent-ui/chat` -> src/chat) for libs whose sibling-lib
|
||||
// code reaches into deeper modules. Prefix entries with regex `find`
|
||||
// are matched first.
|
||||
alias: [
|
||||
// Subpath prefixes — longest first so they win before the bare match.
|
||||
{ find: /^@crema\/agent-ui\//, replacement: `${agentUiSrc}/` },
|
||||
{ find: /^@crema\/aifirst-ui\//, replacement: `${aifirstUiSrc}/` },
|
||||
{ find: /^@crema\/notification-ui\//, replacement: `${notificationUiSrc}/` },
|
||||
{ find: /^@crema\/onboarding-ui\//, replacement: `${onboardingUiSrc}/` },
|
||||
{ find: /^@crema\/lexical-rag-ui\//, replacement: `${lexicalRagUiSrc}/` },
|
||||
{ find: /^@crema\/action-bus\//, replacement: `${actionBusSrc}/` },
|
||||
|
||||
// Bare-specifier exact matches.
|
||||
{ find: "@crema/content-ui", replacement: `${contentUiSrc}/index.ts` },
|
||||
{ find: "@crema/content-editor-ui", replacement: `${contentEditorUiSrc}/index.ts` },
|
||||
{ find: "@crema/content-media-ui", replacement: `${contentMediaUiSrc}/index.tsx` },
|
||||
{ find: "@crema/color-ui", replacement: `${colorUiSrc}/index.tsx` },
|
||||
{ find: "@crema/typography-ui", replacement: `${typographyUiSrc}/index.tsx` },
|
||||
{ find: "@crema/data-ui", replacement: `${dataUiSrc}/index.tsx` },
|
||||
{ find: "@crema/layout-ui", replacement: `${layoutUiSrc}/index.tsx` },
|
||||
{ find: "@crema/map-ui", replacement: `${mapUiSrc}/index.tsx` },
|
||||
{ find: "@crema/form-ui", replacement: `${formUiSrc}/index.tsx` },
|
||||
{ find: "@crema/feedback-ui", replacement: `${feedbackUiSrc}/index.tsx` },
|
||||
{ find: "@crema/diagram-ui", replacement: `${diagramUiSrc}/index.tsx` },
|
||||
{ find: "@crema/chat-ui", replacement: `${chatUiSrc}/index.tsx` },
|
||||
{ find: "@crema/calendar-ui", replacement: `${calendarUiSrc}/index.tsx` },
|
||||
{ find: "@crema/code-ui", replacement: `${codeUiSrc}/index.tsx` },
|
||||
{ find: "@crema/ai-ui", replacement: `${aiUiSrc}/index.tsx` },
|
||||
{ find: "@crema/auth-ui", replacement: `${authUiSrc}/index.tsx` },
|
||||
{ find: "@crema/table-ui", replacement: `${tableUiSrc}/index.tsx` },
|
||||
{ find: "@crema/search-ui", replacement: `${searchUiSrc}/index.tsx` },
|
||||
{ find: "@crema/arcadia-client", replacement: `${arcadiaClientSrc}/index.tsx` },
|
||||
{
|
||||
find: "@crema/integration-registry-client",
|
||||
replacement: `${integrationRegistryClientSrc}/index.tsx`,
|
||||
},
|
||||
{ find: "@crema/arcadia-auth-ui", replacement: `${arcadiaAuthUiSrc}/index.tsx` },
|
||||
{ find: "@crema/llm-ui", replacement: `${llmUiSrc}/index.tsx` },
|
||||
{ find: "@crema/llm-providers-ui", replacement: `${llmProvidersUiSrc}/index.tsx` },
|
||||
{ find: "@crema/file-ui", replacement: `${fileUiSrc}/index.tsx` },
|
||||
{ find: "@crema/card-ui", replacement: `${cardUiSrc}/index.tsx` },
|
||||
{ find: "@crema/dashboard-ui", replacement: `${dashboardUiSrc}/index.tsx` },
|
||||
{ find: "@crema/chart-ui", replacement: `${chartUiSrc}/index.tsx` },
|
||||
{ find: "@crema/status-ui", replacement: `${statusUiSrc}/index.tsx` },
|
||||
{ find: "@crema/action-bus", replacement: `${actionBusSrc}/index.tsx` },
|
||||
{ find: "@crema/agent-ui", replacement: `${agentUiSrc}/index.tsx` },
|
||||
{ find: "@crema/aifirst-ui", replacement: `${aifirstUiSrc}/index.tsx` },
|
||||
{ find: "@crema/lexical-rag-ui", replacement: `${lexicalRagUiSrc}/index.tsx` },
|
||||
{ find: "@crema/notification-ui", replacement: `${notificationUiSrc}/index.tsx` },
|
||||
{ find: "@crema/onboarding-ui", replacement: `${onboardingUiSrc}/index.tsx` },
|
||||
|
||||
...Object.entries(sharedDepAliases).map(([find, replacement]) => ({
|
||||
find,
|
||||
replacement,
|
||||
})),
|
||||
],
|
||||
dedupe: dedupeDeps,
|
||||
},
|
||||
// Pre-bundle deps that sibling libs reach for. Without this, Vite
|
||||
|
||||
Reference in New Issue
Block a user