import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react" import { Pause, Play, Plus, RefreshCw } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type BadgeTone, type Column, } from "@crema/table-ui" import { SearchInput } from "@crema/search-ui" import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { PageHeader } from "~/components/layout/page-header" 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 { activateTenant, deactivateTenant, listTenants, provisionTenant, suspendTenant, type Tenant, type TenantStatus, } from "~/lib/arcadia/tenants" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" import { useRegisterContext } from "@crema/aifirst-ui/context" export const meta = () => pageTitle("Tenants") type PendingAction = { kind: "suspend" | "deactivate" tenant: Tenant } | null 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 [pending, setPending] = useState(null) const [search, setSearch] = useState("") const [createOpen, setCreateOpen] = useState(false) 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 runAction = useCallback( async (action: PendingAction) => { if (!action) return try { if (action.kind === "suspend") await suspendTenant(arcadia, action.tenant.id) else await deactivateTenant(arcadia, action.tenant.id) setPending(null) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Action failed.") setPending(null) } }, [arcadia, refresh], ) const columns = useMemo[]>( () => [ { id: "name", header: "Name", accessor: "name", sortable: true, cell: (t) => {t.name}, }, { id: "slug", header: "Slug", accessor: "slug", sortable: true, cell: (t) => ( {t.slug} ), }, { id: "status", header: "Status", accessor: "status", sortable: true, cell: (t) => , }, { id: "plan", header: "Plan", accessor: (t) => t.plan?.name ?? "", sortable: true, cell: (t) => {t.plan?.name ?? "—"}, }, { id: "created", header: "Created", accessor: "inserted_at", sortable: true, cell: (t) => , }, { id: "actions", header: "", align: "right", cell: (t) => ( ), }, ], [arcadia, refresh], ) const tenantSummary = useMemo( () => ({ total: tenants.length, byStatus: tenants.reduce>((acc, t) => { acc[t.status] = (acc[t.status] ?? 0) + 1 return acc }, {}), tenants: tenants.map((t) => ({ id: t.id, slug: t.slug, name: t.name, status: t.status, plan: t.plan?.name ?? null, inserted_at: t.inserted_at, })), }), [tenants], ) useRegisterContext("tenants", tenantSummary) const table = useTable({ data: tenants, columns, getRowId: (t) => t.id, initialPageSize: 25, initialSearch: search, }) // Keep useTable's search in lockstep with our SearchInput. useEffect(() => { table.setSearch(search) }, [search, table]) return ( } /> {error ? ( setError(null)}> {error} ) : null}
{table.total} of {tenants.length}
{table.total === 0 && !loading ? ( ) : ( <> t.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && tenants.length > 0} stickyHeader /> )}
setCreateOpen(false)} onCreated={async () => { setCreateOpen(false) await refresh() }} onError={setError} /> !o && setPending(null)} title="Suspend tenant?" description={ pending ? `${pending.tenant.name} will be suspended. Members won't be able to sign in until you reactivate.` : "" } confirmLabel="Suspend" variant="default" onConfirm={() => runAction(pending)} /> !o && setPending(null)} title="Deactivate tenant?" description={ pending ? `${pending.tenant.name} will be deactivated. This is more severe than suspending.` : "" } confirmLabel="Deactivate" variant="danger" onConfirm={() => runAction(pending)} />
) } function statusTone(status: TenantStatus): BadgeTone { if (status === "active") return "success" if (status === "suspended") return "warning" if (status === "deactivated") return "danger" return "default" } function rowActions( t: Tenant, arcadia: ReturnType, refresh: () => Promise, setPending: (p: PendingAction) => void, setError: (msg: string | null) => void, ): ActionItem[] { const items: ActionItem[] = [] if (t.status === "active") { items.push({ id: "suspend", label: "Suspend", icon: , dataAction: `tenant-${t.slug}-suspend`, onSelect: () => setPending({ kind: "suspend", tenant: t }), }) } else { items.push({ id: "activate", label: "Activate", icon: , dataAction: `tenant-${t.slug}-activate`, onSelect: async () => { try { await activateTenant(arcadia, t.id) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Activate failed.") } }, }) } items.push({ id: "deactivate", label: "Deactivate", destructive: true, dataAction: `tenant-${t.slug}-deactivate`, onSelect: () => setPending({ kind: "deactivate", tenant: t }), }) 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 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 ( !o && onClose()}>
New tenant Provisions the tenant with default roles, quotas, and an initial admin user.
{ setName(e.target.value) if (!slugDirty) setSlug(slugify(e.target.value)) }} placeholder="Acme Corp" autoFocus data-action="tenants-create-name" />
{ setSlugDirty(true) setSlug(e.target.value) }} placeholder="acme" data-action="tenants-create-slug" />

{slugInvalid ? "Lowercase letters, digits, and hyphens only." : "Lowercase letters, digits, and hyphens. Used in URLs and the X-Tenant-ID header."}

setFirstName(e.target.value)} placeholder="Jane" data-action="tenants-create-admin-first-name" />
setLastName(e.target.value)} placeholder="Doe" data-action="tenants-create-admin-last-name" />
setEmail(e.target.value)} placeholder="admin@acme.com" data-action="tenants-create-admin-email" />
setPassword(e.target.value)} placeholder="At least 8 characters" data-action="tenants-create-admin-password" />
) }