import { useCallback, useEffect, useMemo, useState } from "react" import { CheckCircle2, Network, Pause, Play, Plus, RefreshCw, Trash2, } 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 { 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 { activateMembership, createMembership, deleteMembership, listMemberships, suspendMembership, updateMembership, type Membership, type MembershipStatus, } from "~/lib/arcadia/memberships" import { listUsers, type User } from "~/lib/arcadia/users" import { listRoles, type Role } from "~/lib/arcadia/roles" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" import { useRegisterContext } from "@crema/aifirst-ui/context" export const meta = () => pageTitle("Memberships") type Editor = | { kind: "create" } | { kind: "edit"; membership: Membership } | null export default function MembershipsRoute() { const session = useSession() const arcadia = useArcadiaClient() const [memberships, setMemberships] = useState([]) const [users, setUsers] = useState([]) const [roles, setRoles] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [search, setSearch] = useState("") const [statusFilter, setStatusFilter] = useState<"all" | MembershipStatus>("all") const [editor, setEditor] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const refresh = useCallback(async () => { setError(null) setLoading(true) try { const [m, u, r] = await Promise.all([ listMemberships(arcadia), listUsers(arcadia).catch(() => [] as User[]), listRoles(arcadia).catch(() => [] as Role[]), ]) setMemberships(m) setUsers(u) setRoles(r) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load memberships.") } finally { setLoading(false) } }, [arcadia]) useEffect(() => { if (session) refresh() }, [session, refresh]) const filtered = useMemo( () => statusFilter === "all" ? memberships : memberships.filter((m) => m.status === statusFilter), [memberships, statusFilter], ) const columns = useMemo[]>( () => [ { id: "user", header: "User", accessor: (m) => m.user?.email ?? m.user_id, sortable: true, cell: (m) => (
{m.user?.email ?? "—"} {m.user?.first_name || m.user?.last_name ? `${m.user?.first_name ?? ""} ${m.user?.last_name ?? ""}`.trim() : m.user_id.slice(0, 8) + "…"}
), }, { id: "tenant", header: "Tenant", accessor: (m) => m.tenant?.name ?? "", sortable: true, cell: (m) => m.tenant ? (
{m.tenant.name} {m.tenant.slug}
) : ( ), }, { id: "status", header: "Status", accessor: "status", sortable: true, cell: (m) => , }, { id: "primary", header: "Primary", accessor: "is_primary", sortable: true, cell: (m) => m.is_primary ? ( ) : ( ), }, { id: "roles", header: "Roles", cell: (m) => m.roles.length === 0 ? ( ) : (
{m.roles.map((r) => ( {r.slug} ))}
), }, { id: "joined", header: "Joined", accessor: "joined_at", sortable: true, cell: (m) => m.joined_at ? ( ) : ( ), }, { id: "actions", header: "", align: "right", cell: (m) => { const items: ActionItem[] = [ { id: "edit", label: "Edit", dataAction: `membership-${m.id}-edit`, onSelect: () => setEditor({ kind: "edit", membership: m }), }, m.status === "active" ? { id: "suspend", label: "Suspend", icon: , dataAction: `membership-${m.id}-suspend`, onSelect: async () => { try { await suspendMembership(arcadia, m.id) setInfo("Membership suspended.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Suspend failed.") } }, } : { id: "activate", label: "Activate", icon: , dataAction: `membership-${m.id}-activate`, onSelect: async () => { try { await activateMembership(arcadia, m.id) setInfo("Membership activated.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Activate failed.") } }, }, { id: "delete", label: "Remove", icon: , destructive: true, dataAction: `membership-${m.id}-delete`, onSelect: () => setPendingDelete(m), }, ] return ( ) }, }, ], [arcadia, refresh], ) const summary = useMemo( () => ({ total: memberships.length, byStatus: countBy(memberships, (m) => m.status), uniqueTenants: new Set(memberships.map((m) => m.tenant_id)).size, uniqueUsers: new Set(memberships.map((m) => m.user_id)).size, }), [memberships], ) useRegisterContext("memberships", summary) const table = useTable({ data: filtered, columns, getRowId: (m) => m.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (

Memberships

Who belongs to which tenant. A user can have memberships in multiple tenants; one is marked primary.

{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null}
{table.total} of {memberships.length}
{table.total === 0 && !loading ? ( } title={ search || statusFilter !== "all" ? "No memberships match those filters." : "No memberships yet." } description={ search || statusFilter !== "all" ? "Loosen the filter set." : "Add a user to a tenant to create the first membership." } className="py-12" /> ) : ( <> m.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && memberships.length > 0} stickyHeader /> )}
!o && setPendingDelete(null)} title="Remove membership?" description={ pendingDelete ? `${pendingDelete.user?.email ?? pendingDelete.user_id} will lose access to ${pendingDelete.tenant?.name ?? "this tenant"}.` : "" } confirmLabel="Remove" variant="danger" onConfirm={async () => { if (!pendingDelete) return try { await deleteMembership(arcadia, pendingDelete.id) setPendingDelete(null) setInfo("Membership removed.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Remove failed.") setPendingDelete(null) } }} /> m.user_id))} onClose={() => setEditor(null)} onSaved={async (msg) => { setEditor(null) if (msg) setInfo(msg) await refresh() }} onError={setError} />
) } function statusTone(s: MembershipStatus): BadgeTone { if (s === "active") return "success" if (s === "suspended") return "warning" return "default" } function MembershipEditorDialog({ state, users, roles, existingUserIds, onClose, onSaved, onError, }: { state: Editor users: User[] roles: Role[] existingUserIds: Set onClose: () => void onSaved: (msg?: string) => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const open = state !== null const isEdit = state?.kind === "edit" const initial = isEdit ? state.membership : null const [userId, setUserId] = useState("") const [status, setStatus] = useState("active") const [selectedRoles, setSelectedRoles] = useState>(new Set()) const [saving, setSaving] = useState(false) useEffect(() => { if (!open) return if (initial) { setUserId(initial.user_id) setStatus(initial.status) setSelectedRoles(new Set(initial.roles.map((r) => r.id))) } else { setUserId("") setStatus("active") setSelectedRoles(new Set()) } }, [open, initial]) const eligibleUsers = useMemo( () => (isEdit ? users : users.filter((u) => !existingUserIds.has(u.id))), [users, existingUserIds, isEdit], ) const submit = async () => { onError(null) setSaving(true) try { const input = { user_id: userId, status, role_ids: Array.from(selectedRoles), } if (isEdit && initial) { await updateMembership(arcadia, initial.id, input) await onSaved("Membership updated.") } else { await createMembership(arcadia, input) await onSaved("Member added.") } } catch (err) { onError( err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.", ) } finally { setSaving(false) } } return ( !o && onClose()}> {isEdit ? "Edit membership" : "Add member"} {isEdit ? "Update status and role assignments." : "Pick a user and assign roles within the current tenant."}
{roles.length === 0 ? (

No roles defined. Create some on the Users tab.

) : (
{roles.map((r) => { const active = selectedRoles.has(r.id) return ( ) })}
)}
) } function countBy(arr: T[], key: (x: T) => string): Record { return arr.reduce>((acc, x) => { const k = key(x) acc[k] = (acc[k] ?? 0) + 1 return acc }, {}) } // File-local alias just to keep the Editor type narrowable inside the dialog. type Editor = | { kind: "create" } | { kind: "edit"; membership: Membership } | null