4 Commits

Author SHA1 Message Date
jules
ea34bcd886 fix(auth): reject expired JWT on session read (silent-401 shell)
readFromStorage validated token shape but never checked exp, so an expired
token mounted the full authed shell and every API call 401d silently. Decode
the JWT and treat an expired token as no session. Pattern backported from
skyai-finance. Frontend audit 2026-06-20, rank 1.

Also clears the localStorage Session in onUnauthorized (root.tsx) so a 401
fully logs out instead of leaving a dead session behind getToken.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:24:20 +10:00
6ab9b730f5 Merge pull request 'Operator Integrations page + capability framework (P3 UI)' (#1) from feat/integration-registry into main 2026-06-09 13:15:15 +00:00
jules
4b817b85ff Wire operator Integrations page + capability-gating framework
Completes the arcadia-admin operator surface for the integration registry and
the capability/route-guard framework it depends on.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:14:13 +10:00
18 changed files with 1895 additions and 147 deletions

View File

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

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

View File

@@ -0,0 +1,40 @@
// Integration-registry client (operator surface) — thin shim over the shared
// `@crema/integration-registry-client` lib, bound to `operator` mode. The lib
// owns the types, the HTTP contract, and the display helpers (shared with
// arcadia-console's tenant surface); this file just exposes operator-idiomatic
// names so the page reads naturally.
import type { ArcadiaClient } from "@crema/arcadia-client"
import {
createIntegrationsApi,
type CredentialInput,
type IntegrationInput,
type ScopeFilter,
} from "@crema/integration-registry-client"
// Re-export the shared types + helpers so callers import from one place.
export * from "@crema/integration-registry-client"
const op = (c: ArcadiaClient) => createIntegrationsApi(c, "operator")
export const listIntegrations = (c: ArcadiaClient, filter: ScopeFilter = {}) =>
op(c).list(filter)
export const createIntegration = (c: ArcadiaClient, input: IntegrationInput) =>
op(c).create(input)
export const updateIntegration = (
c: ArcadiaClient,
id: string,
input: Partial<IntegrationInput>,
) => op(c).update(id, input)
export const deleteIntegration = (c: ArcadiaClient, id: string) => op(c).remove(id)
export const addCredential = (c: ArcadiaClient, integrationId: string, input: CredentialInput) =>
op(c).addCredential(integrationId, input)
export const updateCredential = (
c: ArcadiaClient,
credentialId: string,
input: Partial<CredentialInput>,
) => op(c).updateCredential(credentialId, input)
export const deleteCredential = (c: ArcadiaClient, credentialId: string) =>
op(c).deleteCredential(credentialId)
export const testIntegration = (c: ArcadiaClient, id: string) => op(c).test(id)
export const usageSummary = (c: ArcadiaClient, filter: ScopeFilter = {}) => op(c).usage(filter)

View File

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

@@ -0,0 +1,49 @@
// Tiny JWT helpers — we never *verify* tokens client-side (the server
// is the only authority), we just decode the payload to read claims
// the UI uses for nav gating + tenant context.
export type ArcadiaClaims = {
sub?: string
email?: string
tenant_id?: string
tenant_slug?: string
roles?: string[]
available_tenants?: AvailableTenantClaim[]
exp?: number
iat?: number
[k: string]: unknown
}
export type AvailableTenantClaim = {
id?: string
slug?: string
name?: string
roles?: string[]
}
function b64urlDecode(s: string): string {
const pad = "=".repeat((4 - (s.length % 4)) % 4)
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/")
if (typeof atob === "function") return atob(b64)
// Node fallback (SSR / tests)
return Buffer.from(b64, "base64").toString("binary")
}
export function decodeJwt(token: string): ArcadiaClaims | null {
if (!token) return null
const parts = token.split(".")
if (parts.length !== 3) return null
try {
const raw = b64urlDecode(parts[1])
// Handle UTF-8: atob returns binary string; reconstruct UTF-8.
const utf8 =
typeof TextDecoder !== "undefined"
? new TextDecoder().decode(
Uint8Array.from(raw, (c) => c.charCodeAt(0)),
)
: raw
return JSON.parse(utf8) as ArcadiaClaims
} catch {
return null
}
}

View File

@@ -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"
@@ -31,6 +44,10 @@ function readFromStorage(): Session | null {
typeof parsed.token !== "string" typeof parsed.token !== "string"
) )
return null return null
// An expired JWT is not a session: without this the shell renders as
// "logged in" and every API call 401s silently. Treat it as null so the
// app bounces to /login. (Frontend audit 2026-06-20.)
if (isTokenExpired(parsed.token)) return null
return { return {
userId: parsed.userId, userId: parsed.userId,
name: name:
@@ -41,6 +58,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
@@ -51,6 +80,35 @@ export function loadSession(): Session | null {
return readFromStorage() return readFromStorage()
} }
// A token counts as expired only if it's a JWT carrying an `exp` in the past
// (minus a small clock-skew grace). Non-JWT dev/mock tokens (no decodable
// `exp`) are treated as non-expiring so offline/test flows keep working.
const TOKEN_EXPIRY_SKEW_S = 30
export function isTokenExpired(token: string | undefined | null): boolean {
if (!token) return true
const claims = decodeJwtPayload(token)
const exp =
claims && typeof claims.exp === "number" ? (claims.exp as number) : null
if (exp === null) return false
return Date.now() / 1000 >= exp - TOKEN_EXPIRY_SKEW_S
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".")
if (parts.length !== 3) return null
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/")
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4))
const json =
typeof atob === "function"
? atob(b64 + pad)
: Buffer.from(b64 + pad, "base64").toString("utf-8")
return JSON.parse(json) as Record<string, unknown>
} catch {
return null
}
}
export function signOut() { export function signOut() {
if (typeof window === "undefined") return if (typeof window === "undefined") return
localStorage.removeItem(STORAGE_KEY) localStorage.removeItem(STORAGE_KEY)
@@ -72,12 +130,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)

View File

@@ -15,6 +15,7 @@ import { CommandBusProvider } from "@crema/action-bus"
import { ArcadiaProvider } from "@crema/arcadia-client" import { ArcadiaProvider } from "@crema/arcadia-client"
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap" import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
import { ProfileBootstrap } from "~/lib/profile-bootstrap" import { ProfileBootstrap } from "~/lib/profile-bootstrap"
import { signOut } from "~/lib/session"
// CREMA:PROVIDERS-IMPORTS // CREMA:PROVIDERS-IMPORTS
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000" const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
@@ -59,6 +60,10 @@ export default function App() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
sessionStorage.removeItem("arcadia_access_token") sessionStorage.removeItem("arcadia_access_token")
sessionStorage.removeItem("arcadia_refresh_token") sessionStorage.removeItem("arcadia_refresh_token")
// Also clear the localStorage Session (crema.session); otherwise
// useSession() still reports "logged in" after a 401 and the shell
// keeps mounting with a dead token. (Frontend audit 2026-06-20.)
signOut()
} }
}} }}
> >

