Operator Integrations page + capability framework (P3 UI) #1
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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`)
|
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
|
||||||
|
}
|
||||||
|
|||||||
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 { 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,24 +284,39 @@ 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>
|
||||||
|
{items.length > 0 ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEditor({ kind: "create" })}
|
onClick={() => setEditor({ kind: "create" })}
|
||||||
@@ -260,6 +325,7 @@ export default function AnnouncementsRoute() {
|
|||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New announcement
|
New announcement
|
||||||
</Button>
|
</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">
|
||||||
|
{search && table.total !== items.length
|
||||||
|
? `${table.total} of ${items.length}`
|
||||||
|
: `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
|
||||||
</div>
|
</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,8 +734,17 @@ function AnnouncementEditorDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Optional link group — heading clarifies these two are paired. */}
|
||||||
|
<div className="col-span-2 flex flex-col gap-2 rounded-md border border-dashed p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<Label className="text-sm">Add a link</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">Optional</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="ann-action-label">Action label (optional)</Label>
|
<Label htmlFor="ann-action-label" className="text-xs text-muted-foreground">
|
||||||
|
Button text
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="ann-action-label"
|
id="ann-action-label"
|
||||||
value={actionLabel}
|
value={actionLabel}
|
||||||
@@ -585,7 +754,9 @@ function AnnouncementEditorDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="ann-action-url">Action URL (optional)</Label>
|
<Label htmlFor="ann-action-url" className="text-xs text-muted-foreground">
|
||||||
|
Where it goes
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="ann-action-url"
|
id="ann-action-url"
|
||||||
value={actionUrl}
|
value={actionUrl}
|
||||||
@@ -594,26 +765,41 @@ function AnnouncementEditorDialog({
|
|||||||
data-action="announcement-form-action-url"
|
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>
|
||||||
@@ -622,9 +808,14 @@ function AnnouncementEditorDialog({
|
|||||||
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" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
)}
|
||||||
|
{publishButtonLabel({ isEdit, active, audience, tenantId, tenants })}
|
||||||
</Button>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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 { Pause, Play, Plus, RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
"@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-client": ["../lib-arcadia-client/src/index.tsx"],
|
||||||
"@crema/arcadia-client/*": ["../lib-arcadia-client/src/*"],
|
"@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/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"],
|
||||||
|
|||||||
109
vite.config.ts
109
vite.config.ts
@@ -62,6 +62,9 @@ const searchUiSrc = fileURLToPath(
|
|||||||
const arcadiaClientSrc = fileURLToPath(
|
const arcadiaClientSrc = fileURLToPath(
|
||||||
new URL("../lib-arcadia-client/src", import.meta.url),
|
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(
|
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-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