577 lines · 20.9 KB · TypeScript
RawDownload
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 { PageContainer, PageHeader } from "../components/page";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";import { messageFromExit } from "../api/error-reporting"; // ---------------------------------------------------------------------------// 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";  // The reason the invite was refused, when the server gave one (e.g. the  // plan's member-seat limit). Undefined falls back to the generic message.  errorMessage?: string;}; const initialInviteState: InviteState = {  email: "",  roleSlug: "member",  status: "idle",}; type InviteAction =  | { type: "setEmail"; email: string }  | { type: "setRole"; roleSlug: string }  | { type: "send" }  | { type: "error"; message?: string }  | { 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", (a) => ({      ...state,      status: "error" as const,      errorMessage: a.message,    })),    Match.discriminator("type")("reset", () => initialInviteState),    Match.exhaustive,  );} // Generic fallback when the server gave no typed reason (a transient failure// the admin genuinely can retry). A seat-limit refusal carries its own message.const GENERIC_INVITE_ERROR = "Failed to send invitation. Please try again."; 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;  // Cloud injects the plan-upgrade call to action (a link to billing/plans).  // When set and the org is at its seat limit, clicking "Invite member" opens  // an upgrade prompt instead of the invite form. Self-host has no seat limit,  // so it never reaches this and can omit it.  upgradeAction?: 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 [upgradeOpen, setUpgradeOpen] = 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 seats = AsyncResult.match(membersResult, {    onInitial: () => undefined,    onFailure: () => undefined,    onSuccess: ({ value }) => value.seats,  });  // At the plan's member-seat limit, "Invite member" opens an upgrade prompt  // (when cloud provided one) instead of the invite form, so the admin gets a  // clear next step rather than a refused invite.  const atSeatLimit = !!seats && !seats.unlimited && seats.used >= seats.granted;  const showUpgradeOnInvite = atSeatLimit && !!props.upgradeAction;   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 (    <PageContainer>      <PageHeader title="Organization" />       <section className="mb-10">        <div className="flex items-end gap-3">          <div className="min-w-0 flex-1">            <Label htmlFor="org-name" className="text-sm font-medium text-foreground">              Organization name            </Label>            <Input              id="org-name"              value={editName}              onChange={(e) => setEditName((e.target as HTMLInputElement).value)}              onKeyDown={(e) => {                if (e.key === "Enter") handleSaveName();              }}              className="mt-1.5 h-9 text-sm"            />          </div>          {editName.trim() !== organizationName && editName.trim() !== "" && (            <Button size="sm" onClick={handleSaveName} disabled={savingName}>              {savingName ? "Saving…" : "Save"}            </Button>          )}        </div>      </section>      {props.domainsSection && props.domainsSection}       <section className="mb-10">        <div className="flex items-center justify-between mb-4">          <div>            <h2 className="text-sm font-medium text-foreground">Members</h2>            <p className="mt-0.5 text-sm text-muted-foreground">              People with access to this Executor instance.              {seats && !seats.unlimited && ` ${seats.used} of ${seats.granted} seats used.`}            </p>          </div>          <Button            size="sm"            className="min-w-32"            onClick={() => (showUpgradeOnInvite ? setUpgradeOpen(true) : setInviteOpen(true))}          >            Invite member          </Button>        </div>        <Input          type="text"          placeholder="Search by name or email..."          value={search}          onChange={(e) => setSearch((e.target as HTMLInputElement).value)}          className="mb-3 h-9 text-sm"        />         {AsyncResult.match(membersResult, {          onInitial: () => (            <div className="space-y-2">              {[1, 2, 3].map((i) => (                <div key={i} className="h-14 animate-pulse rounded-lg bg-muted" />              ))}            </div>          ),          onFailure: () => (            <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">              <p className="text-sm text-destructive">Failed to load members</p>            </div>          ),          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 (                <p className="py-8 text-center text-sm text-muted-foreground">                  {search ? "No matching members" : "No members yet"}                </p>              );            }             return (              <div className="space-y-px">                {filtered.map((member: MemberData) => (                  <div                    key={member.id}                    className="group relative grid grid-cols-[2rem_1fr_6rem_5rem_2rem] items-center gap-3 rounded-lg border border-transparent px-4 py-3 transition-all hover:bg-muted/30"                  >                    {member.avatarUrl ? (                      <img src={member.avatarUrl} alt="" className="size-8 rounded-full" />                    ) : (                      <div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">                        {member.name                          ? member.name                              .split(" ")                              .map((n: string) => n[0])                              .join("")                              .slice(0, 2)                              .toUpperCase()                          : member.email[0]!.toUpperCase()}                      </div>                    )}                     <div className="min-w-0">                      <div className="flex items-center gap-2">                        <p className="truncate text-sm font-medium text-foreground leading-none">                          {member.name ?? member.email}                        </p>                        {member.isCurrentUser && (                          <Badge className="bg-muted text-muted-foreground">You</Badge>                        )}                        {member.status === "pending" && (                          <Badge className="bg-muted text-muted-foreground">Invited</Badge>                        )}                      </div>                      {member.name && (                        <p className="mt-0.5 truncate text-xs text-muted-foreground leading-none">                          {member.email}                        </p>                      )}                    </div>                     <p className="text-sm text-muted-foreground capitalize leading-none">                      {member.role}                    </p>                     <p className="text-xs text-muted-foreground leading-none">                      {formatLastActive(member.lastActiveAt)}                    </p>                     {!member.isCurrentUser ? (                      <DropdownMenu>                        <DropdownMenuTrigger asChild>                          <Button                            variant="ghost"                            size="icon"                            className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"                          >                            <svg viewBox="0 0 16 16" className="size-3">                              <circle cx="8" cy="3" r="1.2" fill="currentColor" />                              <circle cx="8" cy="8" r="1.2" fill="currentColor" />                              <circle cx="8" cy="13" r="1.2" fill="currentColor" />                            </svg>                          </Button>                        </DropdownMenuTrigger>                        <DropdownMenuContent align="end" className="w-44">                          {roles.length > 0 && (                            <>                              <DropdownMenuSub>                                <DropdownMenuSubTrigger className="text-xs">                                  Change role                                </DropdownMenuSubTrigger>                                <DropdownMenuSubContent>                                  {roles.map((role: RoleData) => (                                    <DropdownMenuItem                                      key={role.slug}                                      className="text-xs"                                      disabled={role.slug === member.role}                                      onClick={() =>                                        handleChangeRole(member.id, role.slug, role.name)                                      }                                    >                                      {role.name}                                    </DropdownMenuItem>                                  ))}                                </DropdownMenuSubContent>                              </DropdownMenuSub>                              <DropdownMenuSeparator />                            </>                          )}                          <DropdownMenuItem                            className="text-destructive focus:text-destructive text-sm"                            onClick={() => handleRemove(member.id, member.name ?? member.email)}                          >                            Remove member                          </DropdownMenuItem>                        </DropdownMenuContent>                      </DropdownMenu>                    ) : (                      <div />                    )}                  </div>                ))}              </div>            );          },        })}      </section>       <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} roles={roles} />      <UpgradeDialog        open={upgradeOpen}        onOpenChange={setUpgradeOpen}        granted={seats?.granted ?? 0}        upgradeAction={props.upgradeAction}      />    </PageContainer>  );} // Shown when the org is at its member-seat limit: a clear "you're at the limit"// prompt with the plan-upgrade call to action cloud injects (a link to billing).function UpgradeDialog(props: {  open: boolean;  onOpenChange: (v: boolean) => void;  granted: number;  upgradeAction?: React.ReactNode;}) {  return (    <Dialog open={props.open} onOpenChange={props.onOpenChange}>      <DialogContent className="sm:max-w-[400px]">        <DialogHeader>          <DialogTitle className="font-display text-xl">You are at your member limit</DialogTitle>          <DialogDescription className="text-sm leading-relaxed">            Your plan includes {props.granted} member{props.granted === 1 ? "" : "s"}. Upgrade your            plan to invite more.          </DialogDescription>        </DialogHeader>        <DialogFooter>          <DialogClose asChild>            <Button variant="ghost" size="sm">              Cancel            </Button>          </DialogClose>          {props.upgradeAction}        </DialogFooter>      </DialogContent>    </Dialog>  );} 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;    }    // Surface a server-provided reason (e.g. the plan's member-seat limit, a    // 403 AccountForbidden carrying a message) so the admin knows WHY and that    // retrying will not help. Transient/untyped failures fall back to the    // generic retry copy.    dispatch({ type: "error", message: messageFromExit(exit, GENERIC_INVITE_ERROR) });  };   return (    <Dialog      open={props.open}      onOpenChange={(v) => {        if (!v) dispatch({ type: "reset" });        props.onOpenChange(v);      }}    >      <DialogContent className="sm:max-w-[400px]">        <DialogHeader>          <DialogTitle className="font-display text-xl">Invite member</DialogTitle>          <DialogDescription className="text-sm leading-relaxed">            Send an email invitation to join your organization.          </DialogDescription>        </DialogHeader>         <div className="grid gap-4 py-3">          <div className="grid gap-1.5">

Lines 1–500 of 577.