import { useCallback, useEffect, useMemo, useState } from "react" import { CheckCircle2, Network, Pause, Play, Plus, RefreshCw, Trash2, } from "lucide-react" import { useArcadiaClient } from "@crema/arcadia-core-client" import { useToast } from "@crema/notification-ui" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type BadgeTone, type Column, } from "@crema/table-ui" import { SearchInput } from "@crema/search-ui" import { ConfirmDialog, EmptyState } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { DataState, DialogError } from "~/components/data-state" import { errorMessage } from "~/lib/errors" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, CardContent, CardHeader } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" 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 /** Who the membership is for, in the operator's words. */ function memberLabel(m: Membership): string { return m.user?.email ?? m.user_id.slice(0, 8) + "…" } export default function MembershipsRoute() { const session = useSession() const arcadia = useArcadiaClient() const toast = useToast() const [memberships, setMemberships] = useState([]) const [users, setUsers] = useState([]) const [roles, setRoles] = useState([]) const [loading, setLoading] = useState(true) // Raw thrown value — DataState turns the status code into plain language. const [error, setError] = 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 { // Users and roles are only needed to populate the editor's pickers; a // failure there shouldn't blank the table, so they degrade to empty. 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) } 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 who = memberLabel(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) await refresh() toast.success(`Suspended ${who}`) } catch (err) { toast.error(errorMessage(err, `suspend ${who}`)) } }, } : { id: "activate", label: "Activate", icon: , dataAction: `membership-${m.id}-activate`, onSelect: async () => { try { await activateMembership(arcadia, m.id) await refresh() toast.success(`Activated ${who}`) } catch (err) { toast.error(errorMessage(err, `activate ${who}`)) } }, }, { id: "delete", label: "Remove", icon: , destructive: true, dataAction: `membership-${m.id}-delete`, onSelect: () => setPendingDelete(m), }, ] return ( ) }, }, ], [arcadia, refresh, toast], ) 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.

{table.total} of {memberships.length}
} 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 ? `${memberLabel(pendingDelete)} immediately loses access to ${pendingDelete.tenant?.name ?? "this tenant"}, along with any roles they hold there. Suspend instead if this is temporary.` : "" } confirmLabel="Remove" variant="danger" onConfirm={async () => { if (!pendingDelete) return const who = memberLabel(pendingDelete) const where = pendingDelete.tenant?.name ?? "this tenant" try { await deleteMembership(arcadia, pendingDelete.id) setPendingDelete(null) await refresh() toast.success(`Removed ${who} from ${where}`) } catch (err) { setPendingDelete(null) toast.error(errorMessage(err, `remove ${who} from ${where}`)) } }} /> m.user_id))} onClose={() => setEditor(null)} onSaved={async (message) => { setEditor(null) await refresh() toast.success(message) }} />
) } 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, }: { state: Editor users: User[] roles: Role[] existingUserIds: Set onClose: () => void onSaved: (message: string) => Promise }) { 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) // Rendered inside the dialog: a page banner would sit behind the scrim. const [error, setError] = useState(null) useEffect(() => { if (!open) return setError(null) setSaving(false) 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 () => { setError(null) setSaving(true) try { const input = { user_id: userId, status, role_ids: Array.from(selectedRoles), } const who = users.find((u) => u.id === userId)?.email ?? initial?.user?.email ?? "the member" if (isEdit && initial) { await updateMembership(arcadia, initial.id, input) await onSaved(`Saved ${who}'s membership`) } else { await createMembership(arcadia, input) await onSaved(`Added ${who}`) } } catch (err) { // Form state survives so the operator can fix and resubmit. setError(err) 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 ( ) })}
)}
{error ? ( ) : null}
) } 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 }, {}) } export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"