Compare commits
3 Commits
a299900021
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ab9b730f5 | |||
|
|
4b817b85ff | ||
|
|
06490865d3 |
@@ -39,6 +39,8 @@ import {
|
||||
Plug,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
CreditCard,
|
||||
// CREMA:NAV-ICONS
|
||||
} from "lucide-react"
|
||||
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
} from "~/components/ui/popover"
|
||||
import { profileInitials, useProfile } from "~/lib/profile"
|
||||
import { signOut, useSession } from "~/lib/session"
|
||||
import { capabilityForPath, useCapabilities } from "~/lib/capabilities"
|
||||
import {
|
||||
addNotification,
|
||||
dismiss,
|
||||
@@ -96,6 +99,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet"
|
||||
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
|
||||
import { RouteGuard } from "~/components/route-guard"
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
@@ -134,6 +138,16 @@ const navGroups: NavGroup[] = [
|
||||
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: "Billing",
|
||||
icon: CreditCard,
|
||||
items: [
|
||||
{ to: "/apps", icon: LayoutGrid, label: "Apps" },
|
||||
{ to: "/plan", icon: CreditCard, label: "Plan" },
|
||||
{ to: "/entitlements", icon: Gauge, label: "Entitlements" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "data",
|
||||
label: "Data",
|
||||
@@ -142,6 +156,7 @@ const navGroups: NavGroup[] = [
|
||||
{ to: "/storage", icon: HardDrive, label: "Storage" },
|
||||
{ to: "/buckets", icon: Boxes, label: "Buckets" },
|
||||
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
|
||||
{ to: "/integrations", icon: Plug, label: "Integrations" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -189,15 +204,6 @@ const extraNavItems: NavItem[] = [
|
||||
// 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> {
|
||||
if (typeof window === "undefined") return {}
|
||||
try {
|
||||
@@ -230,6 +236,7 @@ export function AppShell({
|
||||
const defaultUser = useUser()
|
||||
const profile = useProfile()
|
||||
const session = useSession()
|
||||
const caps = useCapabilities()
|
||||
const navigate = useNavigate()
|
||||
const brand = brandOverride ?? defaultBrand
|
||||
// Prefer the live session for identity, fall back to the stub user.
|
||||
@@ -264,11 +271,51 @@ export function AppShell({
|
||||
useScriptsHotkey(() => setScriptsOpen(true))
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
// Filter the nav by what the active session can actually reach. A
|
||||
// capability map exists for every protected route — items without one
|
||||
// (or whose capability isn't held) are dropped here, so the sidebar
|
||||
// doesn't advertise routes the user will only hit a 403 from.
|
||||
const allowed = (item: NavItem): boolean => {
|
||||
const cap = capabilityForPath(item.to)
|
||||
if (!cap) return true // unknown routes default to visible
|
||||
return caps.has(cap)
|
||||
}
|
||||
const visiblePinnedTop = useMemo(
|
||||
() => pinnedTop.filter(allowed),
|
||||
[caps],
|
||||
)
|
||||
const visiblePinnedBottom = useMemo(
|
||||
() => pinnedBottom.filter(allowed),
|
||||
[caps],
|
||||
)
|
||||
const visibleNavGroups: NavGroup[] = useMemo(
|
||||
() =>
|
||||
navGroups
|
||||
.map((g) => ({ ...g, items: g.items.filter(allowed) }))
|
||||
.filter((g) => g.items.length > 0),
|
||||
[caps],
|
||||
)
|
||||
const visibleExtraItems = useMemo(
|
||||
() => extraNavItems.filter(allowed),
|
||||
[caps],
|
||||
)
|
||||
const visibleAllNavItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
...visiblePinnedTop,
|
||||
...visibleNavGroups.flatMap((g) => g.items),
|
||||
...visibleExtraItems,
|
||||
...visiblePinnedBottom,
|
||||
],
|
||||
[visiblePinnedTop, visibleNavGroups, visibleExtraItems, visiblePinnedBottom],
|
||||
)
|
||||
|
||||
const activeGroupKey = useMemo(
|
||||
() =>
|
||||
navGroups.find((g) => g.items.some((it) => location.pathname.startsWith(it.to)))
|
||||
?.key ?? null,
|
||||
[location.pathname],
|
||||
visibleNavGroups.find((g) =>
|
||||
g.items.some((it) => location.pathname.startsWith(it.to)),
|
||||
)?.key ?? null,
|
||||
[location.pathname, visibleNavGroups],
|
||||
)
|
||||
|
||||
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">
|
||||
{expanded ? (
|
||||
<>
|
||||
{pinnedTop.map((item) => (
|
||||
{visiblePinnedTop.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
|
||||
{navGroups.map((group) => {
|
||||
{visibleNavGroups.map((group) => {
|
||||
const isOpen = !!openGroups[group.key]
|
||||
const GroupIcon = group.icon
|
||||
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">
|
||||
{extraNavItems.map((item) => (
|
||||
{visibleExtraItems.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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 />
|
||||
))}
|
||||
</div>
|
||||
@@ -392,7 +439,7 @@ export function AppShell({
|
||||
) : (
|
||||
// Icon-only rail: flat list, no group headers.
|
||||
<>
|
||||
{allNavItems.map((item) => (
|
||||
{visibleAllNavItems.map((item) => (
|
||||
<NavRow key={item.label} item={item} expanded={false} />
|
||||
))}
|
||||
</>
|
||||
@@ -443,7 +490,7 @@ export function AppShell({
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<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
|
||||
key={item.label}
|
||||
item={item}
|
||||
@@ -453,7 +500,7 @@ export function AppShell({
|
||||
/>
|
||||
))}
|
||||
|
||||
{navGroups.map((group) => {
|
||||
{visibleNavGroups.map((group) => {
|
||||
const isOpen = !!openGroups[group.key]
|
||||
const GroupIcon = group.icon
|
||||
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">
|
||||
{extraNavItems.map((item) => (
|
||||
{visibleExtraItems.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
@@ -507,7 +554,7 @@ export function AppShell({
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-0.5 pt-2">
|
||||
{pinnedBottom.map((item) => (
|
||||
{visiblePinnedBottom.map((item) => (
|
||||
<NavRow
|
||||
key={item.label}
|
||||
item={item}
|
||||
@@ -601,12 +648,16 @@ export function AppShell({
|
||||
<div
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
// First-child padding clears the fixed top-right floating actions
|
||||
// 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"
|
||||
className="flex flex-1 flex-col focus:outline-none"
|
||||
>
|
||||
{children}
|
||||
{/* Centered content column. Caps line lengths and frames pages
|
||||
on wide displays so the canvas reads as composed instead of
|
||||
one floating card in a sea of black. The floating actions
|
||||
pill is fixed to the viewport edge and lives outside this
|
||||
column, so it stays clear regardless of cap width. */}
|
||||
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 p-6 [&>*:first-child]:lg:pr-72">
|
||||
<RouteGuard>{children}</RouteGuard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -645,7 +696,11 @@ function NavRow({
|
||||
data-action={`${prefix}${item.label.toLowerCase()}`}
|
||||
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
|
||||
? inGroup
|
||||
? // Indent the label by chevron(12) + gap(8) = 20px so it
|
||||
@@ -654,7 +709,7 @@ function NavRow({
|
||||
: "justify-start px-3"
|
||||
: "justify-center px-3",
|
||||
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",
|
||||
].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>
|
||||
)
|
||||
}
|
||||
40
app/lib/arcadia/integrations.ts
Normal file
40
app/lib/arcadia/integrations.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Integration-registry client (operator surface) — thin shim over the shared
|
||||
// `@crema/integration-registry-client` lib, bound to `operator` mode. The lib
|
||||
// owns the types, the HTTP contract, and the display helpers (shared with
|
||||
// arcadia-console's tenant surface); this file just exposes operator-idiomatic
|
||||
// names so the page reads naturally.
|
||||
|
||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
||||
import {
|
||||
createIntegrationsApi,
|
||||
type CredentialInput,
|
||||
type IntegrationInput,
|
||||
type ScopeFilter,
|
||||
} from "@crema/integration-registry-client"
|
||||
|
||||
// Re-export the shared types + helpers so callers import from one place.
|
||||
export * from "@crema/integration-registry-client"
|
||||
|
||||
const op = (c: ArcadiaClient) => createIntegrationsApi(c, "operator")
|
||||
|
||||
export const listIntegrations = (c: ArcadiaClient, filter: ScopeFilter = {}) =>
|
||||
op(c).list(filter)
|
||||
export const createIntegration = (c: ArcadiaClient, input: IntegrationInput) =>
|
||||
op(c).create(input)
|
||||
export const updateIntegration = (
|
||||
c: ArcadiaClient,
|
||||
id: string,
|
||||
input: Partial<IntegrationInput>,
|
||||
) => op(c).update(id, input)
|
||||
export const deleteIntegration = (c: ArcadiaClient, id: string) => op(c).remove(id)
|
||||
export const addCredential = (c: ArcadiaClient, integrationId: string, input: CredentialInput) =>
|
||||
op(c).addCredential(integrationId, input)
|
||||
export const updateCredential = (
|
||||
c: ArcadiaClient,
|
||||
credentialId: string,
|
||||
input: Partial<CredentialInput>,
|
||||
) => op(c).updateCredential(credentialId, input)
|
||||
export const deleteCredential = (c: ArcadiaClient, credentialId: string) =>
|
||||
op(c).deleteCredential(credentialId)
|
||||
export const testIntegration = (c: ArcadiaClient, id: string) => op(c).test(id)
|
||||
export const usageSummary = (c: ArcadiaClient, filter: ScopeFilter = {}) => op(c).usage(filter)
|
||||
@@ -91,3 +91,23 @@ export async function deactivateTenant(arcadia: ArcadiaClient, id: string): Prom
|
||||
const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface ProvisionTenantInput {
|
||||
tenant: { name: string; slug: string }
|
||||
admin_user: {
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionTenant(
|
||||
arcadia: ArcadiaClient,
|
||||
input: ProvisionTenantInput,
|
||||
): Promise<Tenant> {
|
||||
const res = await arcadia.POST<{ data: Tenant }>("/api/v1/admin/tenants/provision", {
|
||||
body: input,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
168
app/lib/capabilities.ts
Normal file
168
app/lib/capabilities.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// Capability gating — the contract between roles, nav, and routes.
|
||||
//
|
||||
// A capability is a *thing the user can do in this UI*. The set held by
|
||||
// the current session is computed from their active membership's roles
|
||||
// + the slug of the active tenant (platform-admin gets the platform.*
|
||||
// fleet by default). Sidebar nav filters by it; per-route guards 403
|
||||
// when the user deep-links to one they don't hold.
|
||||
//
|
||||
// The server is the real authority — these checks are UI-shaping, not
|
||||
// security. Don't ever trust the client capability check on its own.
|
||||
|
||||
export type Capability =
|
||||
// tenant.* — held by tenant_admin on the active membership.
|
||||
| "tenant.home"
|
||||
| "tenant.users"
|
||||
| "tenant.invitations"
|
||||
| "tenant.roles"
|
||||
| "tenant.memberships"
|
||||
| "tenant.apps"
|
||||
| "tenant.plan"
|
||||
| "tenant.entitlements"
|
||||
| "tenant.storage"
|
||||
| "tenant.buckets"
|
||||
| "tenant.activity"
|
||||
| "tenant.settings"
|
||||
| "tenant.profile"
|
||||
// platform.* — held by platform_admin on platform-admin.
|
||||
| "platform.tenants"
|
||||
| "platform.organizations"
|
||||
| "platform.networking"
|
||||
| "platform.monitoring"
|
||||
| "platform.status_page"
|
||||
| "platform.scheduled_tasks"
|
||||
| "platform.secrets"
|
||||
| "platform.webhooks"
|
||||
| "platform.announcements"
|
||||
| "platform.sso"
|
||||
| "platform.library"
|
||||
| "platform.search"
|
||||
| "platform.ai"
|
||||
| "platform.integrations" // external-API registry (keys/budgets) on the gateway
|
||||
// Special — always-on; not gated.
|
||||
| "always.assistant"
|
||||
| "always.profile"
|
||||
|
||||
/** Roles arcadia issues that this UI knows about. */
|
||||
export type Role =
|
||||
| "platform_admin"
|
||||
| "tenant_admin"
|
||||
| "member"
|
||||
| (string & {}) // accept unknown roles forward-compat
|
||||
|
||||
const TENANT_ADMIN_CAPS: Capability[] = [
|
||||
"tenant.home",
|
||||
"tenant.users",
|
||||
"tenant.invitations",
|
||||
"tenant.roles",
|
||||
"tenant.memberships",
|
||||
"tenant.apps",
|
||||
"tenant.plan",
|
||||
"tenant.entitlements",
|
||||
"tenant.storage",
|
||||
"tenant.buckets",
|
||||
"tenant.activity",
|
||||
"tenant.settings",
|
||||
"tenant.profile",
|
||||
]
|
||||
|
||||
const PLATFORM_ADMIN_CAPS: Capability[] = [
|
||||
// platform_admin also gets every tenant.* — they're an admin of the
|
||||
// platform-admin tenant, so they manage *its* users, storage, etc.
|
||||
...TENANT_ADMIN_CAPS,
|
||||
"platform.tenants",
|
||||
"platform.organizations",
|
||||
"platform.networking",
|
||||
"platform.monitoring",
|
||||
"platform.status_page",
|
||||
"platform.scheduled_tasks",
|
||||
"platform.secrets",
|
||||
"platform.webhooks",
|
||||
"platform.announcements",
|
||||
"platform.sso",
|
||||
"platform.library",
|
||||
"platform.search",
|
||||
"platform.ai",
|
||||
"platform.integrations",
|
||||
]
|
||||
|
||||
const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"]
|
||||
|
||||
export function capabilitiesForRoles(roles: readonly string[] | undefined): Set<Capability> {
|
||||
const caps = new Set<Capability>(ALWAYS_CAPS)
|
||||
const has = (r: string) => (roles ?? []).includes(r)
|
||||
if (has("platform_admin")) PLATFORM_ADMIN_CAPS.forEach((c) => caps.add(c))
|
||||
if (has("tenant_admin") || has("admin")) TENANT_ADMIN_CAPS.forEach((c) => caps.add(c))
|
||||
// "member" / other roles get only the always-on set.
|
||||
return caps
|
||||
}
|
||||
|
||||
/** Pure helper — handy in tests + route loaders. */
|
||||
export function holds(caps: Set<Capability>, cap: Capability): boolean {
|
||||
return caps.has(cap)
|
||||
}
|
||||
|
||||
// ----------------------------- Route map ----------------------------
|
||||
//
|
||||
// Every protected route declares which capability it needs. Sidebar nav
|
||||
// and the per-route guard both read this map, so the contract lives in
|
||||
// one place.
|
||||
|
||||
export const ROUTE_CAPABILITY: Record<string, Capability> = {
|
||||
"/": "tenant.home",
|
||||
"/users": "tenant.users",
|
||||
"/memberships": "tenant.memberships",
|
||||
"/storage": "tenant.storage",
|
||||
"/buckets": "tenant.buckets",
|
||||
"/activity": "tenant.activity",
|
||||
"/settings": "tenant.settings",
|
||||
"/apps": "tenant.apps",
|
||||
"/plan": "tenant.plan",
|
||||
"/entitlements": "tenant.entitlements",
|
||||
|
||||
"/tenants": "platform.tenants",
|
||||
"/organizations": "platform.organizations",
|
||||
"/networking": "platform.networking",
|
||||
"/monitoring": "platform.monitoring",
|
||||
"/status-page": "platform.status_page",
|
||||
"/scheduled-tasks": "platform.scheduled_tasks",
|
||||
"/secrets": "platform.secrets",
|
||||
"/webhooks": "platform.webhooks",
|
||||
"/announcements": "platform.announcements",
|
||||
"/sso": "platform.sso",
|
||||
"/library": "platform.library",
|
||||
"/search": "platform.search",
|
||||
"/ai": "platform.ai",
|
||||
"/integrations": "platform.integrations",
|
||||
|
||||
"/assistant": "always.assistant",
|
||||
"/profile": "always.profile",
|
||||
}
|
||||
|
||||
// ----------------------------- Hooks --------------------------------
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useSession } from "~/lib/session"
|
||||
|
||||
/** The active session's capability set. Empty when not signed in. */
|
||||
export function useCapabilities(): Set<Capability> {
|
||||
const session = useSession()
|
||||
return useMemo(() => capabilitiesForRoles(session?.roles), [session?.roles])
|
||||
}
|
||||
|
||||
export function useHasCapability(cap: Capability): boolean {
|
||||
return useCapabilities().has(cap)
|
||||
}
|
||||
|
||||
export function capabilityForPath(pathname: string): Capability | null {
|
||||
// Exact match first.
|
||||
if (ROUTE_CAPABILITY[pathname]) return ROUTE_CAPABILITY[pathname]
|
||||
// Then prefix match — "/users/123" inherits "/users"'s capability.
|
||||
// Walk known keys longest-first so "/scheduled-tasks/x" picks the
|
||||
// right one over "/s".
|
||||
const keys = Object.keys(ROUTE_CAPABILITY).sort((a, b) => b.length - a.length)
|
||||
for (const k of keys) {
|
||||
if (k !== "/" && pathname.startsWith(k + "/")) return ROUTE_CAPABILITY[k]
|
||||
}
|
||||
return null
|
||||
}
|
||||
38
app/lib/gateway.ts
Normal file
38
app/lib/gateway.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Arcadia LLM-gateway client.
|
||||
//
|
||||
// The integration registry lives on arcadia-llm-gateway, not arcadia-app, so
|
||||
// it needs its own ArcadiaClient pointed at a different base URL. Everything
|
||||
// else is identical to the arcadia-app client: the same access token (the
|
||||
// gateway validates arcadia-app JWTs via the shared Guardian secret) and the
|
||||
// same 401 cleanup. The gateway's CORS already allows localhost + any
|
||||
// *.sky-ai.com origin, so the browser calls it directly.
|
||||
|
||||
import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-client"
|
||||
|
||||
const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015"
|
||||
|
||||
const ACCESS_TOKEN_KEY = "arcadia_access_token"
|
||||
const REFRESH_TOKEN_KEY = "arcadia_refresh_token"
|
||||
|
||||
let client: ArcadiaClient | null = null
|
||||
|
||||
export function gatewayClient(): ArcadiaClient {
|
||||
if (!client) {
|
||||
client = createArcadiaClient({
|
||||
baseUrl: GATEWAY_URL,
|
||||
getToken: () =>
|
||||
typeof window === "undefined" ? null : sessionStorage.getItem(ACCESS_TOKEN_KEY),
|
||||
onUnauthorized: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
export function useGatewayClient(): ArcadiaClient {
|
||||
return gatewayClient()
|
||||
}
|
||||
49
app/lib/jwt.ts
Normal file
49
app/lib/jwt.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Tiny JWT helpers — we never *verify* tokens client-side (the server
|
||||
// is the only authority), we just decode the payload to read claims
|
||||
// the UI uses for nav gating + tenant context.
|
||||
|
||||
export type ArcadiaClaims = {
|
||||
sub?: string
|
||||
email?: string
|
||||
tenant_id?: string
|
||||
tenant_slug?: string
|
||||
roles?: string[]
|
||||
available_tenants?: AvailableTenantClaim[]
|
||||
exp?: number
|
||||
iat?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export type AvailableTenantClaim = {
|
||||
id?: string
|
||||
slug?: string
|
||||
name?: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): string {
|
||||
const pad = "=".repeat((4 - (s.length % 4)) % 4)
|
||||
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/")
|
||||
if (typeof atob === "function") return atob(b64)
|
||||
// Node fallback (SSR / tests)
|
||||
return Buffer.from(b64, "base64").toString("binary")
|
||||
}
|
||||
|
||||
export function decodeJwt(token: string): ArcadiaClaims | null {
|
||||
if (!token) return null
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
try {
|
||||
const raw = b64urlDecode(parts[1])
|
||||
// Handle UTF-8: atob returns binary string; reconstruct UTF-8.
|
||||
const utf8 =
|
||||
typeof TextDecoder !== "undefined"
|
||||
? new TextDecoder().decode(
|
||||
Uint8Array.from(raw, (c) => c.charCodeAt(0)),
|
||||
)
|
||||
: raw
|
||||
return JSON.parse(utf8) as ArcadiaClaims
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
|
||||
import { profileInitials } from "~/lib/profile"
|
||||
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
|
||||
|
||||
export type AvailableTenant = {
|
||||
id: string
|
||||
slug?: string
|
||||
name?: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
userId: string
|
||||
@@ -14,6 +22,11 @@ export type Session = {
|
||||
token: string
|
||||
// Issued at, ms since epoch.
|
||||
issuedAt: number
|
||||
// Active membership context — derived from the JWT.
|
||||
tenantId?: string
|
||||
tenantSlug?: string
|
||||
roles: string[]
|
||||
availableTenants: AvailableTenant[]
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "crema.session"
|
||||
@@ -41,6 +54,18 @@ function readFromStorage(): Session | null {
|
||||
token: parsed.token,
|
||||
issuedAt:
|
||||
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
||||
tenantId: typeof parsed.tenantId === "string" ? parsed.tenantId : undefined,
|
||||
tenantSlug:
|
||||
typeof parsed.tenantSlug === "string" ? parsed.tenantSlug : undefined,
|
||||
roles: Array.isArray(parsed.roles)
|
||||
? parsed.roles.filter((r): r is string => typeof r === "string")
|
||||
: [],
|
||||
availableTenants: Array.isArray(parsed.availableTenants)
|
||||
? (parsed.availableTenants.filter(
|
||||
(t): t is AvailableTenant =>
|
||||
!!t && typeof (t as AvailableTenant).id === "string",
|
||||
) as AvailableTenant[])
|
||||
: [],
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -72,12 +97,31 @@ export function persistFromArcadiaLogin(
|
||||
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
|
||||
user?.email ||
|
||||
"Signed-in user"
|
||||
const claims = decodeJwt(tokens.access_token) ?? {}
|
||||
const availableTenants: AvailableTenant[] = Array.isArray(
|
||||
claims.available_tenants,
|
||||
)
|
||||
? (claims.available_tenants as AvailableTenantClaim[])
|
||||
.filter((t) => t && typeof t.id === "string")
|
||||
.map((t) => ({
|
||||
id: t.id as string,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
roles: Array.isArray(t.roles) ? t.roles : [],
|
||||
}))
|
||||
: []
|
||||
const session: Session = {
|
||||
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
|
||||
name,
|
||||
email: user?.email ?? "",
|
||||
token: tokens.access_token,
|
||||
issuedAt: Date.now(),
|
||||
tenantId:
|
||||
typeof claims.tenant_id === "string" ? claims.tenant_id : undefined,
|
||||
tenantSlug:
|
||||
typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined,
|
||||
roles: Array.isArray(claims.roles) ? claims.roles : [],
|
||||
availableTenants,
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
|
||||
|
||||
@@ -28,5 +28,9 @@ export default [
|
||||
route("announcements", "routes/announcements.tsx"),
|
||||
route("status-page", "routes/status-page.tsx"),
|
||||
route("search", "routes/search.tsx"),
|
||||
route("apps", "routes/apps.tsx"),
|
||||
route("plan", "routes/plan.tsx"),
|
||||
route("entitlements", "routes/entitlements.tsx"),
|
||||
route("integrations", "routes/integrations.tsx"),
|
||||
// CREMA:ROUTES
|
||||
] satisfies RouteConfig
|
||||
|
||||
@@ -69,6 +69,39 @@ export const meta = () => pageTitle("Announcements")
|
||||
|
||||
const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"]
|
||||
|
||||
const KIND_OPTIONS: { value: AnnouncementType; hint: string }[] = [
|
||||
{ value: "info", hint: "Neutral update" },
|
||||
{ value: "warning", hint: "Degraded service or heads-up" },
|
||||
{ value: "maintenance", hint: "Scheduled work" },
|
||||
{ value: "incident", hint: "Active outage" },
|
||||
{ value: "feature", hint: "Something new shipped" },
|
||||
]
|
||||
|
||||
function typeToAlertVariant(
|
||||
t: AnnouncementType,
|
||||
): "info" | "success" | "warning" | "error" | "neutral" {
|
||||
if (t === "incident") return "error"
|
||||
if (t === "warning" || t === "maintenance") return "warning"
|
||||
if (t === "feature") return "success"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function publishButtonLabel(opts: {
|
||||
isEdit: boolean
|
||||
active: boolean
|
||||
audience: "platform" | "tenant"
|
||||
tenantId: string
|
||||
tenants: Tenant[]
|
||||
}): string {
|
||||
if (opts.isEdit) return "Save changes"
|
||||
if (!opts.active) return "Save draft"
|
||||
if (opts.audience === "tenant") {
|
||||
const name = opts.tenants.find((t) => t.id === opts.tenantId)?.name
|
||||
return name ? `Publish to ${name}` : "Publish to tenant"
|
||||
}
|
||||
return "Publish to all users"
|
||||
}
|
||||
|
||||
type Editor =
|
||||
| { kind: "create" }
|
||||
| { kind: "edit"; announcement: Announcement }
|
||||
@@ -86,6 +119,8 @@ export default function AnnouncementsRoute() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [editor, setEditor] = useState<Editor>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<Announcement | null>(null)
|
||||
const [refreshedAt, setRefreshedAt] = useState<number | null>(null)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
@@ -97,6 +132,7 @@ export default function AnnouncementsRoute() {
|
||||
])
|
||||
setItems(a)
|
||||
setTenants(t)
|
||||
setRefreshedAt(Date.now())
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
|
||||
} finally {
|
||||
@@ -104,6 +140,21 @@ export default function AnnouncementsRoute() {
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshedAt == null) return
|
||||
const id = window.setInterval(() => setNow(Date.now()), 30_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [refreshedAt])
|
||||
|
||||
const lastRefreshedLabel = useMemo(() => {
|
||||
if (refreshedAt == null) return null
|
||||
const seconds = Math.max(1, Math.round((now - refreshedAt) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.round(minutes / 60)}h ago`
|
||||
}, [refreshedAt, now])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) refresh()
|
||||
}, [session, refresh])
|
||||
@@ -133,13 +184,12 @@ export default function AnnouncementsRoute() {
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
header: "Scope",
|
||||
cell: (a) =>
|
||||
a.tenant_id ? (
|
||||
<Badge variant="secondary">tenant</Badge>
|
||||
) : (
|
||||
<Badge>platform</Badge>
|
||||
),
|
||||
header: "Audience",
|
||||
cell: (a) => {
|
||||
if (!a.tenant_id) return <Badge>All apps</Badge>
|
||||
const t = tenants.find((x) => x.id === a.tenant_id)
|
||||
return <Badge variant="secondary">{t?.slug ?? "Single tenant"}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
@@ -207,7 +257,7 @@ export default function AnnouncementsRoute() {
|
||||
},
|
||||
},
|
||||
],
|
||||
[arcadia, refresh],
|
||||
[arcadia, refresh, tenants],
|
||||
)
|
||||
|
||||
const summary = useMemo(
|
||||
@@ -234,32 +284,48 @@ export default function AnnouncementsRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Announcements</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Platform-wide and per-tenant banners. Apps consuming arcadia surface these to users.
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[26px] font-[620] leading-[1.1] tracking-[-0.02em]">
|
||||
Announcements
|
||||
</h1>
|
||||
<p className="mt-1.5 max-w-[56ch] text-[13.5px] leading-[1.5] text-muted-foreground">
|
||||
Banners that appear at the top of every Sky AI app. Use them for maintenance
|
||||
windows, incidents, or new features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex 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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
aria-label="Refresh announcements"
|
||||
data-action="announcements-refresh"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setEditor({ kind: "create" })}
|
||||
data-action="announcements-create"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New announcement
|
||||
</Button>
|
||||
{items.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setEditor({ kind: "create" })}
|
||||
data-action="announcements-create"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New announcement
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -283,9 +349,13 @@ export default function AnnouncementsRoute() {
|
||||
data-action="announcements-search"
|
||||
className="max-w-sm flex-1"
|
||||
/>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{table.total} of {items.length}
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||
{search && table.total !== items.length
|
||||
? `${table.total} of ${items.length}`
|
||||
: `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
@@ -295,12 +365,47 @@ export default function AnnouncementsRoute() {
|
||||
/>
|
||||
{table.total === 0 && !loading ? (
|
||||
<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."}
|
||||
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 [active, setActive] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setLocalError(null)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -439,6 +549,7 @@ function AnnouncementEditorDialog({
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setLocalError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const input: AnnouncementInput = {
|
||||
@@ -462,13 +573,13 @@ function AnnouncementEditorDialog({
|
||||
await onSaved("Announcement posted.")
|
||||
}
|
||||
} catch (err) {
|
||||
onError(
|
||||
const msg =
|
||||
err instanceof ArcadiaError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Save failed.",
|
||||
)
|
||||
: "Save failed."
|
||||
setLocalError(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -476,15 +587,59 @@ function AnnouncementEditorDialog({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<DialogTitle>{isEdit ? "Edit announcement" : "New announcement"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Banners surface in apps that consume arcadia. Active + currently within the start/end
|
||||
window = visible.
|
||||
A banner shows at the top of every Sky AI app. It's visible when it's switched on
|
||||
and today falls inside its date range.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Live preview — what users will see. Updates as the form is edited so
|
||||
the operator never has to imagine the output or publish blind. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Preview
|
||||
</Label>
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<AlertBanner
|
||||
variant={typeToAlertVariant(type)}
|
||||
title={title || "Your banner title appears here"}
|
||||
dismissible={dismissible}
|
||||
onDismiss={() => {}}
|
||||
action={
|
||||
actionLabel && actionUrl ? (
|
||||
<Button size="xs" variant="outline" type="button" tabIndex={-1}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{body || (
|
||||
<span className="italic opacity-60">Body text appears here.</span>
|
||||
)}
|
||||
</AlertBanner>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{audience === "tenant"
|
||||
? `Visible to users of ${
|
||||
tenants.find((t) => t.id === tenantId)?.name ?? "the selected tenant"
|
||||
} only.`
|
||||
: "Visible to everyone across every Sky AI app."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError ? (
|
||||
<AlertBanner
|
||||
variant="error"
|
||||
dismissible
|
||||
onDismiss={() => setLocalError(null)}
|
||||
>
|
||||
{localError}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-title">Title</Label>
|
||||
@@ -493,6 +648,7 @@ function AnnouncementEditorDialog({
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
data-action="announcement-form-title"
|
||||
placeholder="Scheduled maintenance Sunday 2am AEST"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
@@ -501,21 +657,25 @@ function AnnouncementEditorDialog({
|
||||
id="ann-body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={4}
|
||||
rows={3}
|
||||
data-action="announcement-form-body"
|
||||
placeholder="Expect ~10 minutes of downtime while we ship the new tenant switcher."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Type</Label>
|
||||
<Label>Kind</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger data-action="announcement-form-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium capitalize">{opt.value}</span>
|
||||
<span className="text-xs text-muted-foreground">{opt.hint}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -523,21 +683,21 @@ function AnnouncementEditorDialog({
|
||||
</div>
|
||||
|
||||
<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")}>
|
||||
<SelectTrigger data-action="announcement-form-audience">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="platform">Platform-wide</SelectItem>
|
||||
<SelectItem value="tenant">Single tenant</SelectItem>
|
||||
<SelectItem value="platform">Everyone</SelectItem>
|
||||
<SelectItem value="tenant">Just one tenant</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{audience === "tenant" ? (
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<Label>Tenant</Label>
|
||||
<Label>Which tenant</Label>
|
||||
<Select value={tenantId} onValueChange={setTenantId}>
|
||||
<SelectTrigger data-action="announcement-form-tenant">
|
||||
<SelectValue placeholder="Pick a tenant" />
|
||||
@@ -554,7 +714,7 @@ function AnnouncementEditorDialog({
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-starts">Starts at</Label>
|
||||
<Label htmlFor="ann-starts">Starts</Label>
|
||||
<Input
|
||||
id="ann-starts"
|
||||
type="datetime-local"
|
||||
@@ -564,7 +724,7 @@ function AnnouncementEditorDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-ends">Ends at</Label>
|
||||
<Label htmlFor="ann-ends">Ends</Label>
|
||||
<Input
|
||||
id="ann-ends"
|
||||
type="datetime-local"
|
||||
@@ -574,57 +734,88 @@ function AnnouncementEditorDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-action-label">Action label (optional)</Label>
|
||||
<Input
|
||||
id="ann-action-label"
|
||||
value={actionLabel}
|
||||
onChange={(e) => setActionLabel(e.target.value)}
|
||||
placeholder="Read more"
|
||||
data-action="announcement-form-action-label"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-action-url">Action URL (optional)</Label>
|
||||
<Input
|
||||
id="ann-action-url"
|
||||
value={actionUrl}
|
||||
onChange={(e) => setActionUrl(e.target.value)}
|
||||
placeholder="/changelog/v2"
|
||||
data-action="announcement-form-action-url"
|
||||
/>
|
||||
{/* Optional link group — heading clarifies these two are paired. */}
|
||||
<div className="col-span-2 flex flex-col gap-2 rounded-md border border-dashed p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Label className="text-sm">Add a link</Label>
|
||||
<span className="text-xs text-muted-foreground">Optional</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-action-label" className="text-xs text-muted-foreground">
|
||||
Button text
|
||||
</Label>
|
||||
<Input
|
||||
id="ann-action-label"
|
||||
value={actionLabel}
|
||||
onChange={(e) => setActionLabel(e.target.value)}
|
||||
placeholder="Read more"
|
||||
data-action="announcement-form-action-label"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ann-action-url" className="text-xs text-muted-foreground">
|
||||
Where it goes
|
||||
</Label>
|
||||
<Input
|
||||
id="ann-action-url"
|
||||
value={actionUrl}
|
||||
onChange={(e) => setActionUrl(e.target.value)}
|
||||
placeholder="/changelog/v2"
|
||||
data-action="announcement-form-action-url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label className="text-sm">Dismissible</Label>
|
||||
{/* End-user behavior toggle, not publish state — kept with content fields. */}
|
||||
<div className="col-span-2 flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm">Let users dismiss</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Adds an × users can click to hide the banner.
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={dismissible}
|
||||
onCheckedChange={setDismissible}
|
||||
data-action="announcement-form-dismissible"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<Label className="text-sm">Active</Label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Active = publish state, paired with the publish button. */}
|
||||
<label
|
||||
htmlFor="ann-active"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground sm:mr-auto"
|
||||
>
|
||||
<Switch
|
||||
id="ann-active"
|
||||
checked={active}
|
||||
onCheckedChange={setActive}
|
||||
data-action="announcement-form-active"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span>{active ? "Switched on" : "Switched off (draft)"}</span>
|
||||
</label>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !title.trim() || (audience === "tenant" && !tenantId)}
|
||||
data-action="announcement-form-save"
|
||||
>
|
||||
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
{isEdit ? "Save" : "Post"}
|
||||
</Button>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={saving || !title.trim() || (audience === "tenant" && !tenantId)}
|
||||
data-action="announcement-form-save"
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4" />
|
||||
)}
|
||||
{publishButtonLabel({ isEdit, active, audience, tenantId, tenants })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
44
app/routes/apps.tsx
Normal file
44
app/routes/apps.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// Tenant-scoped "Apps" — placeholder. Real surface is the apps this
|
||||
// tenant publishes (and their per-app users/grants on the personal
|
||||
// cloud side). Wired into the nav so tenant admins see the route they
|
||||
// expect; data layer follows.
|
||||
|
||||
import { LayoutGrid } from "lucide-react"
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
|
||||
export default function AppsRoute() {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutGrid className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Apps</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apps this tenant publishes — and the users that have granted them
|
||||
access to their personal clouds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming soon</CardTitle>
|
||||
<CardDescription>
|
||||
App authoring lives in arcadia-agents-manager today. This view will
|
||||
surface published apps + per-app grants once the catalog endpoint
|
||||
is wired.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
632
app/routes/integrations.tsx
Normal file
632
app/routes/integrations.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
// Integrations (operator) — platform/pooled external-API arrangements across
|
||||
// every scope, backed by the integration registry on arcadia-llm-gateway
|
||||
// (`/api/v1/integrations*`). The operator manages pooled credentials and
|
||||
// inspects cross-tenant usage metadata; secrets are write-only.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plug,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { ArcadiaError } from "@crema/arcadia-client"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import { useGatewayClient } from "~/lib/gateway"
|
||||
import {
|
||||
addCredential,
|
||||
createIntegration,
|
||||
credentialHealth,
|
||||
deleteIntegration,
|
||||
formatUsd,
|
||||
listIntegrations,
|
||||
testIntegration,
|
||||
updateIntegration,
|
||||
usageSummary,
|
||||
type AuthKind,
|
||||
type Integration,
|
||||
type Scope,
|
||||
type UsageEntry,
|
||||
} from "~/lib/arcadia/integrations"
|
||||
|
||||
const AUTH_KINDS: AuthKind[] = ["bearer_static", "api_key_header", "basic", "oauth2"]
|
||||
const SCOPES: Scope[] = ["platform", "tenant", "app", "user", "agent"]
|
||||
const SCOPE_FILTERS: Array<Scope | "all"> = ["all", ...SCOPES]
|
||||
|
||||
type Form = {
|
||||
scope: Scope
|
||||
scope_id: string
|
||||
provider: string
|
||||
capability: string
|
||||
display_name: string
|
||||
unit: string
|
||||
price_usd: string
|
||||
monthly_budget_usd: string
|
||||
secret_name: string
|
||||
auth_kind: AuthKind
|
||||
secret: string
|
||||
pooled: boolean
|
||||
}
|
||||
|
||||
const emptyForm: Form = {
|
||||
scope: "platform",
|
||||
scope_id: "",
|
||||
provider: "",
|
||||
capability: "",
|
||||
display_name: "",
|
||||
unit: "call",
|
||||
price_usd: "",
|
||||
monthly_budget_usd: "",
|
||||
secret_name: "",
|
||||
auth_kind: "bearer_static",
|
||||
secret: "",
|
||||
pooled: true,
|
||||
}
|
||||
|
||||
export default function IntegrationsRoute() {
|
||||
const gw = useGatewayClient()
|
||||
const [items, setItems] = useState<Integration[]>([])
|
||||
const [usage, setUsage] = useState<UsageEntry[]>([])
|
||||
const [scopeFilter, setScopeFilter] = useState<Scope | "all">("all")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<Integration | "new" | null>(null)
|
||||
const [tests, setTests] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
const filter = scopeFilter === "all" ? {} : { scope: scopeFilter }
|
||||
try {
|
||||
const [list, use] = await Promise.all([
|
||||
listIntegrations(gw, filter),
|
||||
usageSummary(gw, filter).catch(() => [] as UsageEntry[]),
|
||||
])
|
||||
setItems(list)
|
||||
setUsage(use)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load integrations.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [gw, scopeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const usageById = useMemo(
|
||||
() => new Map(usage.map((u) => [u.integration_id, u] as const)),
|
||||
[usage],
|
||||
)
|
||||
|
||||
const runTest = useCallback(
|
||||
async (it: Integration) => {
|
||||
setTests((t) => ({ ...t, [it.id]: { ok: true, message: "Testing…" } }))
|
||||
try {
|
||||
const verdict = await testIntegration(gw, it.id)
|
||||
const remaining = verdict.policy?.remaining_budget_usd
|
||||
setTests((t) => ({
|
||||
...t,
|
||||
[it.id]: {
|
||||
ok: true,
|
||||
message:
|
||||
verdict.status === "ok"
|
||||
? `OK — within budget & rate${remaining ? ` (${formatUsd(remaining)} left)` : ""}`
|
||||
: verdict.status,
|
||||
},
|
||||
}))
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof ArcadiaError
|
||||
? e.status === 409
|
||||
? "Credential expired — rotate it"
|
||||
: e.status === 429
|
||||
? "Over budget / rate limit"
|
||||
: e.status === 404
|
||||
? "No credential to test"
|
||||
: e.message
|
||||
: "Test failed"
|
||||
setTests((t) => ({ ...t, [it.id]: { ok: false, message: msg } }))
|
||||
}
|
||||
},
|
||||
[gw],
|
||||
)
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
async (it: Integration, enabled: boolean) => {
|
||||
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled } : x)))
|
||||
try {
|
||||
await updateIntegration(gw, it.id, { enabled })
|
||||
} catch {
|
||||
setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled: !enabled } : x)))
|
||||
}
|
||||
},
|
||||
[gw],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (it: Integration) => {
|
||||
if (!window.confirm(`Delete ${it.display_name || it.provider} and its credentials?`)) return
|
||||
await deleteIntegration(gw, it.id)
|
||||
await refresh()
|
||||
},
|
||||
[gw, refresh],
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Plug className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Integrations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Platform & pooled external-API credentials across every scope.
|
||||
Keys are stored encrypted and never shown; usage is metadata only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={scopeFilter} onValueChange={(v) => setScopeFilter((v as Scope | "all") ?? "all")}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_FILTERS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s === "all" ? "All scopes" : s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> Add integration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Couldn’t load integrations</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No integrations in this scope</CardTitle>
|
||||
<CardDescription>
|
||||
Register a platform/pooled arrangement — a shared key the platform
|
||||
meters and bills to tenants who opt in.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" /> Add integration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{items.map((it) => {
|
||||
const u = usageById.get(it.id)
|
||||
const test = tests[it.id]
|
||||
return (
|
||||
<Card key={it.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{it.display_name || it.provider}
|
||||
<Badge>{it.scope}</Badge>
|
||||
{it.scope_id ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{it.scope_id}
|
||||
</span>
|
||||
) : null}
|
||||
{it.capability ? (
|
||||
<Badge variant="secondary">{it.capability}</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{it.provider}
|
||||
{it.cost_model?.price_usd
|
||||
? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}`
|
||||
: ""}
|
||||
{it.constraints?.monthly_budget_usd
|
||||
? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo`
|
||||
: ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`en-${it.id}`} className="text-xs text-muted-foreground">
|
||||
{it.enabled ? "Enabled" : "Disabled"}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`en-${it.id}`}
|
||||
checked={it.enabled}
|
||||
onCheckedChange={(v) => toggleEnabled(it, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
{it.credentials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No credential set.</p>
|
||||
) : (
|
||||
it.credentials.map((cred) => {
|
||||
const health = credentialHealth(cred)
|
||||
return (
|
||||
<div key={cred.id} className="flex items-center gap-2 text-sm">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
<span className="font-mono">{cred.secret_name}</span>
|
||||
<Badge variant="outline">{cred.source}</Badge>
|
||||
<HealthBadge health={health} />
|
||||
{cred.expires_at ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
expires {new Date(cred.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"}
|
||||
</p>
|
||||
|
||||
{test ? (
|
||||
<p
|
||||
className={`text-sm ${test.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}
|
||||
>
|
||||
{test.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" onClick={() => runTest(it)}>
|
||||
<FlaskConical className="size-4" /> Test
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(it)}>
|
||||
<Pencil className="size-4" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => remove(it)}
|
||||
>
|
||||
<Trash2 className="size-4" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<IntegrationDialog
|
||||
mode={editing === "new" ? "new" : "edit"}
|
||||
initial={editing === "new" ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={async () => {
|
||||
setEditing(null)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthBadge({ health }: { health: ReturnType<typeof credentialHealth> }) {
|
||||
if (health === "ok")
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle2 className="size-3" /> healthy
|
||||
</Badge>
|
||||
)
|
||||
const label = health === "missing" ? "no secret" : health
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="size-3" /> {label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationDialog({
|
||||
mode,
|
||||
initial,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
mode: "new" | "edit"
|
||||
initial: Integration | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const gw = useGatewayClient()
|
||||
const [form, setForm] = useState<Form>(() =>
|
||||
initial
|
||||
? {
|
||||
...emptyForm,
|
||||
scope: initial.scope,
|
||||
scope_id: initial.scope_id ?? "",
|
||||
provider: initial.provider,
|
||||
capability: initial.capability ?? "",
|
||||
display_name: initial.display_name ?? "",
|
||||
unit: initial.cost_model?.unit ?? "call",
|
||||
price_usd: initial.cost_model?.price_usd?.toString() ?? "",
|
||||
monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "",
|
||||
}
|
||||
: emptyForm,
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const set = (patch: Partial<Form>) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const needsScopeId = form.scope !== "platform"
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const cost_model = form.price_usd
|
||||
? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" }
|
||||
: undefined
|
||||
const constraints = form.monthly_budget_usd
|
||||
? { monthly_budget_usd: form.monthly_budget_usd }
|
||||
: undefined
|
||||
|
||||
if (mode === "edit" && initial) {
|
||||
await updateIntegration(gw, initial.id, {
|
||||
provider: form.provider.trim(),
|
||||
capability: form.capability.trim() || undefined,
|
||||
display_name: form.display_name.trim() || undefined,
|
||||
cost_model,
|
||||
constraints,
|
||||
})
|
||||
} else {
|
||||
const created = await createIntegration(gw, {
|
||||
scope: form.scope,
|
||||
scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined,
|
||||
provider: form.provider.trim(),
|
||||
capability: form.capability.trim() || undefined,
|
||||
display_name: form.display_name.trim() || undefined,
|
||||
cost_model,
|
||||
constraints,
|
||||
})
|
||||
if (form.secret_name.trim() && form.secret.trim()) {
|
||||
await addCredential(gw, created.id, {
|
||||
secret_name: form.secret_name.trim(),
|
||||
auth_kind: form.auth_kind,
|
||||
secret: form.secret,
|
||||
source: form.pooled ? "pooled" : "byo",
|
||||
})
|
||||
}
|
||||
}
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Save failed.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => (!o ? onClose() : undefined)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "new" ? "Add integration" : "Edit integration"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Register an external-API arrangement. Platform scope = a pooled key
|
||||
the platform meters and bills.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{mode === "new" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Scope">
|
||||
<Select value={form.scope} onValueChange={(v) => set({ scope: (v as Scope) ?? "platform" })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Scope ID" hint={needsScopeId ? "tenant/app/user/agent id" : "n/a for platform"}>
|
||||
<Input
|
||||
value={form.scope_id}
|
||||
onChange={(e) => set({ scope_id: e.target.value })}
|
||||
disabled={!needsScopeId}
|
||||
placeholder={needsScopeId ? "acme" : "—"}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field label="Provider" hint="e.g. tavily, google_maps, duffel">
|
||||
<Input
|
||||
value={form.provider}
|
||||
onChange={(e) => set({ provider: e.target.value })}
|
||||
placeholder="tavily"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Capability (optional)" hint="e.g. web_search, geocode">
|
||||
<Input
|
||||
value={form.capability}
|
||||
onChange={(e) => set({ capability: e.target.value })}
|
||||
placeholder="web_search"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Display name (optional)">
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => set({ display_name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Price (USD)" hint="per unit, for metering">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={form.price_usd}
|
||||
onChange={(e) => set({ price_usd: e.target.value })}
|
||||
placeholder="0.01"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Unit">
|
||||
<Select value={form.unit} onValueChange={(v) => set({ unit: v ?? "call" })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="call">call</SelectItem>
|
||||
<SelectItem value="search">search</SelectItem>
|
||||
<SelectItem value="1k_tokens">1k_tokens</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Monthly budget (USD, optional)" hint="resolve is refused past this">
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={form.monthly_budget_usd}
|
||||
onChange={(e) => set({ monthly_budget_usd: e.target.value })}
|
||||
placeholder="500"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{mode === "new" ? (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<p className="text-sm font-medium">Credential (optional)</p>
|
||||
<Field label="Secret name" hint="the stable handle tools resolve by">
|
||||
<Input
|
||||
value={form.secret_name}
|
||||
onChange={(e) => set({ secret_name: e.target.value })}
|
||||
placeholder="tavily_default"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Auth kind">
|
||||
<Select
|
||||
value={form.auth_kind}
|
||||
onValueChange={(v) => set({ auth_kind: (v as AuthKind) ?? "bearer_static" })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTH_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{k}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Source">
|
||||
<div className="flex h-9 items-center gap-2">
|
||||
<Switch
|
||||
id="pooled"
|
||||
checked={form.pooled}
|
||||
onCheckedChange={(v) => set({ pooled: v })}
|
||||
/>
|
||||
<Label htmlFor="pooled" className="text-sm">
|
||||
{form.pooled ? "pooled (billed)" : "BYO key"}
|
||||
</Label>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Secret value" hint="stored encrypted, never shown again">
|
||||
<Input
|
||||
type="password"
|
||||
value={form.secret}
|
||||
onChange={(e) => set({ secret: e.target.value })}
|
||||
placeholder="sk-…"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving || !form.provider.trim()}>
|
||||
{saving ? "Saving…" : mode === "new" ? "Create" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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,4 +1,4 @@
|
||||
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 { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||
@@ -26,10 +26,21 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import {
|
||||
activateTenant,
|
||||
deactivateTenant,
|
||||
listTenants,
|
||||
provisionTenant,
|
||||
suspendTenant,
|
||||
type Tenant,
|
||||
type TenantStatus,
|
||||
@@ -54,6 +65,7 @@ export default function TenantsRoute() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<PendingAction>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
@@ -191,7 +203,11 @@ export default function TenantsRoute() {
|
||||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" disabled data-action="tenants-create">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
data-action="tenants-create"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New tenant
|
||||
</Button>
|
||||
@@ -252,6 +268,15 @@ export default function TenantsRoute() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TenantCreateDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={async () => {
|
||||
setCreateOpen(false)
|
||||
await refresh()
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={pending?.kind === "suspend"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
@@ -330,3 +355,218 @@ function rowActions(
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
function formatArcadiaError(err: unknown, fallback: string): string {
|
||||
if (!(err instanceof ArcadiaError)) return fallback
|
||||
// 422 validation errors carry per-field reasons in `details`. Shape from
|
||||
// Ecto's FallbackController is typically `{ field: ["msg1", "msg2"] }` or
|
||||
// nested `{ tenant: { slug: ["has already been taken"] } }`. Flatten so
|
||||
// the user sees what to fix instead of a generic "validation failed".
|
||||
if (err.isValidation && err.details) {
|
||||
const lines: string[] = []
|
||||
const walk = (obj: unknown, prefix: string) => {
|
||||
if (Array.isArray(obj)) {
|
||||
lines.push(`${prefix}: ${obj.join(", ")}`)
|
||||
} else if (obj && typeof obj === "object") {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
walk(v, prefix ? `${prefix}.${k}` : k)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(err.details, "")
|
||||
if (lines.length) return `${err.message} — ${lines.join("; ")}`
|
||||
}
|
||||
return err.message
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
function TenantCreateDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: () => Promise<void> | void
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [name, setName] = useState("")
|
||||
const [slug, setSlug] = useState("")
|
||||
const [slugDirty, setSlugDirty] = useState(false)
|
||||
const [firstName, setFirstName] = useState("")
|
||||
const [lastName, setLastName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName("")
|
||||
setSlug("")
|
||||
setSlugDirty(false)
|
||||
setFirstName("")
|
||||
setLastName("")
|
||||
setEmail("")
|
||||
setPassword("")
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const slugInvalid = slug.length > 0 && !/^[a-z0-9-]+$/.test(slug)
|
||||
const canSubmit =
|
||||
!submitting &&
|
||||
name.trim().length > 0 &&
|
||||
slug.length > 0 &&
|
||||
!slugInvalid &&
|
||||
firstName.trim().length > 0 &&
|
||||
lastName.trim().length > 0 &&
|
||||
email.trim().length > 0 &&
|
||||
password.length >= 8
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await provisionTenant(arcadia, {
|
||||
tenant: { name: name.trim(), slug },
|
||||
admin_user: {
|
||||
email: email.trim(),
|
||||
password,
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
},
|
||||
})
|
||||
await onCreated()
|
||||
} catch (err) {
|
||||
onError(formatArcadiaError(err, "Failed to create tenant."))
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New tenant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Provisions the tenant with default roles, quotas, and an initial admin user.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Tenant name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (!slugDirty) setSlug(slugify(e.target.value))
|
||||
}}
|
||||
placeholder="Acme Corp"
|
||||
autoFocus
|
||||
data-action="tenants-create-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Slug</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlugDirty(true)
|
||||
setSlug(e.target.value)
|
||||
}}
|
||||
placeholder="acme"
|
||||
data-action="tenants-create-slug"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{slugInvalid
|
||||
? "Lowercase letters, digits, and hyphens only."
|
||||
: "Lowercase letters, digits, and hyphens. Used in URLs and the X-Tenant-ID header."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-first-name">Admin first name</Label>
|
||||
<Input
|
||||
id="tenant-admin-first-name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="Jane"
|
||||
data-action="tenants-create-admin-first-name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-last-name">Admin last name</Label>
|
||||
<Input
|
||||
id="tenant-admin-last-name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder="Doe"
|
||||
data-action="tenants-create-admin-last-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-email">Admin email</Label>
|
||||
<Input
|
||||
id="tenant-admin-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@acme.com"
|
||||
data-action="tenants-create-admin-email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-admin-password">Admin password</Label>
|
||||
<Input
|
||||
id="tenant-admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
data-action="tenants-create-admin-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
data-action="tenants-create-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
data-action="tenants-create-submit"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create tenant"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"@crema/action-bus/*": ["../lib-action-bus/src/*"],
|
||||
"@crema/arcadia-client": ["../lib-arcadia-client/src/index.tsx"],
|
||||
"@crema/arcadia-client/*": ["../lib-arcadia-client/src/*"],
|
||||
"@crema/integration-registry-client": ["../lib-integration-registry-client/src/index.tsx"],
|
||||
"@crema/integration-registry-client/*": ["../lib-integration-registry-client/src/*"],
|
||||
"@crema/arcadia-auth-ui": ["../lib-arcadia-auth-ui/src/index.tsx"],
|
||||
"@crema/arcadia-auth-ui/*": ["../lib-arcadia-auth-ui/src/*"],
|
||||
"@crema/table-ui": ["../lib-table-ui/src/index.tsx"],
|
||||
|
||||
111
vite.config.ts
111
vite.config.ts
@@ -62,6 +62,9 @@ const searchUiSrc = fileURLToPath(
|
||||
const arcadiaClientSrc = fileURLToPath(
|
||||
new URL("../lib-arcadia-client/src", import.meta.url),
|
||||
)
|
||||
const integrationRegistryClientSrc = fileURLToPath(
|
||||
new URL("../lib-integration-registry-client/src", import.meta.url),
|
||||
)
|
||||
const arcadiaAuthUiSrc = fileURLToPath(
|
||||
new URL("../lib-arcadia-auth-ui/src", import.meta.url),
|
||||
)
|
||||
@@ -86,6 +89,24 @@ const chartUiSrc = fileURLToPath(
|
||||
const statusUiSrc = fileURLToPath(
|
||||
new URL("../lib-status-ui/src", import.meta.url),
|
||||
)
|
||||
const actionBusSrc = fileURLToPath(
|
||||
new URL("../lib-action-bus/src", import.meta.url),
|
||||
)
|
||||
const agentUiSrc = fileURLToPath(
|
||||
new URL("../lib-agent-ui/src", import.meta.url),
|
||||
)
|
||||
const aifirstUiSrc = fileURLToPath(
|
||||
new URL("../lib-aifirst-ui/src", import.meta.url),
|
||||
)
|
||||
const lexicalRagUiSrc = fileURLToPath(
|
||||
new URL("../lib-lexical-rag-ui/src", import.meta.url),
|
||||
)
|
||||
const notificationUiSrc = fileURLToPath(
|
||||
new URL("../lib-notification-ui/src", import.meta.url),
|
||||
)
|
||||
const onboardingUiSrc = fileURLToPath(
|
||||
new URL("../lib-onboarding-ui/src", import.meta.url),
|
||||
)
|
||||
|
||||
// Sibling lib packages (lib-content-ui, lib-content-editor-ui) import bare
|
||||
// deps like clsx and @tiptap/* but have no node_modules of their own. Pin
|
||||
@@ -105,6 +126,8 @@ const aliasedDeps = [
|
||||
"@tiptap/extension-placeholder",
|
||||
"@tiptap/extension-image",
|
||||
"minisearch",
|
||||
"react-markdown",
|
||||
"remark-gfm",
|
||||
]
|
||||
const sharedDepAliases = Object.fromEntries(
|
||||
aliasedDeps.map((name) => [name, resolvePath(nodeModules, name)]),
|
||||
@@ -119,36 +142,64 @@ const dedupeDeps = [
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@crema/content-ui": `${contentUiSrc}/index.ts`,
|
||||
"@crema/content-editor-ui": `${contentEditorUiSrc}/index.ts`,
|
||||
"@crema/content-media-ui": `${contentMediaUiSrc}/index.tsx`,
|
||||
"@crema/color-ui": `${colorUiSrc}/index.tsx`,
|
||||
"@crema/typography-ui": `${typographyUiSrc}/index.tsx`,
|
||||
"@crema/data-ui": `${dataUiSrc}/index.tsx`,
|
||||
"@crema/layout-ui": `${layoutUiSrc}/index.tsx`,
|
||||
"@crema/map-ui": `${mapUiSrc}/index.tsx`,
|
||||
"@crema/form-ui": `${formUiSrc}/index.tsx`,
|
||||
"@crema/feedback-ui": `${feedbackUiSrc}/index.tsx`,
|
||||
"@crema/diagram-ui": `${diagramUiSrc}/index.tsx`,
|
||||
"@crema/chat-ui": `${chatUiSrc}/index.tsx`,
|
||||
"@crema/calendar-ui": `${calendarUiSrc}/index.tsx`,
|
||||
"@crema/code-ui": `${codeUiSrc}/index.tsx`,
|
||||
"@crema/ai-ui": `${aiUiSrc}/index.tsx`,
|
||||
"@crema/auth-ui": `${authUiSrc}/index.tsx`,
|
||||
"@crema/table-ui": `${tableUiSrc}/index.tsx`,
|
||||
"@crema/search-ui": `${searchUiSrc}/index.tsx`,
|
||||
"@crema/arcadia-client": `${arcadiaClientSrc}/index.tsx`,
|
||||
"@crema/arcadia-auth-ui": `${arcadiaAuthUiSrc}/index.tsx`,
|
||||
"@crema/llm-ui": `${llmUiSrc}/index.tsx`,
|
||||
"@crema/llm-providers-ui": `${llmProvidersUiSrc}/index.tsx`,
|
||||
"@crema/file-ui": `${fileUiSrc}/index.tsx`,
|
||||
"@crema/card-ui": `${cardUiSrc}/index.tsx`,
|
||||
"@crema/dashboard-ui": `${dashboardUiSrc}/index.tsx`,
|
||||
"@crema/chart-ui": `${chartUiSrc}/index.tsx`,
|
||||
"@crema/status-ui": `${statusUiSrc}/index.tsx`,
|
||||
...sharedDepAliases,
|
||||
},
|
||||
// Array form so we can express both the bare-specifier alias
|
||||
// (`@crema/agent-ui` -> src/index.tsx) and a subpath prefix alias
|
||||
// (`@crema/agent-ui/chat` -> src/chat) for libs whose sibling-lib
|
||||
// code reaches into deeper modules. Prefix entries with regex `find`
|
||||
// are matched first.
|
||||
alias: [
|
||||
// Subpath prefixes — longest first so they win before the bare match.
|
||||
{ find: /^@crema\/agent-ui\//, replacement: `${agentUiSrc}/` },
|
||||
{ find: /^@crema\/aifirst-ui\//, replacement: `${aifirstUiSrc}/` },
|
||||
{ find: /^@crema\/notification-ui\//, replacement: `${notificationUiSrc}/` },
|
||||
{ find: /^@crema\/onboarding-ui\//, replacement: `${onboardingUiSrc}/` },
|
||||
{ find: /^@crema\/lexical-rag-ui\//, replacement: `${lexicalRagUiSrc}/` },
|
||||
{ find: /^@crema\/action-bus\//, replacement: `${actionBusSrc}/` },
|
||||
|
||||
// Bare-specifier exact matches.
|
||||
{ find: "@crema/content-ui", replacement: `${contentUiSrc}/index.ts` },
|
||||
{ find: "@crema/content-editor-ui", replacement: `${contentEditorUiSrc}/index.ts` },
|
||||
{ find: "@crema/content-media-ui", replacement: `${contentMediaUiSrc}/index.tsx` },
|
||||
{ find: "@crema/color-ui", replacement: `${colorUiSrc}/index.tsx` },
|
||||
{ find: "@crema/typography-ui", replacement: `${typographyUiSrc}/index.tsx` },
|
||||
{ find: "@crema/data-ui", replacement: `${dataUiSrc}/index.tsx` },
|
||||
{ find: "@crema/layout-ui", replacement: `${layoutUiSrc}/index.tsx` },
|
||||
{ find: "@crema/map-ui", replacement: `${mapUiSrc}/index.tsx` },
|
||||
{ find: "@crema/form-ui", replacement: `${formUiSrc}/index.tsx` },
|
||||
{ find: "@crema/feedback-ui", replacement: `${feedbackUiSrc}/index.tsx` },
|
||||
{ find: "@crema/diagram-ui", replacement: `${diagramUiSrc}/index.tsx` },
|
||||
{ find: "@crema/chat-ui", replacement: `${chatUiSrc}/index.tsx` },
|
||||
{ find: "@crema/calendar-ui", replacement: `${calendarUiSrc}/index.tsx` },
|
||||
{ find: "@crema/code-ui", replacement: `${codeUiSrc}/index.tsx` },
|
||||
{ find: "@crema/ai-ui", replacement: `${aiUiSrc}/index.tsx` },
|
||||
{ find: "@crema/auth-ui", replacement: `${authUiSrc}/index.tsx` },
|
||||
{ find: "@crema/table-ui", replacement: `${tableUiSrc}/index.tsx` },
|
||||
{ find: "@crema/search-ui", replacement: `${searchUiSrc}/index.tsx` },
|
||||
{ find: "@crema/arcadia-client", replacement: `${arcadiaClientSrc}/index.tsx` },
|
||||
{
|
||||
find: "@crema/integration-registry-client",
|
||||
replacement: `${integrationRegistryClientSrc}/index.tsx`,
|
||||
},
|
||||
{ find: "@crema/arcadia-auth-ui", replacement: `${arcadiaAuthUiSrc}/index.tsx` },
|
||||
{ find: "@crema/llm-ui", replacement: `${llmUiSrc}/index.tsx` },
|
||||
{ find: "@crema/llm-providers-ui", replacement: `${llmProvidersUiSrc}/index.tsx` },
|
||||
{ find: "@crema/file-ui", replacement: `${fileUiSrc}/index.tsx` },
|
||||
{ find: "@crema/card-ui", replacement: `${cardUiSrc}/index.tsx` },
|
||||
{ find: "@crema/dashboard-ui", replacement: `${dashboardUiSrc}/index.tsx` },
|
||||
{ find: "@crema/chart-ui", replacement: `${chartUiSrc}/index.tsx` },
|
||||
{ find: "@crema/status-ui", replacement: `${statusUiSrc}/index.tsx` },
|
||||
{ find: "@crema/action-bus", replacement: `${actionBusSrc}/index.tsx` },
|
||||
{ find: "@crema/agent-ui", replacement: `${agentUiSrc}/index.tsx` },
|
||||
{ find: "@crema/aifirst-ui", replacement: `${aifirstUiSrc}/index.tsx` },
|
||||
{ find: "@crema/lexical-rag-ui", replacement: `${lexicalRagUiSrc}/index.tsx` },
|
||||
{ find: "@crema/notification-ui", replacement: `${notificationUiSrc}/index.tsx` },
|
||||
{ find: "@crema/onboarding-ui", replacement: `${onboardingUiSrc}/index.tsx` },
|
||||
|
||||
...Object.entries(sharedDepAliases).map(([find, replacement]) => ({
|
||||
find,
|
||||
replacement,
|
||||
})),
|
||||
],
|
||||
dedupe: dedupeDeps,
|
||||
},
|
||||
// Pre-bundle deps that sibling libs reach for. Without this, Vite
|
||||
|
||||
Reference in New Issue
Block a user