import { useCallback, useEffect, useMemo, useState } from "react" import { Building, Crown, Mail, RefreshCw, Settings as SettingsIcon, Trash2, UserCog, UserPlus, Users as UsersIcon, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-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 { addRestrictedMember, changeMemberRole, inviteMember, listAllOrganizations, listMembers, removeMember, transferOwnership, updateOrganization, type OnOwnerRemoval, type OrgMembership, type OrgRole, type OrgStatus, type Organization, } from "~/lib/arcadia/organizations" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" export const meta = () => pageTitle("Organizations") type MembersDialogState = { org: Organization } | null type SettingsDialogState = { org: Organization } | null const ON_OWNER_REMOVAL_LABEL: Record = { delete: "Delete org", require_transfer: "Require transfer", freeze_until_new_owner: "Freeze until new owner", } export default function OrganizationsRoute() { const session = useSession() const arcadia = useArcadiaClient() const [orgs, setOrgs] = 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" | OrgStatus>("all") const [membersDialog, setMembersDialog] = useState(null) const [settingsDialog, setSettingsDialog] = useState(null) const refresh = useCallback(async () => { setError(null) setLoading(true) try { setOrgs(await listAllOrganizations(arcadia)) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load organizations.") } finally { setLoading(false) } }, [arcadia]) useEffect(() => { if (session) refresh() }, [session, refresh]) const filtered = useMemo( () => (statusFilter === "all" ? orgs : orgs.filter((o) => o.status === statusFilter)), [orgs, statusFilter], ) const columns = useMemo[]>( () => [ { id: "name", header: "Organization", accessor: "name", sortable: true, cell: (o) => (
{o.name} {o.slug}
), }, { id: "status", header: "Status", accessor: "status", sortable: true, cell: (o) => , }, { id: "on_owner_removal", header: "Owner-removal policy", accessor: "on_owner_removal", sortable: true, cell: (o) => ( {ON_OWNER_REMOVAL_LABEL[o.on_owner_removal] ?? o.on_owner_removal} ), }, { id: "updated_at", header: "Updated", accessor: "updated_at", sortable: true, cell: (o) => , }, { id: "actions", header: "", align: "right", cell: (o) => { const items: ActionItem[] = [ { id: "members", label: "Manage members", icon: , dataAction: `org-${o.id}-members`, onSelect: () => setMembersDialog({ org: o }), }, { id: "settings", label: "Settings", icon: , dataAction: `org-${o.id}-settings`, onSelect: () => setSettingsDialog({ org: o }), }, ] return }, }, ], [], ) const table = useTable({ data: filtered, columns, getRowId: (o) => o.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (

Organizations

End-user workspaces inside this tenant. Each one is owned by a regular user; admins here can manage members, change ownership policy, or freeze a workspace.

{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null}
{table.total} of {orgs.length}
{table.total === 0 && !loading ? ( } title={ search || statusFilter !== "all" ? "No organizations match those filters." : "No organizations yet." } description={ search || statusFilter !== "all" ? "Loosen the filter set." : "End-users create these from inside the app; nothing to do here yet." } className="py-12" /> ) : ( <> o.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && orgs.length > 0} stickyHeader /> )}
setMembersDialog(null)} onInfo={setInfo} onError={setError} /> setSettingsDialog(null)} onSaved={async (msg) => { setSettingsDialog(null) if (msg) setInfo(msg) await refresh() }} onError={setError} />
) } function statusTone(s: OrgStatus): BadgeTone { if (s === "active") return "success" if (s === "frozen") return "warning" if (s === "pending_deletion") return "danger" return "default" } function roleBadgeVariant(r: OrgRole): "default" | "secondary" | "destructive" | "outline" { if (r === "owner") return "default" if (r === "admin") return "secondary" return "outline" } // ============================================================================ // Members dialog // ============================================================================ type InvitePane = "none" | "invite_existing" | "add_restricted" function MembersDialog({ state, onClose, onInfo, onError, }: { state: MembersDialogState onClose: () => void onInfo: (msg: string | null) => void onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const open = state !== null const org = state?.org const [members, setMembers] = useState([]) const [loading, setLoading] = useState(false) const [pendingRemove, setPendingRemove] = useState(null) const [transferTarget, setTransferTarget] = useState(null) const [pane, setPane] = useState("none") const refresh = useCallback(async () => { if (!org) return setLoading(true) try { setMembers(await listMembers(arcadia, org.id)) } catch (err) { onError(err instanceof ArcadiaError ? err.message : "Failed to load members.") } finally { setLoading(false) } }, [arcadia, org, onError]) useEffect(() => { if (open) refresh() }, [open, refresh]) return ( !o && onClose()}> {org ? `Members — ${org.name}` : "Members"} {org ? `Manage who can act inside ${org.name}. The owner can be changed via transfer; one active owner at a time.` : ""} {pane === "none" ? (
) : pane === "invite_existing" ? ( setPane("none")} onSaved={async (msg) => { setPane("none") onInfo(msg) await refresh() }} onError={onError} /> ) : ( setPane("none")} onSaved={async (msg) => { setPane("none") onInfo(msg) await refresh() }} onError={onError} /> )}
{members.length === 0 && !loading ? ( } title="No members yet." description="Invite someone or add a restricted sub-user to get started." className="py-8" /> ) : (
{members.map((m) => ( ))}
User Role Status Joined
{m.user_id.slice(0, 8)}… {m.role} {m.status} {m.joined_at ? new Date(m.joined_at).toLocaleDateString() : "—"} setTransferTarget(m)} onRemove={() => setPendingRemove(m)} onRoleChanged={async (msg) => { onInfo(msg) await refresh() }} onError={onError} />
)}
!o && setPendingRemove(null)} title="Remove member?" description={ pendingRemove ? pendingRemove.role === "owner" ? "This member is the owner. Removal will follow the org's owner-removal policy." : "They will lose access to this organization." : "" } confirmLabel="Remove" variant="danger" onConfirm={async () => { if (!pendingRemove || !org) return try { await removeMember(arcadia, org.id, pendingRemove.user_id) setPendingRemove(null) onInfo("Member removed.") await refresh() } catch (err) { onError(err instanceof ArcadiaError ? err.message : "Remove failed.") setPendingRemove(null) } }} /> !o && setTransferTarget(null)} title="Transfer ownership?" description={ transferTarget ? `The current owner will be demoted to admin. ${transferTarget.user_id.slice(0, 8)}… will become owner.` : "" } confirmLabel="Transfer" variant="default" onConfirm={async () => { if (!transferTarget || !org) return try { await transferOwnership(arcadia, org.id, transferTarget.user_id) setTransferTarget(null) onInfo("Ownership transferred.") await refresh() } catch (err) { onError(err instanceof ArcadiaError ? err.message : "Transfer failed.") setTransferTarget(null) } }} />
) } function MemberRowActions({ member, orgId, onTransfer, onRemove, onRoleChanged, onError, }: { member: OrgMembership orgId: string onTransfer: () => void onRemove: () => void onRoleChanged: (msg: string) => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const items: ActionItem[] = [] if (member.role !== "owner") { items.push({ id: "promote-admin", label: member.role === "admin" ? "Demote to member" : "Promote to admin", icon: , dataAction: `org-${orgId}-member-${member.id}-role`, onSelect: async () => { const next = member.role === "admin" ? "member" : "admin" try { await changeMemberRole(arcadia, orgId, member.user_id, next) await onRoleChanged(`Role set to ${next}.`) } catch (err) { onError(err instanceof ArcadiaError ? err.message : "Role change failed.") } }, }) items.push({ id: "transfer", label: "Transfer ownership to this user", icon: , dataAction: `org-${orgId}-member-${member.id}-transfer`, onSelect: onTransfer, }) } items.push({ id: "remove", label: "Remove", icon: , destructive: true, dataAction: `org-${orgId}-member-${member.id}-remove`, onSelect: onRemove, }) return } // ============================================================================ // Invite-by-email and add-restricted forms // ============================================================================ function InviteByEmailForm({ orgId, onCancel, onSaved, onError, }: { orgId: string onCancel: () => void onSaved: (msg: string) => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const [email, setEmail] = useState("") const [role, setRole] = useState("member") const [saving, setSaving] = useState(false) return (
setEmail(e.target.value)} />

