import { useReducer, useState } from "react"; import { Exit, Match } from "effect"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { toast } from "sonner"; import { trackEvent } from "../api/analytics"; import { orgMemberWriteKeys, orgInfoWriteKeys } from "../api/reactivity-keys"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, } from "../components/dialog"; import { Button } from "../components/button"; import { Badge } from "../components/badge"; import { Input } from "../components/input"; import { Label } from "../components/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../components/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, DropdownMenuSeparator, } from "../components/dropdown-menu"; import { orgMembersAtom, orgRolesAtom, inviteMember, removeMember, updateMemberRole, updateOrgName, } from "../api/account-atoms"; import { useAuth } from "../multiplayer/auth-context"; // --------------------------------------------------------------------------- // Shared organization page — members + roles + invites + org name, over the // provider-neutral `/account/*` surface. Cloud-only surfaces (domain // verification, seat/billing gating) are NOT here; cloud composes those // alongside this page as its own additions. // --------------------------------------------------------------------------- type MemberData = { id: string; email: string; name: string | null; avatarUrl: string | null; role: string; status: string; lastActiveAt: string | null; isCurrentUser: boolean; }; type RoleData = { slug: string; name: string }; type InviteState = { email: string; roleSlug: string; status: "idle" | "sending" | "error"; }; const initialInviteState: InviteState = { email: "", roleSlug: "member", status: "idle", }; type InviteAction = | { type: "setEmail"; email: string } | { type: "setRole"; roleSlug: string } | { type: "send" } | { type: "error" } | { type: "reset" }; function inviteReducer(state: InviteState, action: InviteAction): InviteState { return Match.value(action).pipe( Match.discriminator("type")("setEmail", (a) => ({ ...state, email: a.email, })), Match.discriminator("type")("setRole", (a) => ({ ...state, roleSlug: a.roleSlug, })), Match.discriminator("type")("send", () => ({ ...state, status: "sending" as const, })), Match.discriminator("type")("error", () => ({ ...state, status: "error" as const, })), Match.discriminator("type")("reset", () => initialInviteState), Match.exhaustive, ); } function formatLastActive(lastActiveAt: string | null): string { if (!lastActiveAt) return "—"; const date = new Date(lastActiveAt); const diffMins = Math.floor((Date.now() - date.getTime()) / 60000); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; const diffHours = Math.floor(diffMins / 60); if (diffHours < 24) return `${diffHours}h ago`; const diffDays = Math.floor(diffHours / 24); if (diffDays < 30) return `${diffDays}d ago`; return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } export function OrgPage(props: { domainsSection?: React.ReactNode }) { const auth = useAuth(); const organizationName = auth.status === "authenticated" ? (auth.organization?.name ?? "Organization") : "Organization"; const membersResult = useAtomValue(orgMembersAtom); const rolesResult = useAtomValue(orgRolesAtom); const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); const doUpdateOrgName = useAtomSet(updateOrgName, { mode: "promiseExit" }); const [inviteOpen, setInviteOpen] = useState(false); const [editName, setEditName] = useState(organizationName); const [savingName, setSavingName] = useState(false); const [search, setSearch] = useState(""); const roles = AsyncResult.match(rolesResult, { onInitial: () => [] as readonly RoleData[], onFailure: () => [] as readonly RoleData[], onSuccess: ({ value }) => value.roles, }); const handleRemove = async (membershipId: string, name: string) => { const exit = await doRemove({ params: { membershipId }, reactivityKeys: orgMemberWriteKeys, }); trackEvent("org_member_removed", { success: Exit.isSuccess(exit) }); toast[Exit.isSuccess(exit) ? "success" : "error"]( Exit.isSuccess(exit) ? `Removed ${name}` : "Failed to remove member", ); }; const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => { const exit = await doUpdateRole({ params: { membershipId }, payload: { roleSlug }, reactivityKeys: orgMemberWriteKeys, }); trackEvent("org_member_role_changed", { role: roleSlug, success: Exit.isSuccess(exit) }); toast[Exit.isSuccess(exit) ? "success" : "error"]( Exit.isSuccess(exit) ? `Role changed to ${roleName}` : "Failed to change role", ); }; const handleSaveName = async () => { const trimmed = editName.trim(); if (!trimmed || trimmed === organizationName) { setEditName(organizationName); return; } setSavingName(true); const exit = await doUpdateOrgName({ payload: { name: trimmed }, reactivityKeys: orgInfoWriteKeys, }); trackEvent("org_renamed", { success: Exit.isSuccess(exit) }); if (Exit.isSuccess(exit)) { toast.success("Organization name updated"); } else { toast.error("Failed to update organization name"); setEditName(organizationName); } setSavingName(false); }; return (

Organization

setEditName((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") handleSaveName(); }} className="mt-1.5 h-9 text-sm" />
{editName.trim() !== organizationName && editName.trim() !== "" && ( )}
{props.domainsSection && props.domainsSection}

Members

People with access to this Executor instance.

setSearch((e.target as HTMLInputElement).value)} className="mb-3 h-9 text-sm" /> {AsyncResult.match(membersResult, { onInitial: () => (
{[1, 2, 3].map((i) => (
))}
), onFailure: () => (

Failed to load members

), onSuccess: ({ value }) => { const members = value.members; const filtered = search ? members.filter( (m: MemberData) => m.email.toLowerCase().includes(search.toLowerCase()) || (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), ) : members; if (filtered.length === 0) { return (

{search ? "No matching members" : "No members yet"}

); } return (
{filtered.map((member: MemberData) => (
{member.avatarUrl ? ( ) : (
{member.name ? member.name .split(" ") .map((n: string) => n[0]) .join("") .slice(0, 2) .toUpperCase() : member.email[0]!.toUpperCase()}
)}

{member.name ?? member.email}

{member.isCurrentUser && ( You )} {member.status === "pending" && ( Invited )}
{member.name && (

{member.email}

)}

{member.role}

{formatLastActive(member.lastActiveAt)}

{!member.isCurrentUser ? ( {roles.length > 0 && ( <> Change role {roles.map((role: RoleData) => ( handleChangeRole(member.id, role.slug, role.name) } > {role.name} ))} )} handleRemove(member.id, member.name ?? member.email)} > Remove member ) : (
)}
))}
); }, })}
); } function InviteDialog(props: { open: boolean; onOpenChange: (v: boolean) => void; roles: readonly RoleData[]; }) { const [state, dispatch] = useReducer(inviteReducer, initialInviteState); const doInvite = useAtomSet(inviteMember, { mode: "promiseExit" }); const handleInvite = async () => { if (!state.email.trim()) return; dispatch({ type: "send" }); const exit = await doInvite({ payload: { email: state.email.trim(), ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}), }, reactivityKeys: orgMemberWriteKeys, }); trackEvent("org_member_invited", { role: state.roleSlug, success: Exit.isSuccess(exit) }); if (Exit.isSuccess(exit)) { toast.success(`Invitation sent to ${state.email.trim()}`); dispatch({ type: "reset" }); props.onOpenChange(false); return; } dispatch({ type: "error" }); }; return ( { if (!v) dispatch({ type: "reset" }); props.onOpenChange(v); }} > Invite member Send an email invitation to join your organization.
dispatch({ type: "setEmail", email: (e.target as HTMLInputElement).value, }) } onKeyDown={(e) => { if (e.key === "Enter") handleInvite(); }} className="text-sm h-9" />
{props.roles.length > 0 && (
)} {state.status === "error" && (

Failed to send invitation. Please try again.

)}
); }