Compare commits
4 Commits
a299900021
...
feat/integ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
938143f3f5 | ||
|
|
ab116f8465 | ||
|
|
4b817b85ff | ||
|
|
06490865d3 |
@@ -7,9 +7,9 @@ This file is a quick map, not a duplication of upstream docs.
|
|||||||
|
|
||||||
## What Arcadia Admin is
|
## What Arcadia Admin is
|
||||||
|
|
||||||
- **Arcadia Admin** is the operator/admin UI for [arcadia-core](../reference/arcadia-app), a multi-tenant Phoenix backend. Surfaces tenant management, user/role admin, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
|
- **Arcadia Admin** is the operator/admin UI for [arcadia-core](../reference/arcadia-core), a multi-tenant Phoenix backend. Surfaces tenant management, user/role admin, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
|
||||||
- **Cloned from** [Vibespace](../vibespace) — the starter for webapps in this style. Vibespace and Skyrise are the upstream sources of truth for the shell and the theme; don't backport arcadia-admin-specific changes into Vibespace unless they're broadly applicable.
|
- **Cloned from** [Vibespace](../vibespace) — the starter for webapps in this style. Vibespace and Skyrise are the upstream sources of truth for the shell and the theme; don't backport arcadia-admin-specific changes into Vibespace unless they're broadly applicable.
|
||||||
- **Backend reference** lives at `../reference/arcadia-app/`. Treat it as read-only documentation — it's the Phoenix umbrella app that owns the OpenAPI spec, controllers, schemas, and seed data. Spec is regenerated from a running arcadia at `http://localhost:4000/api/openapi` via `node ../lib-arcadia-client/scripts/sync-spec.mjs` (run from this directory).
|
- **Backend reference** lives at `../reference/arcadia-core/`. Treat it as read-only documentation — it's the Phoenix umbrella app that owns the OpenAPI spec, controllers, schemas, and seed data. Spec is regenerated from a running arcadia at `http://localhost:4000/api/openapi` via `node ../lib-arcadia-core-client/scripts/sync-spec.mjs` (run from this directory).
|
||||||
- **Skyrise** (`lib-theme-skyrise`) is the canonical theme — premium AI-first glass, iridescent body, vivid text, Apple-spring motion. Theme tweaks belong upstream in Vibespace + Skyrise, not here.
|
- **Skyrise** (`lib-theme-skyrise`) is the canonical theme — premium AI-first glass, iridescent body, vivid text, Apple-spring motion. Theme tweaks belong upstream in Vibespace + Skyrise, not here.
|
||||||
- The brand string lives in **one place**: `app/lib/identity.ts` (`useBrand()` / `getBrand()`). Don't hardcode "Arcadia Admin" in components, page titles, or copy.
|
- The brand string lives in **one place**: `app/lib/identity.ts` (`useBrand()` / `getBrand()`). Don't hardcode "Arcadia Admin" in components, page titles, or copy.
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Arcadia Admin
|
# Arcadia Admin
|
||||||
|
|
||||||
Admin webapp for [arcadia-core](../reference/arcadia-app) — the multi-tenant Phoenix backend. Built on the [Crema design system](https://git.sky-ai.com/CremaUIStudio) with the **Skyrise** theme and started from the [Vibespace](../vibespace) starter.
|
Admin webapp for [arcadia-core](../reference/arcadia-core) — the multi-tenant Phoenix backend. Built on the [Crema design system](https://git.sky-ai.com/CremaUIStudio) with the **Skyrise** theme and started from the [Vibespace](../vibespace) starter.
|
||||||
|
|
||||||
Surfaces tenant management, user/role administration, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
|
Surfaces tenant management, user/role administration, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Open [http://localhost:5173](http://localhost:5173). The app talks to arcadia at
|
|||||||
|
|
||||||
To use it for real:
|
To use it for real:
|
||||||
|
|
||||||
1. Have arcadia running locally (see `../reference/arcadia-app/DEV_SETUP.md`).
|
1. Have arcadia running locally (see `../reference/arcadia-core/DEV_SETUP.md`).
|
||||||
2. Visit `/login` and sign in with admin credentials. In dev seeds: `admin@example.com` / `AdminP@ssw0rd` (tenant `default`).
|
2. Visit `/login` and sign in with admin credentials. In dev seeds: `admin@example.com` / `AdminP@ssw0rd` (tenant `default`).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -33,7 +33,7 @@ To use it for real:
|
|||||||
`app/components/layout/app-shell.tsx` — left rail + appbar + avatar dropdown. Brand identity in `app/lib/identity.ts` (`name: "Arcadia Admin"`, icon: `Shield`). The shell is **template code, not a lib** — fork it freely as admin features are added.
|
`app/components/layout/app-shell.tsx` — left rail + appbar + avatar dropdown. Brand identity in `app/lib/identity.ts` (`name: "Arcadia Admin"`, icon: `Shield`). The shell is **template code, not a lib** — fork it freely as admin features are added.
|
||||||
|
|
||||||
### Arcadia client + auth UI
|
### Arcadia client + auth UI
|
||||||
- [`@crema/arcadia-client`](../lib-arcadia-client) — typed HTTP client (generic + openapi-fetch-backed `client.typed`), Phoenix Channels realtime, error normalization. Mounted at the root via `<ArcadiaProvider>`.
|
- [`@crema/arcadia-core-client`](../lib-arcadia-core-client) — typed HTTP client (generic + openapi-fetch-backed `client.typed`), Phoenix Channels realtime, error normalization. Mounted at the root via `<ArcadiaProvider>`.
|
||||||
- [`@crema/arcadia-auth-ui`](../lib-arcadia-auth-ui) — login / signup / password reset / 2FA forms, themed via Skyrise tokens. The `/login` route renders `<LoginForm>`.
|
- [`@crema/arcadia-auth-ui`](../lib-arcadia-auth-ui) — login / signup / password reset / 2FA forms, themed via Skyrise tokens. The `/login` route renders `<LoginForm>`.
|
||||||
|
|
||||||
### Skyrise theme
|
### Skyrise theme
|
||||||
@@ -50,8 +50,8 @@ Surface tints (`body[data-surface="snow|stone|sage|slate"]`) and dark mode (`htm
|
|||||||
your-workspace/
|
your-workspace/
|
||||||
arcadia-admin/ ← this repo
|
arcadia-admin/ ← this repo
|
||||||
vibespace/ ← starter that this was cloned from
|
vibespace/ ← starter that this was cloned from
|
||||||
reference/arcadia-app/ ← Phoenix backend (read-only reference)
|
reference/arcadia-core/ ← Phoenix backend (read-only reference)
|
||||||
lib-arcadia-client/
|
lib-arcadia-core-client/
|
||||||
lib-arcadia-auth-ui/
|
lib-arcadia-auth-ui/
|
||||||
lib-action-bus/
|
lib-action-bus/
|
||||||
lib-aifirst-ui/
|
lib-aifirst-ui/
|
||||||
@@ -84,4 +84,4 @@ your-workspace/
|
|||||||
- [`docs/AI_FIRST.md`](docs/AI_FIRST.md) — command-bus / DSL system tour
|
- [`docs/AI_FIRST.md`](docs/AI_FIRST.md) — command-bus / DSL system tour
|
||||||
- [`app/components/layout/THEME_CONTRACT.md`](app/components/layout/THEME_CONTRACT.md) — token contract every theme must satisfy
|
- [`app/components/layout/THEME_CONTRACT.md`](app/components/layout/THEME_CONTRACT.md) — token contract every theme must satisfy
|
||||||
- `CLAUDE.md` — orientation for an LLM working in this repo
|
- `CLAUDE.md` — orientation for an LLM working in this repo
|
||||||
- `../reference/arcadia-app/` — backend (DEV_SETUP, controllers, OpenAPI source-of-truth)
|
- `../reference/arcadia-core/` — backend (DEV_SETUP, controllers, OpenAPI source-of-truth)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
@source "../../lib-aifirst-ui/src";
|
@source "../../lib-aifirst-ui/src";
|
||||||
@source "../../lib-llm-ui/src";
|
@source "../../lib-llm-ui/src";
|
||||||
@source "../../lib-action-bus/src";
|
@source "../../lib-action-bus/src";
|
||||||
@source "../../lib-arcadia-client/src";
|
@source "../../lib-arcadia-core-client/src";
|
||||||
@source "../../lib-arcadia-auth-ui/src";
|
@source "../../lib-arcadia-auth-ui/src";
|
||||||
@source "../../lib-table-ui/src";
|
@source "../../lib-table-ui/src";
|
||||||
@source "../../lib-search-ui/src";
|
@source "../../lib-search-ui/src";
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
Plug,
|
Plug,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Eye,
|
Eye,
|
||||||
|
LayoutGrid,
|
||||||
|
CreditCard,
|
||||||
// CREMA:NAV-ICONS
|
// CREMA:NAV-ICONS
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ import {
|
|||||||
} from "~/components/ui/popover"
|
} from "~/components/ui/popover"
|
||||||
import { profileInitials, useProfile } from "~/lib/profile"
|
import { profileInitials, useProfile } from "~/lib/profile"
|
||||||
import { signOut, useSession } from "~/lib/session"
|
import { signOut, useSession } from "~/lib/session"
|
||||||
|
import { capabilityForPath, useCapabilities } from "~/lib/capabilities"
|
||||||
import {
|
import {
|
||||||
addNotification,
|
addNotification,
|
||||||
dismiss,
|
dismiss,
|
||||||
@@ -96,6 +99,7 @@ import {
|
|||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "~/components/ui/sheet"
|
} from "~/components/ui/sheet"
|
||||||
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
|
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
|
||||||
|
import { RouteGuard } from "~/components/route-guard"
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
to: string
|
to: string
|
||||||
@@ -134,6 +138,16 @@ const navGroups: NavGroup[] = [
|
|||||||
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
|
{ 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",
|
key: "data",
|
||||||
label: "Data",
|
label: "Data",
|
||||||
@@ -142,6 +156,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
||||||
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
||||||
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
|
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
|
||||||
|
{ to: "/integrations", icon: Plug, label: "Integrations" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -189,15 +204,6 @@ const extraNavItems: NavItem[] = [
|
|||||||
// CREMA:NAV-ITEMS
|
// CREMA:NAV-ITEMS
|
||||||
]
|
]
|
||||||
|
|
||||||
// Flat list — used by the icon-only collapsed rail, where group headers
|
|
||||||
// don't render and items appear as a single column of icons.
|
|
||||||
const allNavItems: NavItem[] = [
|
|
||||||
...pinnedTop,
|
|
||||||
...navGroups.flatMap((g) => g.items),
|
|
||||||
...extraNavItems,
|
|
||||||
...pinnedBottom,
|
|
||||||
]
|
|
||||||
|
|
||||||
function readNavGroupState(): Record<string, boolean> {
|
function readNavGroupState(): Record<string, boolean> {
|
||||||
if (typeof window === "undefined") return {}
|
if (typeof window === "undefined") return {}
|
||||||
try {
|
try {
|
||||||
@@ -230,6 +236,7 @@ export function AppShell({
|
|||||||
const defaultUser = useUser()
|
const defaultUser = useUser()
|
||||||
const profile = useProfile()
|
const profile = useProfile()
|
||||||
const session = useSession()
|
const session = useSession()
|
||||||
|
const caps = useCapabilities()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const brand = brandOverride ?? defaultBrand
|
const brand = brandOverride ?? defaultBrand
|
||||||
// Prefer the live session for identity, fall back to the stub user.
|
// Prefer the live session for identity, fall back to the stub user.
|
||||||
@@ -264,11 +271,51 @@ export function AppShell({
|
|||||||
useScriptsHotkey(() => setScriptsOpen(true))
|
useScriptsHotkey(() => setScriptsOpen(true))
|
||||||
|
|
||||||
const location = useLocation()
|
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(
|
const activeGroupKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
navGroups.find((g) => g.items.some((it) => location.pathname.startsWith(it.to)))
|
visibleNavGroups.find((g) =>
|
||||||
?.key ?? null,
|
g.items.some((it) => location.pathname.startsWith(it.to)),
|
||||||
[location.pathname],
|
)?.key ?? null,
|
||||||
|
[location.pathname, visibleNavGroups],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() =>
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() =>
|
||||||
@@ -339,11 +386,11 @@ export function AppShell({
|
|||||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<>
|
<>
|
||||||
{pinnedTop.map((item) => (
|
{visiblePinnedTop.map((item) => (
|
||||||
<NavRow key={item.label} item={item} expanded />
|
<NavRow key={item.label} item={item} expanded />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{navGroups.map((group) => {
|
{visibleNavGroups.map((group) => {
|
||||||
const isOpen = !!openGroups[group.key]
|
const isOpen = !!openGroups[group.key]
|
||||||
const GroupIcon = group.icon
|
const GroupIcon = group.icon
|
||||||
return (
|
return (
|
||||||
@@ -375,16 +422,16 @@ export function AppShell({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{extraNavItems.length > 0 ? (
|
{visibleExtraItems.length > 0 ? (
|
||||||
<div className="mt-1.5 flex flex-col gap-0.5">
|
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||||
{extraNavItems.map((item) => (
|
{visibleExtraItems.map((item) => (
|
||||||
<NavRow key={item.label} item={item} expanded />
|
<NavRow key={item.label} item={item} expanded />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||||
{pinnedBottom.map((item) => (
|
{visiblePinnedBottom.map((item) => (
|
||||||
<NavRow key={item.label} item={item} expanded />
|
<NavRow key={item.label} item={item} expanded />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -392,7 +439,7 @@ export function AppShell({
|
|||||||
) : (
|
) : (
|
||||||
// Icon-only rail: flat list, no group headers.
|
// Icon-only rail: flat list, no group headers.
|
||||||
<>
|
<>
|
||||||
{allNavItems.map((item) => (
|
{visibleAllNavItems.map((item) => (
|
||||||
<NavRow key={item.label} item={item} expanded={false} />
|
<NavRow key={item.label} item={item} expanded={false} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -443,7 +490,7 @@ export function AppShell({
|
|||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||||
{pinnedTop.map((item) => (
|
{visiblePinnedTop.map((item) => (
|
||||||
<NavRow
|
<NavRow
|
||||||
key={item.label}
|
key={item.label}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -453,7 +500,7 @@ export function AppShell({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{navGroups.map((group) => {
|
{visibleNavGroups.map((group) => {
|
||||||
const isOpen = !!openGroups[group.key]
|
const isOpen = !!openGroups[group.key]
|
||||||
const GroupIcon = group.icon
|
const GroupIcon = group.icon
|
||||||
return (
|
return (
|
||||||
@@ -492,9 +539,9 @@ export function AppShell({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{extraNavItems.length > 0 ? (
|
{visibleExtraItems.length > 0 ? (
|
||||||
<div className="mt-1.5 flex flex-col gap-0.5">
|
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||||
{extraNavItems.map((item) => (
|
{visibleExtraItems.map((item) => (
|
||||||
<NavRow
|
<NavRow
|
||||||
key={item.label}
|
key={item.label}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -507,7 +554,7 @@ export function AppShell({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||||
{pinnedBottom.map((item) => (
|
{visiblePinnedBottom.map((item) => (
|
||||||
<NavRow
|
<NavRow
|
||||||
key={item.label}
|
key={item.label}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -601,12 +648,16 @@ export function AppShell({
|
|||||||
<div
|
<div
|
||||||
id="main-content"
|
id="main-content"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
// First-child padding clears the fixed top-right floating actions
|
className="flex flex-1 flex-col focus:outline-none"
|
||||||
// pill so page headers can put refresh/new buttons in their normal
|
|
||||||
// top-right slot without sliding under the appbar avatar/controls.
|
|
||||||
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none [&>*:first-child]:lg:pr-72"
|
|
||||||
>
|
>
|
||||||
{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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -645,7 +696,11 @@ function NavRow({
|
|||||||
data-action={`${prefix}${item.label.toLowerCase()}`}
|
data-action={`${prefix}${item.label.toLowerCase()}`}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
"flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard",
|
"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
|
expanded
|
||||||
? inGroup
|
? inGroup
|
||||||
? // Indent the label by chevron(12) + gap(8) = 20px so it
|
? // Indent the label by chevron(12) + gap(8) = 20px so it
|
||||||
@@ -654,7 +709,7 @@ function NavRow({
|
|||||||
: "justify-start px-3"
|
: "justify-start px-3"
|
||||||
: "justify-center px-3",
|
: "justify-center px-3",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/[0.08] text-primary before:opacity-100"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
].join(" ")
|
].join(" ")
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
|
import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
|
||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
loadSettings as loadActiveSettings,
|
loadSettings as loadActiveSettings,
|
||||||
saveSettings as saveActiveSettings,
|
saveSettings as saveActiveSettings,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import { AlertBanner, ConfirmDialog } from "@crema/feedback-ui"
|
import { AlertBanner, ConfirmDialog } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { Badge } from "~/components/ui/badge"
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// Each tool is a named function with documented args. The LLM never sees
|
// Each tool is a named function with documented args. The LLM never sees
|
||||||
// raw HTTP — only the menu below.
|
// raw HTTP — only the menu below.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
createToolRuntime,
|
createToolRuntime,
|
||||||
type ToolDef,
|
type ToolDef,
|
||||||
@@ -53,7 +53,7 @@ const docsClient = createRAGClient("/docs-index.json")
|
|||||||
// tooling and wire it through `.env.local`.
|
// tooling and wire it through `.env.local`.
|
||||||
// 3. operator session JWT — works only when arcadia-search shares the
|
// 3. operator session JWT — works only when arcadia-search shares the
|
||||||
// JWT signing secret with the arcadia issuing the operator's session
|
// JWT signing secret with the arcadia issuing the operator's session
|
||||||
// (i.e. local arcadia-app + local arcadia-search with matching keys).
|
// (i.e. local arcadia-core + local arcadia-search with matching keys).
|
||||||
// 4. "dev" literal — only accepted by AUTH_MODE=dev backends.
|
// 4. "dev" literal — only accepted by AUTH_MODE=dev backends.
|
||||||
function readEnv(key: string): string | undefined {
|
function readEnv(key: string): string | undefined {
|
||||||
if (typeof import.meta === "undefined") return undefined
|
if (typeof import.meta === "undefined") return undefined
|
||||||
@@ -667,7 +667,7 @@ const TOOLS: ToolDef<ToolCtx>[] = [
|
|||||||
{
|
{
|
||||||
name: "search_docs",
|
name: "search_docs",
|
||||||
description:
|
description:
|
||||||
"Search the arcadia-app documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
|
"Search the arcadia-core documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -715,7 +715,7 @@ const TOOLS: ToolDef<ToolCtx>[] = [
|
|||||||
{
|
{
|
||||||
name: "search_kb",
|
name: "search_kb",
|
||||||
description:
|
description:
|
||||||
"Lexical (BM25) search over the arcadia-search Tantivy backend. Returns chunks with snippets + chunk_ids that can be passed to `read_chunk` to expand. Prefer this over `search_docs` (browser) when you need richer hits or when the content wouldn't be in the bundled docs.\n\nKnown corpora on the platform-admin tenant:\n- `docs` — arcadia-app architecture/ops docs (same as the browser RAG, server-hosted for parity).\n- `operator-tools` — arcadia-search + arcadia-admin documentation (admin sidecar, deploy script, search admin UI, MULTI_TENANT, RAG, AI_FIRST, LIBS, LLM_PROXY_CONTRACT).\n- `files` — markdown/text files uploaded by tenant users via arcadia-app.\n\nIf you're not sure what's available, call `list_search_corpora` first. Operators can add new corpora via the `/search` route.",
|
"Lexical (BM25) search over the arcadia-search Tantivy backend. Returns chunks with snippets + chunk_ids that can be passed to `read_chunk` to expand. Prefer this over `search_docs` (browser) when you need richer hits or when the content wouldn't be in the bundled docs.\n\nKnown corpora on the platform-admin tenant:\n- `docs` — arcadia-core architecture/ops docs (same as the browser RAG, server-hosted for parity).\n- `operator-tools` — arcadia-search + arcadia-admin documentation (admin sidecar, deploy script, search admin UI, MULTI_TENANT, RAG, AI_FIRST, LIBS, LLM_PROXY_CONTRACT).\n- `files` — markdown/text files uploaded by tenant users via arcadia-core.\n\nIf you're not sure what's available, call `list_search_corpora` first. Operators can add new corpora via the `/search` route.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Domain primer baked into the assistant's system prompt so it understands
|
// Domain primer baked into the assistant's system prompt so it understands
|
||||||
// what arcadia-app is, what platform admins do, and how the data model fits
|
// what arcadia-core is, what platform admins do, and how the data model fits
|
||||||
// together. Keep this tight — it costs context tokens on every turn.
|
// together. Keep this tight — it costs context tokens on every turn.
|
||||||
|
|
||||||
export const ARCADIA_KNOWLEDGE = `Arcadia (the backend you administer):
|
export const ARCADIA_KNOWLEDGE = `Arcadia (the backend you administer):
|
||||||
@@ -31,7 +31,7 @@ Things to keep in mind when assisting:
|
|||||||
- Writes are auditable. Suggest the user double-check tenant slug and impact before suspend/deactivate. Deactivate is harsher than suspend — only use when clearly intended.
|
- Writes are auditable. Suggest the user double-check tenant slug and impact before suspend/deactivate. Deactivate is harsher than suspend — only use when clearly intended.
|
||||||
- The operator can impersonate tenant users for debugging (POST /api/v1/admin/impersonate/:user_id) — surface this when they ask "why can't user X log in".
|
- The operator can impersonate tenant users for debugging (POST /api/v1/admin/impersonate/:user_id) — surface this when they ask "why can't user X log in".
|
||||||
- Quotas / rate cards / billing config errors usually surface as 402/403 from /api/v1 endpoints — diagnose by checking the tenant's billing-config and api-metering quotas.
|
- Quotas / rate cards / billing config errors usually surface as 402/403 from /api/v1 endpoints — diagnose by checking the tenant's billing-config and api-metering quotas.
|
||||||
- The reference Phoenix app lives at \`reference/arcadia-app/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-client/scripts/sync-spec.mjs\`).
|
- The reference Phoenix app lives at \`reference/arcadia-core/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-core-client/scripts/sync-spec.mjs\`).
|
||||||
- Search admin (arcadia-search) is a separate service. Manage tenants/corpora at \`/search\`. Use \`list_search_corpora\` if you don't know what's indexed; \`rebuild_search_corpus\` after uploads or when results look stale; \`search_kb\` / \`read_chunk\` to query.
|
- Search admin (arcadia-search) is a separate service. Manage tenants/corpora at \`/search\`. Use \`list_search_corpora\` if you don't know what's indexed; \`rebuild_search_corpus\` after uploads or when results look stale; \`search_kb\` / \`read_chunk\` to query.
|
||||||
|
|
||||||
When the user asks something that maps to a tool, call it. When they ask about a concept, explain it from this primer in plain language. Write tools (suspend_tenant, activate_tenant) prompt the operator with an inline confirm card before they actually run — you do not need to ask in prose first; just call the tool and the user will see the confirmation UI. If the user denies a write, do not retry it; ask what they'd like to do differently.
|
When the user asks something that maps to a tool, call it. When they ask about a concept, explain it from this primer in plain language. Write tools (suspend_tenant, activate_tenant) prompt the operator with an inline confirm card before they actually run — you do not need to ask in prose first; just call the tool and the user will see the confirmation UI. If the user denies a write, do not retry it; ask what they'd like to do differently.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Platform announcements helpers.
|
// Platform announcements helpers.
|
||||||
// Backend: /api/v1/admin/announcements (admin CRUD).
|
// Backend: /api/v1/admin/announcements (admin CRUD).
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type AnnouncementType =
|
export type AnnouncementType =
|
||||||
| "info"
|
| "info"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// once — list/show endpoints only return the prefix. Callers must surface
|
// once — list/show endpoints only return the prefix. Callers must surface
|
||||||
// the value to the user immediately on create.
|
// the value to the user immediately on create.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface ApiKey {
|
export interface ApiKey {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Audit log + observability helpers.
|
// Audit log + observability helpers.
|
||||||
// All endpoints are read-only; the backend writes audit events itself.
|
// All endpoints are read-only; the backend writes audit events itself.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type AuditSeverity = "info" | "warning" | "error" | "critical" | string
|
export type AuditSeverity = "info" | "warning" | "error" | "critical" | string
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Backend: /api/v1/platform/buckets/*. All operations require a
|
// Backend: /api/v1/platform/buckets/*. All operations require a
|
||||||
// storage_config_id pointing at a credential row in /api/v1/storage_configs.
|
// storage_config_id pointing at a credential row in /api/v1/storage_configs.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface Bucket {
|
export interface Bucket {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// used by the avatar uploader. The full digital-objects API is much
|
// used by the avatar uploader. The full digital-objects API is much
|
||||||
// larger; add endpoints here as we wire more features.
|
// larger; add endpoints here as we wire more features.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface DigitalObject {
|
export interface DigitalObject {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
//
|
//
|
||||||
// Backed by /api/v1/health* (public — no auth). Each subsystem is probed
|
// Backed by /api/v1/health* (public — no auth). Each subsystem is probed
|
||||||
// independently; the overall endpoint aggregates and returns 503 if any
|
// independently; the overall endpoint aggregates and returns 503 if any
|
||||||
// subsystem is not "ok". See arcadia-app commit f427892.
|
// subsystem is not "ok". See arcadia-core commit f427892.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type HealthSubsystem = "api" | "db" | "workers" | "storage"
|
export type HealthSubsystem = "api" | "db" | "workers" | "storage"
|
||||||
|
|
||||||
|
|||||||
40
app/lib/arcadia/integrations.ts
Normal file
40
app/lib/arcadia/integrations.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Integration-registry client (operator surface) — thin shim over the shared
|
||||||
|
// `@crema/integration-registry-client` lib, bound to `operator` mode. The lib
|
||||||
|
// owns the types, the HTTP contract, and the display helpers (shared with
|
||||||
|
// arcadia-console's tenant surface); this file just exposes operator-idiomatic
|
||||||
|
// names so the page reads naturally.
|
||||||
|
|
||||||
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
import {
|
||||||
|
createIntegrationsApi,
|
||||||
|
type CredentialInput,
|
||||||
|
type IntegrationInput,
|
||||||
|
type ScopeFilter,
|
||||||
|
} from "@crema/integration-registry-client"
|
||||||
|
|
||||||
|
// Re-export the shared types + helpers so callers import from one place.
|
||||||
|
export * from "@crema/integration-registry-client"
|
||||||
|
|
||||||
|
const op = (c: ArcadiaClient) => createIntegrationsApi(c, "operator")
|
||||||
|
|
||||||
|
export const listIntegrations = (c: ArcadiaClient, filter: ScopeFilter = {}) =>
|
||||||
|
op(c).list(filter)
|
||||||
|
export const createIntegration = (c: ArcadiaClient, input: IntegrationInput) =>
|
||||||
|
op(c).create(input)
|
||||||
|
export const updateIntegration = (
|
||||||
|
c: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<IntegrationInput>,
|
||||||
|
) => op(c).update(id, input)
|
||||||
|
export const deleteIntegration = (c: ArcadiaClient, id: string) => op(c).remove(id)
|
||||||
|
export const addCredential = (c: ArcadiaClient, integrationId: string, input: CredentialInput) =>
|
||||||
|
op(c).addCredential(integrationId, input)
|
||||||
|
export const updateCredential = (
|
||||||
|
c: ArcadiaClient,
|
||||||
|
credentialId: string,
|
||||||
|
input: Partial<CredentialInput>,
|
||||||
|
) => op(c).updateCredential(credentialId, input)
|
||||||
|
export const deleteCredential = (c: ArcadiaClient, credentialId: string) =>
|
||||||
|
op(c).deleteCredential(credentialId)
|
||||||
|
export const testIntegration = (c: ArcadiaClient, id: string) => op(c).test(id)
|
||||||
|
export const usageSummary = (c: ArcadiaClient, filter: ScopeFilter = {}) => op(c).usage(filter)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Arcadia invitations API helpers.
|
// Arcadia invitations API helpers.
|
||||||
// Backed by /api/v1/invitations.
|
// Backed by /api/v1/invitations.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface InvitationRole {
|
export interface InvitationRole {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// `tenant_id: null` configurations are platform-defaults visible to
|
// `tenant_id: null` configurations are platform-defaults visible to
|
||||||
// every tenant. Names are unique within (tenant, name).
|
// every tenant. Names are unique within (tenant, name).
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type LlmProvider = "openai" | "anthropic" | "deepseek" | "qwen" | "lmstudio"
|
export type LlmProvider = "openai" | "anthropic" | "deepseek" | "qwen" | "lmstudio"
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export async function getCatalog(arcadia: ArcadiaClient): Promise<CatalogEntry[]
|
|||||||
/**
|
/**
|
||||||
* Compute cost in cents for a given input/output token count using a
|
* Compute cost in cents for a given input/output token count using a
|
||||||
* configuration's published rates. Mirrors `LlmConfiguration.compute_cost_cents/3`
|
* configuration's published rates. Mirrors `LlmConfiguration.compute_cost_cents/3`
|
||||||
* in arcadia-app — keep in sync.
|
* in arcadia-core — keep in sync.
|
||||||
*/
|
*/
|
||||||
export function computeCostCents(
|
export function computeCostCents(
|
||||||
config: Pick<LlmConfiguration, "input_cost_per_million" | "output_cost_per_million">,
|
config: Pick<LlmConfiguration, "input_cost_per_million" | "output_cost_per_million">,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// Arcadia LLM proxy client.
|
// Arcadia LLM proxy client.
|
||||||
//
|
//
|
||||||
// Implements the spec in docs/LLM_PROXY_CONTRACT.md against arcadia-app's
|
// Implements the spec in docs/LLM_PROXY_CONTRACT.md against arcadia-core's
|
||||||
// POST /api/v1/ai/llm/chat. The lib (@crema/llm-providers-ui buildAdapter)
|
// POST /api/v1/ai/llm/chat. The lib (@crema/llm-providers-ui buildAdapter)
|
||||||
// owns the streaming chat path itself; this module exposes a lightweight
|
// owns the streaming chat path itself; this module exposes a lightweight
|
||||||
// non-streaming probe so the Settings "Test connection" button can verify
|
// non-streaming probe so the Settings "Test connection" button can verify
|
||||||
// the proxy round-trips end-to-end (auth → secret resolution → upstream
|
// the proxy round-trips end-to-end (auth → secret resolution → upstream
|
||||||
// dispatch → response shape).
|
// dispatch → response shape).
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type LLMProxyProvider =
|
export type LLMProxyProvider =
|
||||||
| "openai"
|
| "openai"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Tenant memberships — the M:N glue between users and tenants.
|
// Tenant memberships — the M:N glue between users and tenants.
|
||||||
// Backend: /api/v1/admin/memberships (admin) + /api/v1/me/tenants (self).
|
// Backend: /api/v1/admin/memberships (admin) + /api/v1/me/tenants (self).
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type MembershipStatus = "active" | "suspended" | "deactivated" | string
|
export type MembershipStatus = "active" | "suspended" | "deactivated" | string
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Wraps /api/v1/admin/monitoring/* + /api/v1/platform/* + a few observability
|
// Wraps /api/v1/admin/monitoring/* + /api/v1/platform/* + a few observability
|
||||||
// endpoints used by the monitoring dashboard.
|
// endpoints used by the monitoring dashboard.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
// --- Rate limits ---------------------------------------------------------
|
// --- Rate limits ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Networking helpers: firewalls, VPCs, domains + DNS records, floating IPs.
|
// Networking helpers: firewalls, VPCs, domains + DNS records, floating IPs.
|
||||||
// Backend: /api/v1/platform/{firewalls,vpcs,domains,floating_ips,...}.
|
// Backend: /api/v1/platform/{firewalls,vpcs,domains,floating_ips,...}.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
const BASE = "/api/v1/platform"
|
const BASE = "/api/v1/platform"
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// `OrganizationContext` plug, so the same per-org routes used by end-users
|
// `OrganizationContext` plug, so the same per-org routes used by end-users
|
||||||
// are used here to mutate any org in the tenant.
|
// are used here to mutate any org in the tenant.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type OrgStatus = "active" | "frozen" | "pending_deletion" | string
|
export type OrgStatus = "active" | "frozen" | "pending_deletion" | string
|
||||||
export type OnOwnerRemoval =
|
export type OnOwnerRemoval =
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// profile fields. The "profile" here is the per-tenant profile row, not
|
// profile fields. The "profile" here is the per-tenant profile row, not
|
||||||
// the auth account.
|
// the auth account.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Arcadia roles API helpers.
|
// Arcadia roles API helpers.
|
||||||
// Backed by /api/v1/roles (resources route, except :new and :edit).
|
// Backed by /api/v1/roles (resources route, except :new and :edit).
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface Role {
|
export interface Role {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Scheduled tasks (cron) helpers.
|
// Scheduled tasks (cron) helpers.
|
||||||
// Backend: /api/v1/admin/scheduled-tasks (CRUD + runs/enable/disable/trigger).
|
// Backend: /api/v1/admin/scheduled-tasks (CRUD + runs/enable/disable/trigger).
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type ScheduledTaskAction = "webhook" | "event"
|
export type ScheduledTaskAction = "webhook" | "event"
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// exposed by these endpoints. Tenant-side resolution (returning the value)
|
// exposed by these endpoints. Tenant-side resolution (returning the value)
|
||||||
// goes through a separate runtime endpoint that's not used by the admin UI.
|
// goes through a separate runtime endpoint that's not used by the admin UI.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type SecretCategory =
|
export type SecretCategory =
|
||||||
| "api_key"
|
| "api_key"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Backend: /api/v1/sso/identity-providers (tenant CRUD) + /sessions.
|
// Backend: /api/v1/sso/identity-providers (tenant CRUD) + /sessions.
|
||||||
// Note: certificates are large and write-only.
|
// Note: certificates are large and write-only.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface IdentityProvider {
|
export interface IdentityProvider {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Status page helpers — components, incidents, subscribers.
|
// Status page helpers — components, incidents, subscribers.
|
||||||
// Backend: /api/v1/admin/status-page/* (admin CRUD).
|
// Backend: /api/v1/admin/status-page/* (admin CRUD).
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type ComponentStatus =
|
export type ComponentStatus =
|
||||||
| "operational"
|
| "operational"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// generic `arcadia.GET<T>` / `arcadia.POST<T>` / etc. — same pattern as
|
// generic `arcadia.GET<T>` / `arcadia.POST<T>` / etc. — same pattern as
|
||||||
// `tenants.ts`. Switch to `arcadia.typed.*` when the spec gains coverage.
|
// `tenants.ts`. Switch to `arcadia.typed.*` when the spec gains coverage.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type StorageBackend = "s3" | "local" | "gcs"
|
export type StorageBackend = "s3" | "local" | "gcs"
|
||||||
export type StorageStatus = "active" | "inactive" | "degraded" | "maintenance"
|
export type StorageStatus = "active" | "inactive" | "degraded" | "maintenance"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
|
// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)`
|
||||||
// and drop these manual types.
|
// and drop these manual types.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type TenantStatus = "active" | "suspended" | "deactivated" | string
|
export type TenantStatus = "active" | "suspended" | "deactivated" | 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`)
|
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProvisionTenantInput {
|
||||||
|
tenant: { name: string; slug: string }
|
||||||
|
admin_user: {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provisionTenant(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: ProvisionTenantInput,
|
||||||
|
): Promise<Tenant> {
|
||||||
|
const res = await arcadia.POST<{ data: Tenant }>("/api/v1/admin/tenants/provision", {
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Per-user usage + quota helpers.
|
// Per-user usage + quota helpers.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export interface UserUsage {
|
export interface UserUsage {
|
||||||
storage_used_bytes: number
|
storage_used_bytes: number
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// describe these operations as typed paths, so we hand-roll types and use
|
// describe these operations as typed paths, so we hand-roll types and use
|
||||||
// the generic verb methods on the client. Same pattern as tenants.ts.
|
// the generic verb methods on the client. Same pattern as tenants.ts.
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type UserStatus = "active" | "inactive" | "suspended"
|
export type UserStatus = "active" | "inactive" | "suspended"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Outbound webhook helpers.
|
// Outbound webhook helpers.
|
||||||
// Backend: /api/v1/webhooks (CRUD + pause/resume/regenerate-secret/deliveries/stats/test).
|
// Backend: /api/v1/webhooks (CRUD + pause/resume/regenerate-secret/deliveries/stats/test).
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
import type { ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
export type WebhookStatus = "active" | "paused" | "disabled"
|
export type WebhookStatus = "active" | "paused" | "disabled"
|
||||||
export type WebhookRetryStrategy = "linear" | "exponential"
|
export type WebhookRetryStrategy = "linear" | "exponential"
|
||||||
|
|||||||
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-core, so
|
||||||
|
// it needs its own ArcadiaClient pointed at a different base URL. Everything
|
||||||
|
// else is identical to the arcadia-core client: the same access token (the
|
||||||
|
// gateway validates arcadia-core JWTs via the shared Guardian secret) and the
|
||||||
|
// same 401 cleanup. The gateway's CORS already allows localhost + any
|
||||||
|
// *.sky-ai.com origin, so the browser calls it directly.
|
||||||
|
|
||||||
|
import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
|
const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015"
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = "arcadia_access_token"
|
||||||
|
const REFRESH_TOKEN_KEY = "arcadia_refresh_token"
|
||||||
|
|
||||||
|
let client: ArcadiaClient | null = null
|
||||||
|
|
||||||
|
export function gatewayClient(): ArcadiaClient {
|
||||||
|
if (!client) {
|
||||||
|
client = createArcadiaClient({
|
||||||
|
baseUrl: GATEWAY_URL,
|
||||||
|
getToken: () =>
|
||||||
|
typeof window === "undefined" ? null : sessionStorage.getItem(ACCESS_TOKEN_KEY),
|
||||||
|
onUnauthorized: () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGatewayClient(): ArcadiaClient {
|
||||||
|
return gatewayClient()
|
||||||
|
}
|
||||||
49
app/lib/jwt.ts
Normal file
49
app/lib/jwt.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Tiny JWT helpers — we never *verify* tokens client-side (the server
|
||||||
|
// is the only authority), we just decode the payload to read claims
|
||||||
|
// the UI uses for nav gating + tenant context.
|
||||||
|
|
||||||
|
export type ArcadiaClaims = {
|
||||||
|
sub?: string
|
||||||
|
email?: string
|
||||||
|
tenant_id?: string
|
||||||
|
tenant_slug?: string
|
||||||
|
roles?: string[]
|
||||||
|
available_tenants?: AvailableTenantClaim[]
|
||||||
|
exp?: number
|
||||||
|
iat?: number
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvailableTenantClaim = {
|
||||||
|
id?: string
|
||||||
|
slug?: string
|
||||||
|
name?: string
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlDecode(s: string): string {
|
||||||
|
const pad = "=".repeat((4 - (s.length % 4)) % 4)
|
||||||
|
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/")
|
||||||
|
if (typeof atob === "function") return atob(b64)
|
||||||
|
// Node fallback (SSR / tests)
|
||||||
|
return Buffer.from(b64, "base64").toString("binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJwt(token: string): ArcadiaClaims | null {
|
||||||
|
if (!token) return null
|
||||||
|
const parts = token.split(".")
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
try {
|
||||||
|
const raw = b64urlDecode(parts[1])
|
||||||
|
// Handle UTF-8: atob returns binary string; reconstruct UTF-8.
|
||||||
|
const utf8 =
|
||||||
|
typeof TextDecoder !== "undefined"
|
||||||
|
? new TextDecoder().decode(
|
||||||
|
Uint8Array.from(raw, (c) => c.charCodeAt(0)),
|
||||||
|
)
|
||||||
|
: raw
|
||||||
|
return JSON.parse(utf8) as ArcadiaClaims
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// panel remains the place to switch between configs.
|
// panel remains the place to switch between configs.
|
||||||
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// immediately, without waiting for the user to navigate to /profile.
|
// immediately, without waiting for the user to navigate to /profile.
|
||||||
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
import { fetchDigitalObjectAsBlobUrl } from "~/lib/arcadia/digital-objects"
|
import { fetchDigitalObjectAsBlobUrl } from "~/lib/arcadia/digital-objects"
|
||||||
import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles"
|
import { getProfile, pickAvatarUrl } from "~/lib/arcadia/profiles"
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
import { useEffect, useSyncExternalStore } from "react"
|
import { useEffect, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
import { profileInitials } from "~/lib/profile"
|
import { profileInitials } from "~/lib/profile"
|
||||||
|
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
|
||||||
|
|
||||||
|
export type AvailableTenant = {
|
||||||
|
id: string
|
||||||
|
slug?: string
|
||||||
|
name?: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type Session = {
|
export type Session = {
|
||||||
userId: string
|
userId: string
|
||||||
@@ -14,6 +22,11 @@ export type Session = {
|
|||||||
token: string
|
token: string
|
||||||
// Issued at, ms since epoch.
|
// Issued at, ms since epoch.
|
||||||
issuedAt: number
|
issuedAt: number
|
||||||
|
// Active membership context — derived from the JWT.
|
||||||
|
tenantId?: string
|
||||||
|
tenantSlug?: string
|
||||||
|
roles: string[]
|
||||||
|
availableTenants: AvailableTenant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "crema.session"
|
const STORAGE_KEY = "crema.session"
|
||||||
@@ -41,6 +54,18 @@ function readFromStorage(): Session | null {
|
|||||||
token: parsed.token,
|
token: parsed.token,
|
||||||
issuedAt:
|
issuedAt:
|
||||||
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
||||||
|
tenantId: typeof parsed.tenantId === "string" ? parsed.tenantId : undefined,
|
||||||
|
tenantSlug:
|
||||||
|
typeof parsed.tenantSlug === "string" ? parsed.tenantSlug : undefined,
|
||||||
|
roles: Array.isArray(parsed.roles)
|
||||||
|
? parsed.roles.filter((r): r is string => typeof r === "string")
|
||||||
|
: [],
|
||||||
|
availableTenants: Array.isArray(parsed.availableTenants)
|
||||||
|
? (parsed.availableTenants.filter(
|
||||||
|
(t): t is AvailableTenant =>
|
||||||
|
!!t && typeof (t as AvailableTenant).id === "string",
|
||||||
|
) as AvailableTenant[])
|
||||||
|
: [],
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -72,12 +97,31 @@ export function persistFromArcadiaLogin(
|
|||||||
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
|
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
|
||||||
user?.email ||
|
user?.email ||
|
||||||
"Signed-in user"
|
"Signed-in user"
|
||||||
|
const claims = decodeJwt(tokens.access_token) ?? {}
|
||||||
|
const availableTenants: AvailableTenant[] = Array.isArray(
|
||||||
|
claims.available_tenants,
|
||||||
|
)
|
||||||
|
? (claims.available_tenants as AvailableTenantClaim[])
|
||||||
|
.filter((t) => t && typeof t.id === "string")
|
||||||
|
.map((t) => ({
|
||||||
|
id: t.id as string,
|
||||||
|
slug: t.slug,
|
||||||
|
name: t.name,
|
||||||
|
roles: Array.isArray(t.roles) ? t.roles : [],
|
||||||
|
}))
|
||||||
|
: []
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
|
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
|
||||||
name,
|
name,
|
||||||
email: user?.email ?? "",
|
email: user?.email ?? "",
|
||||||
token: tokens.access_token,
|
token: tokens.access_token,
|
||||||
issuedAt: Date.now(),
|
issuedAt: Date.now(),
|
||||||
|
tenantId:
|
||||||
|
typeof claims.tenant_id === "string" ? claims.tenant_id : undefined,
|
||||||
|
tenantSlug:
|
||||||
|
typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined,
|
||||||
|
roles: Array.isArray(claims.roles) ? claims.roles : [],
|
||||||
|
availableTenants,
|
||||||
}
|
}
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
|
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import "./app.css"
|
|||||||
|
|
||||||
import { ToastProvider, Toaster } from "@crema/notification-ui"
|
import { ToastProvider, Toaster } from "@crema/notification-ui"
|
||||||
import { CommandBusProvider } from "@crema/action-bus"
|
import { CommandBusProvider } from "@crema/action-bus"
|
||||||
import { ArcadiaProvider } from "@crema/arcadia-client"
|
import { ArcadiaProvider } from "@crema/arcadia-core-client"
|
||||||
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
|
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
|
||||||
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
|
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
|
||||||
// CREMA:PROVIDERS-IMPORTS
|
// CREMA:PROVIDERS-IMPORTS
|
||||||
|
|||||||
@@ -28,5 +28,9 @@ export default [
|
|||||||
route("announcements", "routes/announcements.tsx"),
|
route("announcements", "routes/announcements.tsx"),
|
||||||
route("status-page", "routes/status-page.tsx"),
|
route("status-page", "routes/status-page.tsx"),
|
||||||
route("search", "routes/search.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
|
// CREMA:ROUTES
|
||||||
] satisfies RouteConfig
|
] satisfies RouteConfig
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Activity, Eye, RefreshCw } from "lucide-react"
|
import { Activity, Eye, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
import { addLibraryItem } from "~/lib/library"
|
import { addLibraryItem } from "~/lib/library"
|
||||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
|
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
|
||||||
// Shape of a single hit returned by the `search_docs` tool. Defined here
|
// Shape of a single hit returned by the `search_docs` tool. Defined here
|
||||||
// rather than imported from the lib because the tool wrapper in
|
// rather than imported from the lib because the tool wrapper in
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
@@ -69,6 +69,39 @@ export const meta = () => pageTitle("Announcements")
|
|||||||
|
|
||||||
const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"]
|
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 =
|
type Editor =
|
||||||
| { kind: "create" }
|
| { kind: "create" }
|
||||||
| { kind: "edit"; announcement: Announcement }
|
| { kind: "edit"; announcement: Announcement }
|
||||||
@@ -86,6 +119,8 @@ export default function AnnouncementsRoute() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [editor, setEditor] = useState<Editor>(null)
|
const [editor, setEditor] = useState<Editor>(null)
|
||||||
const [pendingDelete, setPendingDelete] = useState<Announcement | null>(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 () => {
|
const refresh = useCallback(async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -97,6 +132,7 @@ export default function AnnouncementsRoute() {
|
|||||||
])
|
])
|
||||||
setItems(a)
|
setItems(a)
|
||||||
setTenants(t)
|
setTenants(t)
|
||||||
|
setRefreshedAt(Date.now())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
|
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,6 +140,21 @@ export default function AnnouncementsRoute() {
|
|||||||
}
|
}
|
||||||
}, [arcadia])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (session) refresh()
|
if (session) refresh()
|
||||||
}, [session, refresh])
|
}, [session, refresh])
|
||||||
@@ -133,13 +184,12 @@ export default function AnnouncementsRoute() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "scope",
|
id: "scope",
|
||||||
header: "Scope",
|
header: "Audience",
|
||||||
cell: (a) =>
|
cell: (a) => {
|
||||||
a.tenant_id ? (
|
if (!a.tenant_id) return <Badge>All apps</Badge>
|
||||||
<Badge variant="secondary">tenant</Badge>
|
const t = tenants.find((x) => x.id === a.tenant_id)
|
||||||
) : (
|
return <Badge variant="secondary">{t?.slug ?? "Single tenant"}</Badge>
|
||||||
<Badge>platform</Badge>
|
},
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "active",
|
id: "active",
|
||||||
@@ -207,7 +257,7 @@ export default function AnnouncementsRoute() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[arcadia, refresh],
|
[arcadia, refresh, tenants],
|
||||||
)
|
)
|
||||||
|
|
||||||
const summary = useMemo(
|
const summary = useMemo(
|
||||||
@@ -234,32 +284,48 @@ export default function AnnouncementsRoute() {
|
|||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Announcements</h1>
|
<h1 className="text-[26px] font-[620] leading-[1.1] tracking-[-0.02em]">
|
||||||
<p className="text-sm text-muted-foreground">
|
Announcements
|
||||||
Platform-wide and per-tenant banners. Apps consuming arcadia surface these to users.
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
aria-label="Refresh announcements"
|
||||||
data-action="announcements-refresh"
|
data-action="announcements-refresh"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditor({ kind: "create" })}
|
|
||||||
data-action="announcements-create"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
New announcement
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditor({ kind: "create" })}
|
||||||
|
data-action="announcements-create"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New announcement
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -283,9 +349,13 @@ export default function AnnouncementsRoute() {
|
|||||||
data-action="announcements-search"
|
data-action="announcements-search"
|
||||||
className="max-w-sm flex-1"
|
className="max-w-sm flex-1"
|
||||||
/>
|
/>
|
||||||
<div className="ml-auto text-xs text-muted-foreground">
|
{items.length > 0 ? (
|
||||||
{table.total} of {items.length}
|
<div className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||||
</div>
|
{search && table.total !== items.length
|
||||||
|
? `${table.total} of ${items.length}`
|
||||||
|
: `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="relative p-0">
|
<CardContent className="relative p-0">
|
||||||
@@ -295,12 +365,47 @@ export default function AnnouncementsRoute() {
|
|||||||
/>
|
/>
|
||||||
{table.total === 0 && !loading ? (
|
{table.total === 0 && !loading ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Megaphone className="size-6" />}
|
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."}
|
title={search ? "No announcements match." : "No announcements yet."}
|
||||||
description={
|
description={
|
||||||
search ? "Try a different search." : "Post the first one — platform-wide or scoped to a tenant."
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="py-12"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -407,6 +512,11 @@ function AnnouncementEditorDialog({
|
|||||||
const [dismissible, setDismissible] = useState(true)
|
const [dismissible, setDismissible] = useState(true)
|
||||||
const [active, setActive] = useState(true)
|
const [active, setActive] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setLocalError(null)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -439,6 +549,7 @@ function AnnouncementEditorDialog({
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
onError(null)
|
onError(null)
|
||||||
|
setLocalError(null)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const input: AnnouncementInput = {
|
const input: AnnouncementInput = {
|
||||||
@@ -462,13 +573,13 @@ function AnnouncementEditorDialog({
|
|||||||
await onSaved("Announcement posted.")
|
await onSaved("Announcement posted.")
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(
|
const msg =
|
||||||
err instanceof ArcadiaError
|
err instanceof ArcadiaError
|
||||||
? err.message
|
? err.message
|
||||||
: err instanceof Error
|
: err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "Save failed.",
|
: "Save failed."
|
||||||
)
|
setLocalError(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -476,15 +587,59 @@ function AnnouncementEditorDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEdit ? "Edit announcement" : "New announcement"}</DialogTitle>
|
<DialogTitle>{isEdit ? "Edit announcement" : "New announcement"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Banners surface in apps that consume arcadia. Active + currently within the start/end
|
A banner shows at the top of every Sky AI app. It's visible when it's switched on
|
||||||
window = visible.
|
and today falls inside its date range.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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="grid grid-cols-2 gap-3">
|
||||||
<div className="col-span-2 flex flex-col gap-1.5">
|
<div className="col-span-2 flex flex-col gap-1.5">
|
||||||
<Label htmlFor="ann-title">Title</Label>
|
<Label htmlFor="ann-title">Title</Label>
|
||||||
@@ -493,6 +648,7 @@ function AnnouncementEditorDialog({
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
data-action="announcement-form-title"
|
data-action="announcement-form-title"
|
||||||
|
placeholder="Scheduled maintenance Sunday 2am AEST"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 flex flex-col gap-1.5">
|
<div className="col-span-2 flex flex-col gap-1.5">
|
||||||
@@ -501,21 +657,25 @@ function AnnouncementEditorDialog({
|
|||||||
id="ann-body"
|
id="ann-body"
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
rows={4}
|
rows={3}
|
||||||
data-action="announcement-form-body"
|
data-action="announcement-form-body"
|
||||||
|
placeholder="Expect ~10 minutes of downtime while we ship the new tenant switcher."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Type</Label>
|
<Label>Kind</Label>
|
||||||
<Select value={type} onValueChange={setType}>
|
<Select value={type} onValueChange={setType}>
|
||||||
<SelectTrigger data-action="announcement-form-type">
|
<SelectTrigger data-action="announcement-form-type">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TYPES.map((t) => (
|
{KIND_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={t} value={t}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{t}
|
<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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -523,21 +683,21 @@ function AnnouncementEditorDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Audience</Label>
|
<Label>Who sees this</Label>
|
||||||
<Select value={audience} onValueChange={(v) => setAudience(v as "platform" | "tenant")}>
|
<Select value={audience} onValueChange={(v) => setAudience(v as "platform" | "tenant")}>
|
||||||
<SelectTrigger data-action="announcement-form-audience">
|
<SelectTrigger data-action="announcement-form-audience">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="platform">Platform-wide</SelectItem>
|
<SelectItem value="platform">Everyone</SelectItem>
|
||||||
<SelectItem value="tenant">Single tenant</SelectItem>
|
<SelectItem value="tenant">Just one tenant</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{audience === "tenant" ? (
|
{audience === "tenant" ? (
|
||||||
<div className="col-span-2 flex flex-col gap-1.5">
|
<div className="col-span-2 flex flex-col gap-1.5">
|
||||||
<Label>Tenant</Label>
|
<Label>Which tenant</Label>
|
||||||
<Select value={tenantId} onValueChange={setTenantId}>
|
<Select value={tenantId} onValueChange={setTenantId}>
|
||||||
<SelectTrigger data-action="announcement-form-tenant">
|
<SelectTrigger data-action="announcement-form-tenant">
|
||||||
<SelectValue placeholder="Pick a tenant" />
|
<SelectValue placeholder="Pick a tenant" />
|
||||||
@@ -554,7 +714,7 @@ function AnnouncementEditorDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="ann-starts">Starts at</Label>
|
<Label htmlFor="ann-starts">Starts</Label>
|
||||||
<Input
|
<Input
|
||||||
id="ann-starts"
|
id="ann-starts"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -564,7 +724,7 @@ function AnnouncementEditorDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="ann-ends">Ends at</Label>
|
<Label htmlFor="ann-ends">Ends</Label>
|
||||||
<Input
|
<Input
|
||||||
id="ann-ends"
|
id="ann-ends"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -574,57 +734,88 @@ function AnnouncementEditorDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
{/* Optional link group — heading clarifies these two are paired. */}
|
||||||
<Label htmlFor="ann-action-label">Action label (optional)</Label>
|
<div className="col-span-2 flex flex-col gap-2 rounded-md border border-dashed p-3">
|
||||||
<Input
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
id="ann-action-label"
|
<Label className="text-sm">Add a link</Label>
|
||||||
value={actionLabel}
|
<span className="text-xs text-muted-foreground">Optional</span>
|
||||||
onChange={(e) => setActionLabel(e.target.value)}
|
</div>
|
||||||
placeholder="Read more"
|
<div className="grid grid-cols-2 gap-3">
|
||||||
data-action="announcement-form-action-label"
|
<div className="flex flex-col gap-1.5">
|
||||||
/>
|
<Label htmlFor="ann-action-label" className="text-xs text-muted-foreground">
|
||||||
</div>
|
Button text
|
||||||
<div className="flex flex-col gap-1.5">
|
</Label>
|
||||||
<Label htmlFor="ann-action-url">Action URL (optional)</Label>
|
<Input
|
||||||
<Input
|
id="ann-action-label"
|
||||||
id="ann-action-url"
|
value={actionLabel}
|
||||||
value={actionUrl}
|
onChange={(e) => setActionLabel(e.target.value)}
|
||||||
onChange={(e) => setActionUrl(e.target.value)}
|
placeholder="Read more"
|
||||||
placeholder="/changelog/v2"
|
data-action="announcement-form-action-label"
|
||||||
data-action="announcement-form-action-url"
|
/>
|
||||||
/>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
{/* End-user behavior toggle, not publish state — kept with content fields. */}
|
||||||
<Label className="text-sm">Dismissible</Label>
|
<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
|
<Switch
|
||||||
checked={dismissible}
|
checked={dismissible}
|
||||||
onCheckedChange={setDismissible}
|
onCheckedChange={setDismissible}
|
||||||
data-action="announcement-form-dismissible"
|
data-action="announcement-form-dismissible"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
</div>
|
||||||
<Label className="text-sm">Active</Label>
|
|
||||||
|
<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
|
<Switch
|
||||||
|
id="ann-active"
|
||||||
checked={active}
|
checked={active}
|
||||||
onCheckedChange={setActive}
|
onCheckedChange={setActive}
|
||||||
data-action="announcement-form-active"
|
data-action="announcement-form-active"
|
||||||
/>
|
/>
|
||||||
</div>
|
<span>{active ? "Switched on" : "Switched off (draft)"}</span>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<DialogFooter>
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={saving || !title.trim() || (audience === "tenant" && !tenantId)}
|
disabled={saving || !title.trim() || (audience === "tenant" && !tenantId)}
|
||||||
data-action="announcement-form-save"
|
data-action="announcement-form-save"
|
||||||
>
|
>
|
||||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
{saving ? (
|
||||||
{isEdit ? "Save" : "Post"}
|
<RefreshCw className="size-4 animate-spin" />
|
||||||
</Button>
|
) : (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
)}
|
||||||
|
{publishButtonLabel({ isEdit, active, audience, tenantId, tenants })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@ import {
|
|||||||
runLLMToolCalls,
|
runLLMToolCalls,
|
||||||
} from "~/lib/admin-tools"
|
} from "~/lib/admin-tools"
|
||||||
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
|
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
|
||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import { ConfirmCard } from "~/components/assistant/confirm-card"
|
import { ConfirmCard } from "~/components/assistant/confirm-card"
|
||||||
import type { ToolCall } from "@crema/llm-ui"
|
import type { ToolCall } from "@crema/llm-ui"
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
DataTable,
|
DataTable,
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Users as UsersIcon,
|
Users as UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import { AlertBanner } from "@crema/feedback-ui"
|
import { AlertBanner } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
|||||||
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-core-client"
|
||||||
|
|
||||||
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
import { Badge } from "~/components/ui/badge"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { Label } from "~/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select"
|
||||||
|
import { Switch } from "~/components/ui/switch"
|
||||||
|
import { useGatewayClient } from "~/lib/gateway"
|
||||||
|
import {
|
||||||
|
addCredential,
|
||||||
|
createIntegration,
|
||||||
|
credentialHealth,
|
||||||
|
deleteIntegration,
|
||||||
|
formatUsd,
|
||||||
|
listIntegrations,
|
||||||
|
testIntegration,
|
||||||
|
updateIntegration,
|
||||||
|
usageSummary,
|
||||||
|
type AuthKind,
|
||||||
|
type Integration,
|
||||||
|
type Scope,
|
||||||
|
type UsageEntry,
|
||||||
|
} from "~/lib/arcadia/integrations"
|
||||||
|
|
||||||
|
const AUTH_KINDS: AuthKind[] = ["bearer_static", "api_key_header", "basic", "oauth2"]
|
||||||
|
const SCOPES: Scope[] = ["platform", "tenant", "app", "user", "agent"]
|
||||||
|
const SCOPE_FILTERS: Array<Scope | "all"> = ["all", ...SCOPES]
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
scope: Scope
|
||||||
|
scope_id: string
|
||||||
|
provider: string
|
||||||
|
capability: string
|
||||||
|
display_name: string
|
||||||
|
unit: string
|
||||||
|
price_usd: string
|
||||||
|
monthly_budget_usd: string
|
||||||
|
secret_name: string
|
||||||
|
auth_kind: AuthKind
|
||||||
|
secret: string
|
||||||
|
pooled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: Form = {
|
||||||
|
scope: "platform",
|
||||||
|
scope_id: "",
|
||||||
|
provider: "",
|
||||||
|
capability: "",
|
||||||
|
display_name: "",
|
||||||
|
unit: "call",
|
||||||
|
price_usd: "",
|
||||||
|
monthly_budget_usd: "",
|
||||||
|
secret_name: "",
|
||||||
|
auth_kind: "bearer_static",
|
||||||
|
secret: "",
|
||||||
|
pooled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IntegrationsRoute() {
|
||||||
|
const gw = useGatewayClient()
|
||||||
|
const [items, setItems] = useState<Integration[]>([])
|
||||||
|
const [usage, setUsage] = useState<UsageEntry[]>([])
|
||||||
|
const [scopeFilter, setScopeFilter] = useState<Scope | "all">("all")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState<Integration | "new" | null>(null)
|
||||||
|
const [tests, setTests] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
const filter = scopeFilter === "all" ? {} : { scope: scopeFilter }
|
||||||
|
try {
|
||||||
|
const [list, use] = await Promise.all([
|
||||||
|
listIntegrations(gw, filter),
|
||||||
|
usageSummary(gw, filter).catch(() => [] as UsageEntry[]),
|
||||||
|
])
|
||||||
|
setItems(list)
|
||||||
|
setUsage(use)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load integrations.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [gw, scopeFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const usageById = useMemo(
|
||||||
|
() => new Map(usage.map((u) => [u.integration_id, u] as const)),
|
||||||
|
[usage],
|
||||||
|
)
|
||||||
|
|
||||||
|
const runTest = useCallback(
|
||||||
|
async (it: Integration) => {
|
||||||
|
setTests((t) => ({ ...t, [it.id]: { ok: true, message: "Testing…" } }))
|
||||||
|
try {
|
||||||
|
const verdict = await testIntegration(gw, it.id)
|
||||||
|
const remaining = verdict.policy?.remaining_budget_usd
|
||||||
|
setTests((t) => ({
|
||||||
|
...t,
|
||||||
|
[it.id]: {
|
||||||
|
ok: true,
|
||||||
|
message:
|
||||||
|
verdict.status === "ok"
|
||||||
|
? `OK — within budget & rate${remaining ? ` (${formatUsd(remaining)} left)` : ""}`
|
||||||
|
: verdict.status,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof ArcadiaError
|
||||||
|
? e.status === 409
|
||||||
|
? "Credential expired — rotate it"
|
||||||
|
: e.status === 429
|
||||||
|
? "Over budget / rate limit"
|
||||||
|
: e.status === 404
|
||||||
|
? "No credential to test"
|
||||||
|
: e.message
|
||||||
|
: "Test failed"
|
||||||
|
setTests((t) => ({ ...t, [it.id]: { ok: false, message: msg } }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gw],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleEnabled = useCallback(
|
||||||
|
async (it: Integration, enabled: boolean) => {
|
||||||
|
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled } : x)))
|
||||||
|
try {
|
||||||
|
await updateIntegration(gw, it.id, { enabled })
|
||||||
|
} catch {
|
||||||
|
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled: !enabled } : x)))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gw],
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
async (it: Integration) => {
|
||||||
|
if (!window.confirm(`Delete ${it.display_name || it.provider} and its credentials?`)) return
|
||||||
|
await deleteIntegration(gw, it.id)
|
||||||
|
await refresh()
|
||||||
|
},
|
||||||
|
[gw, refresh],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Plug className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Integrations</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Platform & pooled external-API credentials across every scope.
|
||||||
|
Keys are stored encrypted and never shown; usage is metadata only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={scopeFilter} onValueChange={(v) => setScopeFilter((v as Scope | "all") ?? "all")}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCOPE_FILTERS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s === "all" ? "All scopes" : s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => setEditing("new")}>
|
||||||
|
<Plus className="size-4" /> Add integration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">Couldn’t load integrations</CardTitle>
|
||||||
|
<CardDescription>{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>No integrations in this scope</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Register a platform/pooled arrangement — a shared key the platform
|
||||||
|
meters and bills to tenants who opt in.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={() => setEditing("new")}>
|
||||||
|
<Plus className="size-4" /> Add integration
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{items.map((it) => {
|
||||||
|
const u = usageById.get(it.id)
|
||||||
|
const test = tests[it.id]
|
||||||
|
return (
|
||||||
|
<Card key={it.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{it.display_name || it.provider}
|
||||||
|
<Badge>{it.scope}</Badge>
|
||||||
|
{it.scope_id ? (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{it.scope_id}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{it.capability ? (
|
||||||
|
<Badge variant="secondary">{it.capability}</Badge>
|
||||||
|
) : null}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{it.provider}
|
||||||
|
{it.cost_model?.price_usd
|
||||||
|
? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}`
|
||||||
|
: ""}
|
||||||
|
{it.constraints?.monthly_budget_usd
|
||||||
|
? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo`
|
||||||
|
: ""}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`en-${it.id}`} className="text-xs text-muted-foreground">
|
||||||
|
{it.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id={`en-${it.id}`}
|
||||||
|
checked={it.enabled}
|
||||||
|
onCheckedChange={(v) => toggleEnabled(it, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{it.credentials.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No credential set.</p>
|
||||||
|
) : (
|
||||||
|
it.credentials.map((cred) => {
|
||||||
|
const health = credentialHealth(cred)
|
||||||
|
return (
|
||||||
|
<div key={cred.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<KeyRound className="size-4 text-muted-foreground" />
|
||||||
|
<span className="font-mono">{cred.secret_name}</span>
|
||||||
|
<Badge variant="outline">{cred.source}</Badge>
|
||||||
|
<HealthBadge health={health} />
|
||||||
|
{cred.expires_at ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
expires {new Date(cred.expires_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{test ? (
|
||||||
|
<p
|
||||||
|
className={`text-sm ${test.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}
|
||||||
|
>
|
||||||
|
{test.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => runTest(it)}>
|
||||||
|
<FlaskConical className="size-4" /> Test
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditing(it)}>
|
||||||
|
<Pencil className="size-4" /> Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => remove(it)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<IntegrationDialog
|
||||||
|
mode={editing === "new" ? "new" : "edit"}
|
||||||
|
initial={editing === "new" ? null : editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditing(null)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthBadge({ health }: { health: ReturnType<typeof credentialHealth> }) {
|
||||||
|
if (health === "ok")
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<CheckCircle2 className="size-3" /> healthy
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
const label = health === "missing" ? "no secret" : health
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertTriangle className="size-3" /> {label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationDialog({
|
||||||
|
mode,
|
||||||
|
initial,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
mode: "new" | "edit"
|
||||||
|
initial: Integration | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
const gw = useGatewayClient()
|
||||||
|
const [form, setForm] = useState<Form>(() =>
|
||||||
|
initial
|
||||||
|
? {
|
||||||
|
...emptyForm,
|
||||||
|
scope: initial.scope,
|
||||||
|
scope_id: initial.scope_id ?? "",
|
||||||
|
provider: initial.provider,
|
||||||
|
capability: initial.capability ?? "",
|
||||||
|
display_name: initial.display_name ?? "",
|
||||||
|
unit: initial.cost_model?.unit ?? "call",
|
||||||
|
price_usd: initial.cost_model?.price_usd?.toString() ?? "",
|
||||||
|
monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "",
|
||||||
|
}
|
||||||
|
: emptyForm,
|
||||||
|
)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
const set = (patch: Partial<Form>) => setForm((f) => ({ ...f, ...patch }))
|
||||||
|
|
||||||
|
const needsScopeId = form.scope !== "platform"
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
const cost_model = form.price_usd
|
||||||
|
? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" }
|
||||||
|
: undefined
|
||||||
|
const constraints = form.monthly_budget_usd
|
||||||
|
? { monthly_budget_usd: form.monthly_budget_usd }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (mode === "edit" && initial) {
|
||||||
|
await updateIntegration(gw, initial.id, {
|
||||||
|
provider: form.provider.trim(),
|
||||||
|
capability: form.capability.trim() || undefined,
|
||||||
|
display_name: form.display_name.trim() || undefined,
|
||||||
|
cost_model,
|
||||||
|
constraints,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const created = await createIntegration(gw, {
|
||||||
|
scope: form.scope,
|
||||||
|
scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined,
|
||||||
|
provider: form.provider.trim(),
|
||||||
|
capability: form.capability.trim() || undefined,
|
||||||
|
display_name: form.display_name.trim() || undefined,
|
||||||
|
cost_model,
|
||||||
|
constraints,
|
||||||
|
})
|
||||||
|
if (form.secret_name.trim() && form.secret.trim()) {
|
||||||
|
await addCredential(gw, created.id, {
|
||||||
|
secret_name: form.secret_name.trim(),
|
||||||
|
auth_kind: form.auth_kind,
|
||||||
|
secret: form.secret,
|
||||||
|
source: form.pooled ? "pooled" : "byo",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : "Save failed.")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => (!o ? onClose() : undefined)}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === "new" ? "Add integration" : "Edit integration"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Register an external-API arrangement. Platform scope = a pooled key
|
||||||
|
the platform meters and bills.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
{mode === "new" ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Scope">
|
||||||
|
<Select value={form.scope} onValueChange={(v) => set({ scope: (v as Scope) ?? "platform" })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCOPES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Scope ID" hint={needsScopeId ? "tenant/app/user/agent id" : "n/a for platform"}>
|
||||||
|
<Input
|
||||||
|
value={form.scope_id}
|
||||||
|
onChange={(e) => set({ scope_id: e.target.value })}
|
||||||
|
disabled={!needsScopeId}
|
||||||
|
placeholder={needsScopeId ? "acme" : "—"}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field label="Provider" hint="e.g. tavily, google_maps, duffel">
|
||||||
|
<Input
|
||||||
|
value={form.provider}
|
||||||
|
onChange={(e) => set({ provider: e.target.value })}
|
||||||
|
placeholder="tavily"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Capability (optional)" hint="e.g. web_search, geocode">
|
||||||
|
<Input
|
||||||
|
value={form.capability}
|
||||||
|
onChange={(e) => set({ capability: e.target.value })}
|
||||||
|
placeholder="web_search"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Display name (optional)">
|
||||||
|
<Input
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={(e) => set({ display_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Price (USD)" hint="per unit, for metering">
|
||||||
|
<Input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.price_usd}
|
||||||
|
onChange={(e) => set({ price_usd: e.target.value })}
|
||||||
|
placeholder="0.01"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Unit">
|
||||||
|
<Select value={form.unit} onValueChange={(v) => set({ unit: v ?? "call" })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="call">call</SelectItem>
|
||||||
|
<SelectItem value="search">search</SelectItem>
|
||||||
|
<SelectItem value="1k_tokens">1k_tokens</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Monthly budget (USD, optional)" hint="resolve is refused past this">
|
||||||
|
<Input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.monthly_budget_usd}
|
||||||
|
onChange={(e) => set({ monthly_budget_usd: e.target.value })}
|
||||||
|
placeholder="500"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{mode === "new" ? (
|
||||||
|
<div className="space-y-3 rounded-lg border p-3">
|
||||||
|
<p className="text-sm font-medium">Credential (optional)</p>
|
||||||
|
<Field label="Secret name" hint="the stable handle tools resolve by">
|
||||||
|
<Input
|
||||||
|
value={form.secret_name}
|
||||||
|
onChange={(e) => set({ secret_name: e.target.value })}
|
||||||
|
placeholder="tavily_default"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Auth kind">
|
||||||
|
<Select
|
||||||
|
value={form.auth_kind}
|
||||||
|
onValueChange={(v) => set({ auth_kind: (v as AuthKind) ?? "bearer_static" })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AUTH_KINDS.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{k}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Source">
|
||||||
|
<div className="flex h-9 items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="pooled"
|
||||||
|
checked={form.pooled}
|
||||||
|
onCheckedChange={(v) => set({ pooled: v })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="pooled" className="text-sm">
|
||||||
|
{form.pooled ? "pooled (billed)" : "BYO key"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Secret value" hint="stored encrypted, never shown again">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.secret}
|
||||||
|
onChange={(e) => set({ secret: e.target.value })}
|
||||||
|
placeholder="sk-…"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={saving || !form.provider.trim()}>
|
||||||
|
{saving ? "Saving…" : mode === "new" ? "Create" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import { AlertBanner } from "@crema/feedback-ui"
|
import { AlertBanner } from "@crema/feedback-ui"
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Wifi,
|
Wifi,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Users as UsersIcon,
|
Users as UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
|
|||||||
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,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { Check, RefreshCw, Trash2 } from "lucide-react"
|
import { Check, RefreshCw, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import { AlertBanner } from "@crema/feedback-ui"
|
import { AlertBanner } from "@crema/feedback-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
useSettings as useProviderSettings,
|
useSettings as useProviderSettings,
|
||||||
type LLMProvidersSettings,
|
type LLMProvidersSettings,
|
||||||
} from "@crema/llm-providers-ui"
|
} from "@crema/llm-providers-ui"
|
||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
|
|
||||||
import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
|
import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
|
||||||
import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel"
|
import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
@@ -580,7 +580,7 @@ function IdpEditorDialog({
|
|||||||
id="idp-callback"
|
id="idp-callback"
|
||||||
value={callbackUrl}
|
value={callbackUrl}
|
||||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||||
placeholder="https://your-arcadia-app/api/v1/auth/saml/callback"
|
placeholder="https://your-arcadia-core/api/v1/auth/saml/callback"
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
data-action="idp-form-callback"
|
data-action="idp-form-callback"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||||
import {
|
import {
|
||||||
IncidentTimeline,
|
IncidentTimeline,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"
|
||||||
import { Pause, Play, Plus, RefreshCw } from "lucide-react"
|
import { Pause, Play, Plus, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
@@ -26,10 +26,21 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card"
|
} 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 {
|
import {
|
||||||
activateTenant,
|
activateTenant,
|
||||||
deactivateTenant,
|
deactivateTenant,
|
||||||
listTenants,
|
listTenants,
|
||||||
|
provisionTenant,
|
||||||
suspendTenant,
|
suspendTenant,
|
||||||
type Tenant,
|
type Tenant,
|
||||||
type TenantStatus,
|
type TenantStatus,
|
||||||
@@ -54,6 +65,7 @@ export default function TenantsRoute() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [pending, setPending] = useState<PendingAction>(null)
|
const [pending, setPending] = useState<PendingAction>(null)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -191,7 +203,11 @@ export default function TenantsRoute() {
|
|||||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" disabled data-action="tenants-create">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
data-action="tenants-create"
|
||||||
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New tenant
|
New tenant
|
||||||
</Button>
|
</Button>
|
||||||
@@ -252,6 +268,15 @@ export default function TenantsRoute() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<TenantCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreated={async () => {
|
||||||
|
setCreateOpen(false)
|
||||||
|
await refresh()
|
||||||
|
}}
|
||||||
|
onError={setError}
|
||||||
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={pending?.kind === "suspend"}
|
open={pending?.kind === "suspend"}
|
||||||
onOpenChange={(o) => !o && setPending(null)}
|
onOpenChange={(o) => !o && setPending(null)}
|
||||||
@@ -330,3 +355,218 @@ function rowActions(
|
|||||||
})
|
})
|
||||||
return items
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Webhook as WebhookIcon,
|
Webhook as WebhookIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
BadgeCell,
|
BadgeCell,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# LLM Proxy Contract
|
# LLM Proxy Contract
|
||||||
|
|
||||||
> **Status: implemented.** Backend lives in `arcadia-app` at `apps/arcadia_core/lib/arcadia/ai/llm_proxy*` (see commit `75669f1`). This document remains the contract that `lib-llm-providers-ui` and `app/lib/arcadia/llm-proxy.ts` expect from arcadia — keep it in sync if either side changes.
|
> **Status: implemented.** Backend lives in `arcadia-core` at `apps/arcadia_core/lib/arcadia/ai/llm_proxy*` (see commit `75669f1`). This document remains the contract that `lib-llm-providers-ui` and `app/lib/arcadia/llm-proxy.ts` expect from arcadia — keep it in sync if either side changes.
|
||||||
|
|
||||||
## Why a proxy?
|
## Why a proxy?
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ shape.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Lib / service | `@crema/lexical-rag-ui` | `arcadia-search` (Rust) |
|
| Lib / service | `@crema/lexical-rag-ui` | `arcadia-search` (Rust) |
|
||||||
| Engine | MiniSearch (BM25, JS) | Tantivy (BM25, Rust) |
|
| Engine | MiniSearch (BM25, JS) | Tantivy (BM25, Rust) |
|
||||||
| Where it runs | In the user's browser | Sibling of arcadia-app |
|
| Where it runs | In the user's browser | Sibling of arcadia-core |
|
||||||
| Index storage | Static JSON, fetched once | mmap'd disk, ~30–80MB resident |
|
| 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 |
|
| 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 |
|
| Update cadence | Static — rebuilt at app build time | Live — cron, webhook, or admin trigger |
|
||||||
@@ -58,7 +58,7 @@ The index is a single JSON file built offline by
|
|||||||
**How it's wired here.**
|
**How it's wired here.**
|
||||||
|
|
||||||
- Build script: `arcadia-admin/scripts/build-docs-index.mjs` reads
|
- Build script: `arcadia-admin/scripts/build-docs-index.mjs` reads
|
||||||
markdown from `../reference/arcadia-app/`, chunks at H1–H3,
|
markdown from `../reference/arcadia-core/`, chunks at H1–H3,
|
||||||
produces `public/docs-index.json`. Runs on `npm run build:docs`
|
produces `public/docs-index.json`. Runs on `npm run build:docs`
|
||||||
(and as the `prebuild` step before `npm run build`).
|
(and as the `prebuild` step before `npm run build`).
|
||||||
- Tool wrapper: `app/lib/admin-tools.ts` constructs a singleton
|
- Tool wrapper: `app/lib/admin-tools.ts` constructs a singleton
|
||||||
@@ -130,7 +130,7 @@ text extraction, webhook signature, service tokens) see
|
|||||||
|
|
||||||
The default deploy runs **both**:
|
The default deploy runs **both**:
|
||||||
|
|
||||||
- `search_docs` indexes the same arcadia-app docs the parity corpus
|
- `search_docs` indexes the same arcadia-core docs the parity corpus
|
||||||
on `arcadia-search` indexes. Same content, two engines.
|
on `arcadia-search` indexes. Same content, two engines.
|
||||||
- This is intentional — it means the assistant always has *something*
|
- This is intentional — it means the assistant always has *something*
|
||||||
to search, even if `arcadia-search` is down or unreachable. The
|
to search, even if `arcadia-search` is down or unreachable. The
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Build the /docs-index.json bundle consumed by @crema/lexical-rag-ui at
|
// 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;
|
// runtime. Thin wrapper — all engine logic lives in the lib's builder.ts;
|
||||||
// this file owns the per-app config (which arcadia-app docs to index and
|
// this file owns the per-app config (which arcadia-core docs to index and
|
||||||
// how to tag them).
|
// how to tag them).
|
||||||
//
|
//
|
||||||
// Run: npm run build:docs
|
// Run: npm run build:docs
|
||||||
@@ -18,11 +18,11 @@ import MiniSearch from "minisearch"
|
|||||||
import { buildIndex } from "../../lib-lexical-rag-ui/src/builder.mjs"
|
import { buildIndex } from "../../lib-lexical-rag-ui/src/builder.mjs"
|
||||||
|
|
||||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
const ARCADIA = resolve(ROOT, "../reference/arcadia-app")
|
const ARCADIA = resolve(ROOT, "../reference/arcadia-core")
|
||||||
const OUT = resolve(ROOT, "public/docs-index.json")
|
const OUT = resolve(ROOT, "public/docs-index.json")
|
||||||
|
|
||||||
const SOURCES = [
|
const SOURCES = [
|
||||||
// Arcadia platform docs (resolved against ARCADIA = ../reference/arcadia-app).
|
// Arcadia platform docs (resolved against ARCADIA = ../reference/arcadia-core).
|
||||||
{ path: "README.md", tags: ["core"] },
|
{ path: "README.md", tags: ["core"] },
|
||||||
{ path: "docs/ARCADIA.md", tags: ["core"] },
|
{ path: "docs/ARCADIA.md", tags: ["core"] },
|
||||||
{ path: "docs/MODULAR_MONOLITH.md", tags: ["core"] },
|
{ path: "docs/MODULAR_MONOLITH.md", tags: ["core"] },
|
||||||
|
|||||||
@@ -26,8 +26,10 @@
|
|||||||
"@crema/llm-ui/*": ["../lib-llm-ui/src/*"],
|
"@crema/llm-ui/*": ["../lib-llm-ui/src/*"],
|
||||||
"@crema/action-bus": ["../lib-action-bus/src/index.tsx"],
|
"@crema/action-bus": ["../lib-action-bus/src/index.tsx"],
|
||||||
"@crema/action-bus/*": ["../lib-action-bus/src/*"],
|
"@crema/action-bus/*": ["../lib-action-bus/src/*"],
|
||||||
"@crema/arcadia-client": ["../lib-arcadia-client/src/index.tsx"],
|
"@crema/arcadia-core-client": ["../lib-arcadia-core-client/src/index.tsx"],
|
||||||
"@crema/arcadia-client/*": ["../lib-arcadia-client/src/*"],
|
"@crema/arcadia-core-client/*": ["../lib-arcadia-core-client/src/*"],
|
||||||
|
"@crema/integration-registry-client": ["../lib-integration-registry-client/src/index.tsx"],
|
||||||
|
"@crema/integration-registry-client/*": ["../lib-integration-registry-client/src/*"],
|
||||||
"@crema/arcadia-auth-ui": ["../lib-arcadia-auth-ui/src/index.tsx"],
|
"@crema/arcadia-auth-ui": ["../lib-arcadia-auth-ui/src/index.tsx"],
|
||||||
"@crema/arcadia-auth-ui/*": ["../lib-arcadia-auth-ui/src/*"],
|
"@crema/arcadia-auth-ui/*": ["../lib-arcadia-auth-ui/src/*"],
|
||||||
"@crema/table-ui": ["../lib-table-ui/src/index.tsx"],
|
"@crema/table-ui": ["../lib-table-ui/src/index.tsx"],
|
||||||
|
|||||||
113
vite.config.ts
113
vite.config.ts
@@ -60,7 +60,10 @@ const searchUiSrc = fileURLToPath(
|
|||||||
new URL("../lib-search-ui/src", import.meta.url),
|
new URL("../lib-search-ui/src", import.meta.url),
|
||||||
)
|
)
|
||||||
const arcadiaClientSrc = fileURLToPath(
|
const arcadiaClientSrc = fileURLToPath(
|
||||||
new URL("../lib-arcadia-client/src", import.meta.url),
|
new URL("../lib-arcadia-core-client/src", import.meta.url),
|
||||||
|
)
|
||||||
|
const integrationRegistryClientSrc = fileURLToPath(
|
||||||
|
new URL("../lib-integration-registry-client/src", import.meta.url),
|
||||||
)
|
)
|
||||||
const arcadiaAuthUiSrc = fileURLToPath(
|
const arcadiaAuthUiSrc = fileURLToPath(
|
||||||
new URL("../lib-arcadia-auth-ui/src", import.meta.url),
|
new URL("../lib-arcadia-auth-ui/src", import.meta.url),
|
||||||
@@ -86,6 +89,24 @@ const chartUiSrc = fileURLToPath(
|
|||||||
const statusUiSrc = fileURLToPath(
|
const statusUiSrc = fileURLToPath(
|
||||||
new URL("../lib-status-ui/src", import.meta.url),
|
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
|
// 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
|
// deps like clsx and @tiptap/* but have no node_modules of their own. Pin
|
||||||
@@ -105,6 +126,8 @@ const aliasedDeps = [
|
|||||||
"@tiptap/extension-placeholder",
|
"@tiptap/extension-placeholder",
|
||||||
"@tiptap/extension-image",
|
"@tiptap/extension-image",
|
||||||
"minisearch",
|
"minisearch",
|
||||||
|
"react-markdown",
|
||||||
|
"remark-gfm",
|
||||||
]
|
]
|
||||||
const sharedDepAliases = Object.fromEntries(
|
const sharedDepAliases = Object.fromEntries(
|
||||||
aliasedDeps.map((name) => [name, resolvePath(nodeModules, name)]),
|
aliasedDeps.map((name) => [name, resolvePath(nodeModules, name)]),
|
||||||
@@ -119,36 +142,64 @@ const dedupeDeps = [
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
// Array form so we can express both the bare-specifier alias
|
||||||
"@crema/content-ui": `${contentUiSrc}/index.ts`,
|
// (`@crema/agent-ui` -> src/index.tsx) and a subpath prefix alias
|
||||||
"@crema/content-editor-ui": `${contentEditorUiSrc}/index.ts`,
|
// (`@crema/agent-ui/chat` -> src/chat) for libs whose sibling-lib
|
||||||
"@crema/content-media-ui": `${contentMediaUiSrc}/index.tsx`,
|
// code reaches into deeper modules. Prefix entries with regex `find`
|
||||||
"@crema/color-ui": `${colorUiSrc}/index.tsx`,
|
// are matched first.
|
||||||
"@crema/typography-ui": `${typographyUiSrc}/index.tsx`,
|
alias: [
|
||||||
"@crema/data-ui": `${dataUiSrc}/index.tsx`,
|
// Subpath prefixes — longest first so they win before the bare match.
|
||||||
"@crema/layout-ui": `${layoutUiSrc}/index.tsx`,
|
{ find: /^@crema\/agent-ui\//, replacement: `${agentUiSrc}/` },
|
||||||
"@crema/map-ui": `${mapUiSrc}/index.tsx`,
|
{ find: /^@crema\/aifirst-ui\//, replacement: `${aifirstUiSrc}/` },
|
||||||
"@crema/form-ui": `${formUiSrc}/index.tsx`,
|
{ find: /^@crema\/notification-ui\//, replacement: `${notificationUiSrc}/` },
|
||||||
"@crema/feedback-ui": `${feedbackUiSrc}/index.tsx`,
|
{ find: /^@crema\/onboarding-ui\//, replacement: `${onboardingUiSrc}/` },
|
||||||
"@crema/diagram-ui": `${diagramUiSrc}/index.tsx`,
|
{ find: /^@crema\/lexical-rag-ui\//, replacement: `${lexicalRagUiSrc}/` },
|
||||||
"@crema/chat-ui": `${chatUiSrc}/index.tsx`,
|
{ find: /^@crema\/action-bus\//, replacement: `${actionBusSrc}/` },
|
||||||
"@crema/calendar-ui": `${calendarUiSrc}/index.tsx`,
|
|
||||||
"@crema/code-ui": `${codeUiSrc}/index.tsx`,
|
// Bare-specifier exact matches.
|
||||||
"@crema/ai-ui": `${aiUiSrc}/index.tsx`,
|
{ find: "@crema/content-ui", replacement: `${contentUiSrc}/index.ts` },
|
||||||
"@crema/auth-ui": `${authUiSrc}/index.tsx`,
|
{ find: "@crema/content-editor-ui", replacement: `${contentEditorUiSrc}/index.ts` },
|
||||||
"@crema/table-ui": `${tableUiSrc}/index.tsx`,
|
{ find: "@crema/content-media-ui", replacement: `${contentMediaUiSrc}/index.tsx` },
|
||||||
"@crema/search-ui": `${searchUiSrc}/index.tsx`,
|
{ find: "@crema/color-ui", replacement: `${colorUiSrc}/index.tsx` },
|
||||||
"@crema/arcadia-client": `${arcadiaClientSrc}/index.tsx`,
|
{ find: "@crema/typography-ui", replacement: `${typographyUiSrc}/index.tsx` },
|
||||||
"@crema/arcadia-auth-ui": `${arcadiaAuthUiSrc}/index.tsx`,
|
{ find: "@crema/data-ui", replacement: `${dataUiSrc}/index.tsx` },
|
||||||
"@crema/llm-ui": `${llmUiSrc}/index.tsx`,
|
{ find: "@crema/layout-ui", replacement: `${layoutUiSrc}/index.tsx` },
|
||||||
"@crema/llm-providers-ui": `${llmProvidersUiSrc}/index.tsx`,
|
{ find: "@crema/map-ui", replacement: `${mapUiSrc}/index.tsx` },
|
||||||
"@crema/file-ui": `${fileUiSrc}/index.tsx`,
|
{ find: "@crema/form-ui", replacement: `${formUiSrc}/index.tsx` },
|
||||||
"@crema/card-ui": `${cardUiSrc}/index.tsx`,
|
{ find: "@crema/feedback-ui", replacement: `${feedbackUiSrc}/index.tsx` },
|
||||||
"@crema/dashboard-ui": `${dashboardUiSrc}/index.tsx`,
|
{ find: "@crema/diagram-ui", replacement: `${diagramUiSrc}/index.tsx` },
|
||||||
"@crema/chart-ui": `${chartUiSrc}/index.tsx`,
|
{ find: "@crema/chat-ui", replacement: `${chatUiSrc}/index.tsx` },
|
||||||
"@crema/status-ui": `${statusUiSrc}/index.tsx`,
|
{ find: "@crema/calendar-ui", replacement: `${calendarUiSrc}/index.tsx` },
|
||||||
...sharedDepAliases,
|
{ 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-core-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,
|
dedupe: dedupeDeps,
|
||||||
},
|
},
|
||||||
// Pre-bundle deps that sibling libs reach for. Without this, Vite
|
// Pre-bundle deps that sibling libs reach for. Without this, Vite
|
||||||
|
|||||||
Reference in New Issue
Block a user