View File

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

View File

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

View 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
View 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 &amp; pooled external-API credentials across every scope.
Keys are stored encrypted and never shown; usage is metadata only.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Select value={scopeFilter} onValueChange={(v) => setScopeFilter((v as Scope | "all") ?? "all")}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_FILTERS.map((s) => (
<SelectItem key={s} value={s}>
{s === "all" ? "All scopes" : s}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setEditing("new")}>
<Plus className="size-4" /> Add integration
</Button>
</div>
</div>
{error ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive">Couldnt load integrations</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
) : loading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : items.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>No integrations in this scope</CardTitle>
<CardDescription>
Register a platform/pooled arrangement a shared key the platform
meters and bills to tenants who opt in.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setEditing("new")}>
<Plus className="size-4" /> Add integration
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{items.map((it) => {
const u = usageById.get(it.id)
const test = tests[it.id]
return (
<Card key={it.id}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
{it.display_name || it.provider}
<Badge>{it.scope}</Badge>
{it.scope_id ? (
<span className="font-mono text-xs text-muted-foreground">
{it.scope_id}
</span>
) : null}
{it.capability ? (
<Badge variant="secondary">{it.capability}</Badge>
) : null}
</CardTitle>
<CardDescription>
{it.provider}
{it.cost_model?.price_usd
? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}`
: ""}
{it.constraints?.monthly_budget_usd
? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo`
: ""}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`en-${it.id}`} className="text-xs text-muted-foreground">
{it.enabled ? "Enabled" : "Disabled"}
</Label>
<Switch
id={`en-${it.id}`}
checked={it.enabled}
onCheckedChange={(v) => toggleEnabled(it, v)}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
{it.credentials.length === 0 ? (
<p className="text-sm text-muted-foreground">No credential set.</p>
) : (
it.credentials.map((cred) => {
const health = credentialHealth(cred)
return (
<div key={cred.id} className="flex items-center gap-2 text-sm">
<KeyRound className="size-4 text-muted-foreground" />
<span className="font-mono">{cred.secret_name}</span>
<Badge variant="outline">{cred.source}</Badge>
<HealthBadge health={health} />
{cred.expires_at ? (
<span className="text-xs text-muted-foreground">
expires {new Date(cred.expires_at).toLocaleDateString()}
</span>
) : null}
</div>
)
})
)}
</div>
<p className="text-sm text-muted-foreground">
{u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"}
</p>
{test ? (
<p
className={`text-sm ${test.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}
>
{test.message}
</p>
) : null}
<div className="flex flex-wrap gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => runTest(it)}>
<FlaskConical className="size-4" /> Test
</Button>
<Button variant="outline" size="sm" onClick={() => setEditing(it)}>
<Pencil className="size-4" /> Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => remove(it)}
>
<Trash2 className="size-4" /> Delete
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{editing ? (
<IntegrationDialog
mode={editing === "new" ? "new" : "edit"}
initial={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
onSaved={async () => {
setEditing(null)
await refresh()
}}
/>
) : null}
</AppShell>
)
}
function HealthBadge({ health }: { health: ReturnType<typeof credentialHealth> }) {
if (health === "ok")
return (
<Badge variant="secondary" className="gap-1">
<CheckCircle2 className="size-3" /> healthy
</Badge>
)
const label = health === "missing" ? "no secret" : health
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="size-3" /> {label}
</Badge>
)
}
function IntegrationDialog({
mode,
initial,
onClose,
onSaved,
}: {
mode: "new" | "edit"
initial: Integration | null
onClose: () => void
onSaved: () => void | Promise<void>
}) {
const gw = useGatewayClient()
const [form, setForm] = useState<Form>(() =>
initial
? {
...emptyForm,
scope: initial.scope,
scope_id: initial.scope_id ?? "",
provider: initial.provider,
capability: initial.capability ?? "",
display_name: initial.display_name ?? "",
unit: initial.cost_model?.unit ?? "call",
price_usd: initial.cost_model?.price_usd?.toString() ?? "",
monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "",
}
: emptyForm,
)
const [saving, setSaving] = useState(false)
const [err, setErr] = useState<string | null>(null)
const set = (patch: Partial<Form>) => setForm((f) => ({ ...f, ...patch }))
const needsScopeId = form.scope !== "platform"
const submit = async () => {
setSaving(true)
setErr(null)
try {
const cost_model = form.price_usd
? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" }
: undefined
const constraints = form.monthly_budget_usd
? { monthly_budget_usd: form.monthly_budget_usd }
: undefined
if (mode === "edit" && initial) {
await updateIntegration(gw, initial.id, {
provider: form.provider.trim(),
capability: form.capability.trim() || undefined,
display_name: form.display_name.trim() || undefined,
cost_model,
constraints,
})
} else {
const created = await createIntegration(gw, {
scope: form.scope,
scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined,
provider: form.provider.trim(),
capability: form.capability.trim() || undefined,
display_name: form.display_name.trim() || undefined,
cost_model,
constraints,
})
if (form.secret_name.trim() && form.secret.trim()) {
await addCredential(gw, created.id, {
secret_name: form.secret_name.trim(),
auth_kind: form.auth_kind,
secret: form.secret,
source: form.pooled ? "pooled" : "byo",
})
}
}
await onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : "Save failed.")
} finally {
setSaving(false)
}
}
return (
<Dialog open onOpenChange={(o) => (!o ? onClose() : undefined)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{mode === "new" ? "Add integration" : "Edit integration"}</DialogTitle>
<DialogDescription>
Register an external-API arrangement. Platform scope = a pooled key
the platform meters and bills.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
{mode === "new" ? (
<div className="grid grid-cols-2 gap-3">
<Field label="Scope">
<Select value={form.scope} onValueChange={(v) => set({ scope: (v as Scope) ?? "platform" })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Scope ID" hint={needsScopeId ? "tenant/app/user/agent id" : "n/a for platform"}>
<Input
value={form.scope_id}
onChange={(e) => set({ scope_id: e.target.value })}
disabled={!needsScopeId}
placeholder={needsScopeId ? "acme" : "—"}
/>
</Field>
</div>
) : null}
<Field label="Provider" hint="e.g. tavily, google_maps, duffel">
<Input
value={form.provider}
onChange={(e) => set({ provider: e.target.value })}
placeholder="tavily"
/>
</Field>
<Field label="Capability (optional)" hint="e.g. web_search, geocode">
<Input
value={form.capability}
onChange={(e) => set({ capability: e.target.value })}
placeholder="web_search"
/>
</Field>
<Field label="Display name (optional)">
<Input
value={form.display_name}
onChange={(e) => set({ display_name: e.target.value })}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Price (USD)" hint="per unit, for metering">
<Input
inputMode="decimal"
value={form.price_usd}
onChange={(e) => set({ price_usd: e.target.value })}
placeholder="0.01"
/>
</Field>
<Field label="Unit">
<Select value={form.unit} onValueChange={(v) => set({ unit: v ?? "call" })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="call">call</SelectItem>
<SelectItem value="search">search</SelectItem>
<SelectItem value="1k_tokens">1k_tokens</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Field label="Monthly budget (USD, optional)" hint="resolve is refused past this">
<Input
inputMode="decimal"
value={form.monthly_budget_usd}
onChange={(e) => set({ monthly_budget_usd: e.target.value })}
placeholder="500"
/>
</Field>
{mode === "new" ? (
<div className="space-y-3 rounded-lg border p-3">
<p className="text-sm font-medium">Credential (optional)</p>
<Field label="Secret name" hint="the stable handle tools resolve by">
<Input
value={form.secret_name}
onChange={(e) => set({ secret_name: e.target.value })}
placeholder="tavily_default"
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Auth kind">
<Select
value={form.auth_kind}
onValueChange={(v) => set({ auth_kind: (v as AuthKind) ?? "bearer_static" })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTH_KINDS.map((k) => (
<SelectItem key={k} value={k}>
{k}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Source">
<div className="flex h-9 items-center gap-2">
<Switch
id="pooled"
checked={form.pooled}
onCheckedChange={(v) => set({ pooled: v })}
/>
<Label htmlFor="pooled" className="text-sm">
{form.pooled ? "pooled (billed)" : "BYO key"}
</Label>
</div>
</Field>
</div>
<Field label="Secret value" hint="stored encrypted, never shown again">
<Input
type="password"
value={form.secret}
onChange={(e) => set({ secret: e.target.value })}
placeholder="sk-…"
/>
</Field>
</div>
) : null}
{err ? <p className="text-sm text-destructive">{err}</p> : null}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving || !form.provider.trim()}>
{saving ? "Saving…" : mode === "new" ? "Create" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="grid gap-1.5">
<Label>{label}</Label>
{children}
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
)
}

40
app/routes/plan.tsx Normal file
View 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>
)
}

View File

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

View File

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

View File

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