If an account with that email already exists in this tenant, an invited membership is created; otherwise an email invitation is sent and the user is materialized on accept.

) } function AddRestrictedForm({ orgId, onCancel, onSaved, onError, }: { orgId: string onCancel: () => void onSaved: (msg: string) => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const [email, setEmail] = useState("") const [firstName, setFirstName] = useState("") const [lastName, setLastName] = useState("") const [password, setPassword] = useState("") const [role, setRole] = useState("member") const [saving, setSaving] = useState(false) return (
setEmail(e.target.value)} />
setPassword(e.target.value)} />
setFirstName(e.target.value)} />
setLastName(e.target.value)} />

Restricted users exist only inside this org — they can never act in personal mode and have no plan of their own.

) } // ============================================================================ // Settings dialog // ============================================================================ function SettingsDialog({ state, onClose, onSaved, onError, }: { state: SettingsDialogState onClose: () => void onSaved: (msg?: string) => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const open = state !== null const org = state?.org const [name, setName] = useState("") const [status, setStatus] = useState("active") const [onOwnerRemoval, setOnOwnerRemoval] = useState("require_transfer") const [saving, setSaving] = useState(false) useEffect(() => { if (org) { setName(org.name) setStatus(org.status) setOnOwnerRemoval(org.on_owner_removal) } }, [org]) return ( !o && onClose()}> {org ? `Settings — ${org.name}` : "Settings"} Change name, status, or owner-removal policy.
setName(e.target.value)} />

Decides what happens when the owner's membership is removed.

) }