diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index c3a2d7a..240eb15 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -7,6 +7,7 @@ import { LayoutDashboard, Boxes, Activity, + Building2, Sparkles, Bot, BookOpen, @@ -86,11 +87,8 @@ type NavItem = { const navItems: NavItem[] = [ { to: "/", icon: LayoutDashboard, label: "Overview", end: true }, - { to: "/resources", icon: Boxes, label: "Resources" }, - { to: "/activity", icon: Activity, label: "Activity" }, - { to: "/assistant", icon: Sparkles, label: "Assistant" }, - { to: "/ai", icon: Bot, label: "AI" }, - { to: "/library", icon: BookOpen, label: "Library" }, + { to: "/tenants", icon: Building2, label: "Tenants" }, + { to: "/activity", icon: Activity, label: "Audit log" }, { to: "/settings", icon: Settings, label: "Settings" }, // CREMA:NAV-ITEMS ] diff --git a/app/lib/arcadia/tenants.ts b/app/lib/arcadia/tenants.ts new file mode 100644 index 0000000..66d0b00 --- /dev/null +++ b/app/lib/arcadia/tenants.ts @@ -0,0 +1,93 @@ +// Arcadia tenants API helpers. +// +// Hand-rolled because /api/v1/admin/tenants isn't covered by arcadia's +// OpenAPI spec (controller hasn't been wired into OpenApiSpex yet — same +// "ok"-placeholder issue as some other admin endpoints). When the spec +// gains coverage, switch to `arcadia.typed.GET("/api/v1/admin/tenants", ...)` +// and drop these manual types. + +import type { ArcadiaClient } from "@crema/arcadia-client" + +export type TenantStatus = "active" | "suspended" | "deactivated" | string + +export interface TenantPlan { + name: string + limits: Record +} + +export interface TenantBranding { + logo_url: string | null + favicon_url: string | null + primary_color: string | null + secondary_color: string | null + accent_color: string | null + custom_css: string | null + settings: Record +} + +export interface TenantSettings { + timezone?: string + currency?: string + [key: string]: unknown +} + +export interface TenantLocalization { + locale: string + timezone: string + currency: string + settings: Record +} + +export interface Tenant { + id: string + slug: string + name: string + status: TenantStatus + plan: TenantPlan + branding: TenantBranding + settings: TenantSettings + localization: TenantLocalization + email_settings: Record + notification_settings: Record + metadata: Record + inserted_at: string + updated_at: string +} + +export interface TenantListParams { + q?: string + status?: TenantStatus + page?: number + per_page?: number +} + +export async function listTenants( + arcadia: ArcadiaClient, + params?: TenantListParams, +): Promise { + const queryParams: Record | undefined = params + ? { q: params.q, status: params.status, page: params.page, per_page: params.per_page } + : undefined + const res = await arcadia.GET<{ data: Tenant[] }>("/api/v1/admin/tenants", { params: queryParams }) + return res.data +} + +export async function getTenant(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.GET<{ data: Tenant }>(`/api/v1/admin/tenants/${id}`) + return res.data +} + +export async function suspendTenant(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/suspend`) + return res.data +} + +export async function activateTenant(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/activate`) + return res.data +} + +export async function deactivateTenant(arcadia: ArcadiaClient, id: string): Promise { + const res = await arcadia.POST<{ data: Tenant }>(`/api/v1/admin/tenants/${id}/deactivate`) + return res.data +} diff --git a/app/routes.ts b/app/routes.ts index 17f1068..4c93f24 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -10,5 +10,6 @@ export default [ route("settings", "routes/settings.tsx"), route("profile", "routes/profile.tsx"), route("login", "routes/login.tsx"), + route("tenants", "routes/tenants.tsx"), // CREMA:ROUTES ] satisfies RouteConfig diff --git a/app/routes/tenants.tsx b/app/routes/tenants.tsx new file mode 100644 index 0000000..32b67b7 --- /dev/null +++ b/app/routes/tenants.tsx @@ -0,0 +1,276 @@ +import { useCallback, useEffect, useMemo, useState } from "react" +import { Link } from "react-router" +import { AlertTriangle, Loader2, MoreHorizontal, Pause, Play, Plus, RefreshCw, Search, X } from "lucide-react" + +import { ArcadiaError, useArcadiaClient } 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu" +import { Input } from "~/components/ui/input" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table" +import { activateTenant, deactivateTenant, listTenants, suspendTenant, type Tenant, type TenantStatus } from "~/lib/arcadia/tenants" +import { pageTitle } from "~/lib/page-meta" +import { useSession } from "~/lib/session" + +export const meta = () => pageTitle("Tenants") + +export default function TenantsRoute() { + const session = useSession() + const arcadia = useArcadiaClient() + + const [tenants, setTenants] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [query, setQuery] = useState("") + + const refresh = useCallback(async () => { + setError(null) + setLoading(true) + try { + const list = await listTenants(arcadia) + setTenants(list) + } catch (err) { + setError(err instanceof ArcadiaError ? err.message : "Failed to load tenants.") + } finally { + setLoading(false) + } + }, [arcadia]) + + useEffect(() => { + if (session) refresh() + }, [session, refresh]) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return tenants + return tenants.filter( + (t) => + t.name.toLowerCase().includes(q) || + t.slug.toLowerCase().includes(q) || + t.id.toLowerCase().includes(q), + ) + }, [tenants, query]) + + if (!session) { + return ( + +
+ + + Sign in required + + Tenant administration requires an admin session. + + + + + + +
+
+ ) + } + + return ( + +
+
+
+

Tenants

+

+ Multi-tenant workspaces on this arcadia deployment. +

+
+
+ + +
+
+ + + +
+ + setQuery(e.target.value)} + className="pl-9" + /> + {query ? ( + + ) : null} +
+
+ {filtered.length} of {tenants.length} +
+
+ + + {error ? ( +
+ + {error} +
+ ) : null} + + {loading && tenants.length === 0 ? ( +
+ + Loading tenants… +
+ ) : filtered.length === 0 ? ( +
+ {query ? "No tenants match that search." : "No tenants yet."} +
+ ) : ( + + + + Name + Slug + Status + Plan + Created + + + + + {filtered.map((t) => ( + + ))} + +
+ )} +
+
+
+
+ ) +} + +function TenantRow({ tenant, onChange }: { tenant: Tenant; onChange: () => void | Promise }) { + const arcadia = useArcadiaClient() + const [busy, setBusy] = useState(false) + + const act = async (fn: () => Promise) => { + setBusy(true) + try { + await fn() + await onChange() + } finally { + setBusy(false) + } + } + + return ( + + {tenant.name} + + {tenant.slug} + + + + + + {tenant.plan?.name ?? "—"} + + + {formatDate(tenant.inserted_at)} + + + + + + + + {tenant.status === "active" ? ( + act(() => suspendTenant(arcadia, tenant.id))}> + + Suspend + + ) : ( + act(() => activateTenant(arcadia, tenant.id))}> + + Activate + + )} + + act(() => deactivateTenant(arcadia, tenant.id))}> + Deactivate + + + + + + ) +} + +function StatusBadge({ status }: { status: TenantStatus }) { + const variant: "default" | "secondary" | "destructive" | "outline" = + status === "active" ? "default" : status === "suspended" ? "secondary" : "outline" + return ( + + {status} + + ) +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) + } catch { + return iso + } +}