377 lines · 13.6 KB · TypeScript
RawDownload
import { useMemo, useState } from "react";import {  AuthTemplateSlug,  IntegrationSlug,  type AuthMethodDescriptor,  type Connection,  type Owner,} from "@executor-js/sdk/shared";import { CheckCircle2Icon, RotateCcwIcon, XCircleIcon } from "lucide-react";import { toast } from "sonner"; import { useOrganizationId } from "../api/organization-context";import { normalizeEmail, type ProviderAccount } from "../lib/provider-accounts";import { cn } from "../lib/utils";import { useOAuthPopupFlow, type OAuthStartPayload } from "../plugins/oauth-sign-in";import { Button } from "./button";import { Badge } from "./badge";import { Checkbox } from "./checkbox";import {  Dialog,  DialogContent,  DialogDescription,  DialogFooter,  DialogHeader,  DialogTitle,} from "./dialog";import { Label } from "./label";import { connectionLabelForHost, connectionNameFrom } from "./add-account-modal"; export type EnableServiceIntegration = {  readonly slug: IntegrationSlug;  readonly name: string;  readonly kind: string;  readonly authMethods: readonly AuthMethodDescriptor[];}; export type EnableServiceStepStatus = "pending" | "done" | "failed"; export type EnableServiceQueueStep<TIntegration extends EnableServiceIntegration> = {  readonly integration: TIntegration;  readonly status: EnableServiceStepStatus;}; export type EnableServiceQueue<TIntegration extends EnableServiceIntegration> = {  readonly steps: readonly EnableServiceQueueStep<TIntegration>[];  readonly activeIndex: number;}; export const createEnableServicesQueue = <TIntegration extends EnableServiceIntegration>(  integrations: readonly TIntegration[],): EnableServiceQueue<TIntegration> => ({  steps: integrations.map((integration) => ({ integration, status: "pending" })),  activeIndex: 0,}); const nextPendingIndex = <TIntegration extends EnableServiceIntegration>(  steps: readonly EnableServiceQueueStep<TIntegration>[],  from: number,): number => {  const next = steps.findIndex((step, index) => index > from && step.status !== "done");  return next === -1 ? steps.length : next;}; export const applyEnableServiceStepResult = <TIntegration extends EnableServiceIntegration>(  queue: EnableServiceQueue<TIntegration>,  slug: IntegrationSlug,  status: EnableServiceStepStatus,): EnableServiceQueue<TIntegration> => {  const stepIndex = queue.steps.findIndex((step) => step.integration.slug === slug);  if (stepIndex === -1) return queue;  const steps = queue.steps.map((step, index) =>    index === stepIndex ? { ...step, status } : step,  );  return {    steps,    activeIndex:      status === "done" && stepIndex === queue.activeIndex        ? nextPendingIndex(steps, stepIndex)        : queue.activeIndex,  };}; export const retryEnableServiceStep = <TIntegration extends EnableServiceIntegration>(  queue: EnableServiceQueue<TIntegration>,  slug: IntegrationSlug,): EnableServiceQueue<TIntegration> => {  const stepIndex = queue.steps.findIndex((step) => step.integration.slug === slug);  if (stepIndex === -1) return queue;  return {    steps: queue.steps.map((step, index) =>      index === stepIndex ? { ...step, status: "pending" } : step,    ),    activeIndex: stepIndex,  };}; export const activeEnableServiceStep = <TIntegration extends EnableServiceIntegration>(  queue: EnableServiceQueue<TIntegration>,): EnableServiceQueueStep<TIntegration> | null => queue.steps[queue.activeIndex] ?? null; const oauthMethodFor = (integration: EnableServiceIntegration): AuthMethodDescriptor | null =>  integration.authMethods.find((method) => method.kind === "oauth") ?? null; const mirrorConnectionFor = (  account: ProviderAccount<Connection, EnableServiceIntegration>,): Connection | null =>  account.connections.find((entry) => entry.connection.oauthClient != null)?.connection ?? null; export const buildEnableServiceOAuthStartPayload = (input: {  readonly account: ProviderAccount<Connection, EnableServiceIntegration>;  readonly integration: EnableServiceIntegration;  readonly organizationId: string | null;}): OAuthStartPayload | null => {  const loginHint = normalizeEmail(input.account.label);  if (loginHint === null) return null;  const method = oauthMethodFor(input.integration);  if (!method) return null;  const mirrored = mirrorConnectionFor(input.account);  if (!mirrored?.oauthClient) return null;  const owner = input.account.owner as Owner;  // Seed the label with the account email's localpart ("rhys Google Calendar")  // so two same-owner accounts enabling the same service don't collide on  // (owner, integration, name) and clobber each other's connection.  const accountLabel = `${loginHint.split("@")[0] ?? ""} ${input.integration.name}`;  const identityLabel = connectionLabelForHost(    accountLabel,    owner,    input.integration.name,    input.organizationId,  );   return {    client: mirrored.oauthClient,    clientOwner: mirrored.oauthClientOwner ?? mirrored.owner,    owner,    name: connectionNameFrom(accountLabel, owner, input.integration.name, input.organizationId),    integration: input.integration.slug,    template: AuthTemplateSlug.make(method.template),    identityLabel,    loginHint,  };}; /** Run one queue step: build the payload and open exactly one OAuth popup. *  The completion callbacks only record the step result; advancing to the next *  service always requires a fresh Continue click, so a finishing popup can *  never auto-open the next one. */export const continueEnableServiceStep = (input: {  readonly account: ProviderAccount<Connection, EnableServiceIntegration>;  readonly integration: EnableServiceIntegration;  readonly organizationId: string | null;  readonly start: (input: {    readonly payload: OAuthStartPayload;    readonly onSuccess: () => void;    readonly onError: () => void;  }) => void;  readonly markActive: (status: EnableServiceStepStatus) => void;}): void => {  const payload = buildEnableServiceOAuthStartPayload({    account: input.account,    integration: input.integration,    organizationId: input.organizationId,  });  if (!payload) {    input.markActive("failed");    toast.error("This service cannot reuse the selected OAuth app");    return;  }  input.start({    payload,    onSuccess: () => {      input.markActive("done");      toast.success(`${input.integration.name} connected`);    },    onError: () => {      input.markActive("failed");    },  });}; const serviceKey = (integration: EnableServiceIntegration): string => String(integration.slug); export function EnableServicesModal(props: {  readonly open: boolean;  readonly account: ProviderAccount<Connection, EnableServiceIntegration> | null;  readonly integrations: readonly EnableServiceIntegration[];  readonly onOpenChange: (open: boolean) => void;}) {  if (!props.open || !props.account) return null;  return <EnableServicesModalBody {...props} account={props.account} />;} function EnableServicesModalBody(props: {  readonly account: ProviderAccount<Connection, EnableServiceIntegration>;  readonly integrations: readonly EnableServiceIntegration[];  readonly onOpenChange: (open: boolean) => void;  readonly open: boolean;}) {  const organizationId = useOrganizationId();  const defaultSelected = useMemo(    () =>      new Set(        props.integrations          .filter((integration) => oauthMethodFor(integration) !== null)          .map((integration) => serviceKey(integration)),      ),    [props.integrations],  );  const [selected, setSelected] = useState<ReadonlySet<string>>(defaultSelected);  const [queue, setQueue] = useState<EnableServiceQueue<EnableServiceIntegration> | null>(null);  const oauthPopup = useOAuthPopupFlow({    popupName: "enable-provider-service",    detectPopupClosed: false,    startErrorMessage: "Failed to start service connection",  });  const active = queue ? activeEnableServiceStep(queue) : null;  const selectedIntegrations = props.integrations.filter((integration) =>    selected.has(serviceKey(integration)),  );  const complete = queue ? queue.steps.every((step) => step.status === "done") : false;  const loginHint = normalizeEmail(props.account.label);   const toggleSelected = (integration: EnableServiceIntegration, checked: boolean) => {    setSelected((current) => {      const next = new Set(current);      if (checked) next.add(serviceKey(integration));      else next.delete(serviceKey(integration));      return next;    });  };   const startQueue = () => {    setQueue(createEnableServicesQueue(selectedIntegrations));  };   const markActive = (status: EnableServiceStepStatus) => {    if (!queue || !active) return;    setQueue(applyEnableServiceStepResult(queue, active.integration.slug, status));  };   const handleContinue = () => {    if (!active) return;    continueEnableServiceStep({      account: props.account,      integration: active.integration,      organizationId,      start: (input) => void oauthPopup.start(input),      markActive,    });  };   const handleRetry = () => {    if (!queue || !active) return;    setQueue(retryEnableServiceStep(queue, active.integration.slug));  };   return (    <Dialog open={props.open} onOpenChange={props.onOpenChange}>      <DialogContent className="sm:max-w-xl">        <DialogHeader>          <DialogTitle>Add services</DialogTitle>          <DialogDescription>{loginHint ?? props.account.label}</DialogDescription>        </DialogHeader>         {queue === null ? (          <div className="space-y-3">            <div className="rounded-md border border-border">              {props.integrations.map((integration) => {                const key = serviceKey(integration);                const disabled = oauthMethodFor(integration) === null || loginHint === null;                return (                  <Label                    key={key}                    className={cn(                      "flex cursor-pointer items-center gap-3 border-b border-border px-3 py-3 last:border-b-0",                      disabled && "cursor-not-allowed opacity-50",                    )}                  >                    <Checkbox                      checked={selected.has(key)}                      disabled={disabled}                      onCheckedChange={(value) => toggleSelected(integration, value === true)}                    />                    <span className="flex min-w-0 flex-1 flex-col gap-0.5">                      <span className="truncate text-sm font-medium text-foreground">                        {integration.name}                      </span>                      <span className="text-xs text-muted-foreground">                        {disabled ? "OAuth is not available" : "Ready to connect"}                      </span>                    </span>                  </Label>                );              })}            </div>          </div>        ) : (          <div className="space-y-4">            <div className="space-y-2">              {queue.steps.map((step, index) => (                <div                  key={serviceKey(step.integration)}                  className="flex items-center justify-between rounded-md border border-border px-3 py-2"                >                  <div className="min-w-0">                    <p className="truncate text-sm font-medium text-foreground">                      {step.integration.name}                    </p>                    <p className="text-xs text-muted-foreground">                      {index + 1} of {queue.steps.length}                    </p>                  </div>                  {step.status === "done" ? (                    <Badge variant="outline" className="gap-1 text-emerald-700">                      <CheckCircle2Icon />                      Done                    </Badge>                  ) : step.status === "failed" ? (                    <Badge variant="outline" className="gap-1 text-destructive">                      <XCircleIcon />                      Failed                    </Badge>                  ) : (                    <Badge variant="outline">Pending</Badge>                  )}                </div>              ))}            </div>             {complete ? (              <div className="rounded-md border border-border bg-muted/30 px-3 py-3 text-sm text-foreground">                Selected services are connected.              </div>            ) : active ? (              <div className="rounded-md border border-border bg-muted/30 px-3 py-3">                <p className="text-sm font-medium text-foreground">                  Connecting {active.integration.name} ({queue.activeIndex + 1} of{" "}                  {queue.steps.length})                </p>                {active.status === "failed" ? (                  <p className="mt-1 text-xs text-muted-foreground">                    Retry this service when ready.                  </p>                ) : null}              </div>            ) : null}          </div>        )}         <DialogFooter>          {queue === null ? (            <Button              type="button"              onClick={startQueue}              disabled={selectedIntegrations.length === 0 || loginHint === null}            >              Start queue            </Button>          ) : complete ? (            <Button type="button" onClick={() => props.onOpenChange(false)}>              Done            </Button>          ) : active?.status === "failed" ? (            <Button type="button" onClick={handleRetry} variant="outline">              <RotateCcwIcon />              Retry            </Button>          ) : (            <Button type="button" onClick={handleContinue} loading={oauthPopup.busy}>              Continue            </Button>          )}        </DialogFooter>      </DialogContent>    </Dialog>  );}