298 lines · 9.9 KB · TypeScript
RawDownload
import { useState } from "react";import { useAtomValue, useAtomSet } from "@effect/atom-react";import { useNavigate } from "@tanstack/react-router";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import * as Exit from "effect/Exit";import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";import { trackEvent } from "@executor-js/react/api/analytics";import { Button } from "@executor-js/react/components/button"; import { AUTH_PATHS } from "../../auth/api";import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth";import {  CreateOrganizationFields,  useCreateOrganizationForm,} from "../components/create-organization-form"; type PendingInvitation = {  id: string;  organizationId: string;  organizationName: string;  createdAt: string;  inviter: { email: string; name: string | null } | null;}; const formatRelativeTime = (iso: string): string => {  const then = new Date(iso).getTime();  if (Number.isNaN(then)) return "";  const diffMs = Date.now() - then;  const min = Math.floor(diffMs / 60_000);  if (min < 1) return "just now";  if (min < 60) return `${min}m ago`;  const hrs = Math.floor(min / 60);  if (hrs < 24) return `${hrs}h ago`;  const days = Math.floor(hrs / 24);  if (days < 7) return `${days}d ago`;  const weeks = Math.floor(days / 7);  if (weeks < 5) return `${weeks}w ago`;  const months = Math.floor(days / 30);  if (months < 12) return `${months}mo ago`;  return `${Math.floor(days / 365)}y ago`;}; export const CreateOrgPage = () => {  const auth = useAuth();  const navigate = useNavigate();  const invitationsResult = useAtomValue(pendingInvitationsAtom);  const doAccept = useAtomSet(acceptInvitation, { mode: "promiseExit" });   const [acceptingId, setAcceptingId] = useState<string | null>(null);  const [errorByInvitationId, setErrorByInvitationId] = useState<Record<string, string>>({});   const handleAccept = async (invitation: PendingInvitation) => {    setAcceptingId(invitation.id);    setErrorByInvitationId((prev) => {      if (!(invitation.id in prev)) return prev;      const next = { ...prev };      delete next[invitation.id];      return next;    });    const exit = await doAccept({      payload: { invitationId: invitation.id },      reactivityKeys: authWriteKeys,    });    setAcceptingId(null);    trackEvent("org_invitation_accepted", { success: Exit.isSuccess(exit) });    if (!Exit.isSuccess(exit)) {      setErrorByInvitationId((prev) => ({        ...prev,        [invitation.id]: "Couldn't accept this invitation. Try again or ask the inviter to resend.",      }));    }  };   const suggestedName =    auth.status === "authenticated" && auth.user.name != null && auth.user.name.trim() !== ""      ? `${auth.user.name}'s Organization`      : "";   const form = useCreateOrganizationForm({    defaultName: suggestedName,    onSuccess: () => {      void navigate({ to: "/setup-mcp" });    },  });   // Almost no new user has a pending invitation, so we never show a loading  // placeholder for them — the create form renders immediately and stays put.  // Invitations (when they exist) just appear above it once the fetch resolves;  // a refetch (the create-org submit invalidates the auth-keyed atom) keeps the  // last value, so nothing flickers.  const invitations = AsyncResult.match(invitationsResult, {    onInitial: () => [] as readonly PendingInvitation[],    onFailure: () => [] as readonly PendingInvitation[],    onSuccess: ({ value }) => value.invitations,  });   const count = invitations.length;  const sole = count === 1 ? invitations[0]! : null;   return (    <div className="flex min-h-screen items-center justify-center bg-background px-4">      <div className="mx-auto flex w-full max-w-sm flex-col gap-6">        <header className="flex flex-col gap-2">          <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">            Step 1 of 2          </p>          <h1 className="font-sans text-3xl font-semibold">            {count > 0 ? "You've been invited" : "Create your organization"}          </h1>          {count === 0 && (            <p className="text-sm text-muted-foreground">              Organizations group your sources, secrets, and teammates. You can invite others once              it's set up.            </p>          )}        </header>         {sole && (          <SingleInvitationView            invitation={sole}            accepting={acceptingId === sole.id}            error={errorByInvitationId[sole.id] ?? null}            onAccept={() => void handleAccept(sole)}          />        )}         {count > 1 && (          <MultiInvitationsView            invitations={invitations}            acceptingId={acceptingId}            errorByInvitationId={errorByInvitationId}            onAccept={(inv) => void handleAccept(inv)}          />        )}         <CreateOrgSection isPrimary={count === 0} form={form} />         <footer className="flex items-center justify-center">          {/* oxlint-disable-next-line react/forbid-elements */}          <button            type="button"            className="text-xs text-muted-foreground transition-colors hover:text-foreground"            onClick={async () => {              await fetch(AUTH_PATHS.logout, { method: "POST" });              window.location.href = "/";            }}          >            Sign out          </button>        </footer>      </div>    </div>  );}; // --------------------------------------------------------------------------- const InviterAttribution = ({ invitation }: { invitation: PendingInvitation }) => {  const inviterLabel = invitation.inviter    ? invitation.inviter.name && invitation.inviter.name.length > 0      ? `${invitation.inviter.name} (${invitation.inviter.email})`      : invitation.inviter.email    : null;  const time = formatRelativeTime(invitation.createdAt);  return (    <p className="truncate text-xs text-muted-foreground">      {inviterLabel ? (        <>          Invited by <span className="text-foreground/80">{inviterLabel}</span>          {time && <span> · {time}</span>}        </>      ) : (        <>Invited{time ? ` ${time}` : ""}</>      )}    </p>  );}; // --------------------------------------------------------------------------- const SingleInvitationView = ({  invitation,  accepting,  error,  onAccept,}: {  invitation: PendingInvitation;  accepting: boolean;  error: string | null;  onAccept: () => void;}) => (  <section aria-label="Invitation" className="flex flex-col gap-3">    <div className="flex flex-col gap-0.5 rounded-md border border-border px-3 py-2.5">      <p className="truncate text-sm font-medium leading-tight">{invitation.organizationName}</p>      <InviterAttribution invitation={invitation} />    </div>     {error && <p className="text-xs text-destructive">{error}</p>}     <Button onClick={onAccept} disabled={accepting} className="w-full" size="sm">      {accepting ? "Joining…" : "Accept invitation"}    </Button>  </section>); // --------------------------------------------------------------------------- const MultiInvitationsView = ({  invitations,  acceptingId,  errorByInvitationId,  onAccept,}: {  invitations: readonly PendingInvitation[];  acceptingId: string | null;  errorByInvitationId: Record<string, string>;  onAccept: (invitation: PendingInvitation) => void;}) => (  <section aria-label="Invitations" className="flex flex-col gap-2">    <ul className="flex flex-col gap-2">      {invitations.map((invitation) => {        const isAccepting = acceptingId === invitation.id;        const isOtherAccepting = acceptingId !== null && !isAccepting;        const error = errorByInvitationId[invitation.id] ?? null;        return (          <li key={invitation.id} className="flex flex-col gap-1.5">            <div className="flex items-center gap-3 rounded-md border border-border px-3 py-2.5">              <div className="min-w-0 flex-1">                <p className="truncate text-sm font-medium leading-tight">                  {invitation.organizationName}                </p>                <InviterAttribution invitation={invitation} />              </div>              <Button                size="sm"                onClick={() => onAccept(invitation)}                disabled={isAccepting || isOtherAccepting}              >                {isAccepting ? "Joining…" : "Accept"}              </Button>            </div>            {error && <p className="text-xs text-destructive">{error}</p>}          </li>        );      })}    </ul>  </section>); // --------------------------------------------------------------------------- const CreateOrgSection = ({  isPrimary,  form,}: {  isPrimary: boolean;  form: ReturnType<typeof useCreateOrganizationForm>;}) => {  const [expanded, setExpanded] = useState(isPrimary);   if (!isPrimary && !expanded) {    return (      <div className="text-center text-xs text-muted-foreground">        {/* oxlint-disable-next-line react/forbid-elements */}        <button          type="button"          onClick={() => setExpanded(true)}          className="underline-offset-4 transition-colors hover:text-foreground hover:underline"        >          Or create a new organization        </button>      </div>    );  }   return (    <section aria-label="Create organization" className="flex flex-col gap-3">      <CreateOrganizationFields        name={form.name}        onNameChange={(name) => {          form.setName(name);          if (form.error) form.setError(null);        }}        error={form.error}        onSubmit={() => void form.submit()}      />      <div className="flex justify-end">        <Button          size="sm"          onClick={() => void form.submit()}          disabled={!form.canSubmit || form.creating}        >          {form.creating ? "Creating…" : "Create organization"}        </Button>      </div>    </section>  );};