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>
573 lines
17 KiB
TypeScript
573 lines
17 KiB
TypeScript
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<Tenant[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [pending, setPending] = useState<PendingAction>(null)
|
|
const [search, setSearch] = useState("")
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
|
|
const refresh = useCallback(async () => {
|
|
setError(null)
|
|
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<Column<Tenant>[]>(
|
|
() => [
|
|
{
|
|
id: "name",
|
|
header: "Name",
|
|
accessor: "name",
|
|
sortable: true,
|
|
cell: (t) => <span className="font-medium">{t.name}</span>,
|
|
},
|
|
{
|
|
id: "slug",
|
|
header: "Slug",
|
|
accessor: "slug",
|
|
sortable: true,
|
|
cell: (t) => (
|
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{t.slug}</code>
|
|
),
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
accessor: "status",
|
|
sortable: true,
|
|
cell: (t) => <BadgeCell label={t.status} tone={statusTone(t.status)} />,
|
|
},
|
|
{
|
|
id: "plan",
|
|
header: "Plan",
|
|
accessor: (t) => t.plan?.name ?? "",
|
|
sortable: true,
|
|
cell: (t) => <span className="text-muted-foreground">{t.plan?.name ?? "—"}</span>,
|
|
},
|
|
{
|
|
id: "created",
|
|
header: "Created",
|
|
accessor: "inserted_at",
|
|
sortable: true,
|
|
cell: (t) => <DateCell value={t.inserted_at} format="short" />,
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "",
|
|
align: "right",
|
|
cell: (t) => (
|
|
<ActionsCell
|
|
items={rowActions(t, arcadia, refresh, setPending, setError)}
|
|
triggerDataAction={`tenant-${t.slug}-actions`}
|
|
/>
|
|
),
|
|
},
|
|
],
|
|
[arcadia, refresh],
|
|
)
|
|
|
|
const tenantSummary = useMemo(
|
|
() => ({
|
|
total: tenants.length,
|
|
byStatus: tenants.reduce<Record<string, number>>((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<Tenant>({
|
|
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 (
|
|
<AppShell>
|
|
<PageHeader
|
|
title="Tenants"
|
|
description="Multi-tenant workspaces on this arcadia deployment."
|
|
actions={
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={refresh}
|
|
disabled={loading}
|
|
data-action="tenants-refresh"
|
|
>
|
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setCreateOpen(true)}
|
|
data-action="tenants-create"
|
|
>
|
|
<Plus className="size-4" />
|
|
New tenant
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{error ? (
|
|
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
|
{error}
|
|
</AlertBanner>
|
|
) : null}
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
|
<SearchInput
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
placeholder="Search by name, slug, or status"
|
|
data-action="tenants-search"
|
|
className="max-w-sm flex-1"
|
|
/>
|
|
<div className="text-xs text-muted-foreground">
|
|
{table.total} of {tenants.length}
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="relative p-0">
|
|
<LoadingOverlay active={loading && tenants.length === 0} label="Loading tenants…" />
|
|
{table.total === 0 && !loading ? (
|
|
<EmptyState
|
|
title={search ? "No tenants match that search." : "No tenants yet."}
|
|
description={
|
|
search ? "Try a different name, slug, or status." : "Create your first tenant to get started."
|
|
}
|
|
className="py-12"
|
|
/>
|
|
) : (
|
|
<>
|
|
<DataTable
|
|
columns={columns}
|
|
rows={table.pageRows}
|
|
getRowId={(t) => t.id}
|
|
sort={table.sort}
|
|
onSortToggle={table.toggleSort}
|
|
loading={loading && tenants.length > 0}
|
|
stickyHeader
|
|
/>
|
|
<Pagination
|
|
page={table.page}
|
|
pageSize={table.pageSize}
|
|
total={table.total}
|
|
onPageChange={table.setPage}
|
|
onPageSizeChange={table.setPageSize}
|
|
/>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<TenantCreateDialog
|
|
open={createOpen}
|
|
onClose={() => setCreateOpen(false)}
|
|
onCreated={async () => {
|
|
setCreateOpen(false)
|
|
await refresh()
|
|
}}
|
|
onError={setError}
|
|
/>
|
|
<ConfirmDialog
|
|
open={pending?.kind === "suspend"}
|
|
onOpenChange={(o) => !o && setPending(null)}
|
|
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)}
|
|
/>
|
|
<ConfirmDialog
|
|
open={pending?.kind === "deactivate"}
|
|
onOpenChange={(o) => !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)}
|
|
/>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
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<typeof useArcadiaClient>,
|
|
refresh: () => Promise<void>,
|
|
setPending: (p: PendingAction) => void,
|
|
setError: (msg: string | null) => void,
|
|
): ActionItem[] {
|
|
const items: ActionItem[] = []
|
|
if (t.status === "active") {
|
|
items.push({
|
|
id: "suspend",
|
|
label: "Suspend",
|
|
icon: <Pause className="size-4" />,
|
|
dataAction: `tenant-${t.slug}-suspend`,
|
|
onSelect: () => setPending({ kind: "suspend", tenant: t }),
|
|
})
|
|
} else {
|
|
items.push({
|
|
id: "activate",
|
|
label: "Activate",
|
|
icon: <Play className="size-4" />,
|
|
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> | 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>
|
|
)
|
|
}
|