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 { 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 { 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", } /** Members are keyed by user_id only; show the short form consistently. */ function memberLabel(m: OrgMembership): string { return `${m.user_id.slice(0, 8)}…` } export default function OrganizationsRoute() { const session = useSession() const arcadia = useArcadiaClient() const toast = useToast() const [orgs, setOrgs] = useState([]) const [loading, setLoading] = useState(true) // Raw thrown value: the status code is what makes the message useful. const [error, setError] = 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) } 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.

{table.total} of {orgs.length}
} 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)} /> setSettingsDialog(null)} onSaved={async (message) => { setSettingsDialog(null) await refresh() toast.success(message) }} />
) } 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, }: { state: MembersDialogState onClose: () => void }) { const arcadia = useArcadiaClient() const toast = useToast() const open = state !== null const org = state?.org const [members, setMembers] = useState([]) const [loading, setLoading] = useState(false) // The member list has its own load error — a failed fetch inside this dialog // must not read as "no members yet". const [error, setError] = useState(null) const [pendingRemove, setPendingRemove] = useState(null) const [transferTarget, setTransferTarget] = useState(null) const [pane, setPane] = useState("none") const refresh = useCallback(async () => { if (!org) return setError(null) setLoading(true) try { setMembers(await listMembers(arcadia, org.id)) } catch (err) { setError(err) } finally { setLoading(false) } }, [arcadia, org]) useEffect(() => { if (open) { setPane("none") 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 (message) => { setPane("none") await refresh() toast.success(message) }} /> ) : ( setPane("none")} onSaved={async (message) => { setPane("none") await refresh() toast.success(message) }} /> )}
} 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
{memberLabel(m)} {m.role} {m.status} {m.joined_at ? new Date(m.joined_at).toLocaleDateString() : "—"} setTransferTarget(m)} onRemove={() => setPendingRemove(m)} onRoleChanged={refresh} />
!o && setPendingRemove(null)} title="Remove member?" description={ pendingRemove ? pendingRemove.role === "owner" ? `This member owns ${org?.name ?? "the organization"}. Removing them applies its owner-removal policy — ${ org ? ON_OWNER_REMOVAL_LABEL[org.on_owner_removal].toLowerCase() : "the configured policy" } — which may delete or freeze the whole workspace. Transfer ownership first if you only mean to remove the person.` : `They immediately lose access to ${org?.name ?? "this organization"} and anything shared inside it.` : "" } confirmLabel="Remove" variant="danger" onConfirm={async () => { if (!pendingRemove || !org) return const who = memberLabel(pendingRemove) try { await removeMember(arcadia, org.id, pendingRemove.user_id) setPendingRemove(null) await refresh() toast.success(`Removed ${who} from ${org.name}`) } catch (err) { setPendingRemove(null) toast.error(errorMessage(err, `remove ${who} from ${org.name}`)) } }} /> !o && setTransferTarget(null)} title="Transfer ownership?" description={ transferTarget ? `${memberLabel(transferTarget)} becomes the owner of ${org?.name ?? "this organization"}, and the current owner is demoted to admin.` : "" } confirmLabel="Transfer" variant="default" onConfirm={async () => { if (!transferTarget || !org) return const who = memberLabel(transferTarget) try { await transferOwnership(arcadia, org.id, transferTarget.user_id) setTransferTarget(null) await refresh() toast.success(`${org.name} is now owned by ${who}`) } catch (err) { setTransferTarget(null) toast.error(errorMessage(err, `transfer ${org.name} to ${who}`)) } }} />
) } function MemberRowActions({ member, orgId, onTransfer, onRemove, onRoleChanged, }: { member: OrgMembership orgId: string onTransfer: () => void onRemove: () => void onRoleChanged: () => Promise }) { const arcadia = useArcadiaClient() const toast = useToast() 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() toast.success(`${memberLabel(member)} is now ${next === "admin" ? "an admin" : "a member"}`) } catch (err) { toast.error(errorMessage(err, `change ${memberLabel(member)} to ${next}`)) } }, }) 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, }: { orgId: string onCancel: () => void onSaved: (message: string) => Promise }) { const arcadia = useArcadiaClient() const [email, setEmail] = useState("") const [role, setRole] = useState("member") const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const submit = async () => { setError(null) setSaving(true) try { const res = await inviteMember(arcadia, orgId, { email, role }) await onSaved( res.type === "membership" ? `Added ${email.trim()} — they already had an account` : `Invitation sent to ${email.trim()}`, ) } catch (err) { // The form stays filled in; the error lands right under it. setError(err) setSaving(false) } } return (
setEmail(e.target.value)} data-action={`org-${orgId}-invite-email`} />
{error ? (
) : null}

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, }: { orgId: string onCancel: () => void onSaved: (message: string) => Promise }) { 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) const [error, setError] = useState(null) const submit = async () => { setError(null) setSaving(true) try { await addRestrictedMember(arcadia, orgId, { email, password, first_name: firstName, last_name: lastName, role, }) await onSaved(`Added ${email.trim()} as a restricted user`) } catch (err) { setError(err) setSaving(false) } } return (
setEmail(e.target.value)} data-action={`org-${orgId}-restricted-email`} />
setPassword(e.target.value)} data-action={`org-${orgId}-restricted-password`} />
setFirstName(e.target.value)} data-action={`org-${orgId}-restricted-first-name`} />
setLastName(e.target.value)} data-action={`org-${orgId}-restricted-last-name`} />
{error ? (
) : null}

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, }: { state: SettingsDialogState onClose: () => void onSaved: (message: string) => Promise }) { 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) const [error, setError] = useState(null) useEffect(() => { if (org) { setName(org.name) setStatus(org.status) setOnOwnerRemoval(org.on_owner_removal) setError(null) setSaving(false) } }, [org]) const submit = async () => { if (!org) return setError(null) setSaving(true) try { await updateOrganization(arcadia, org.id, { name, status, on_owner_removal: onOwnerRemoval, }) await onSaved(`Saved ${name.trim() || org.name}`) } catch (err) { setError(err) setSaving(false) } } return ( !o && onClose()}> {org ? `Settings — ${org.name}` : "Settings"} Change name, status, or owner-removal policy.
setName(e.target.value)} data-action="org-settings-name" />

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

{error ? : null}
) } export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"