375 lines · 13.9 KB · TypeScript
RawDownload
import { useAuth } from "@clerk/react";import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts";import { useEffect, useRef, useState } from "react"; import {  CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,  ConnectOnboardingOptOutSchema,  EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,} from "~/cloud/connectOnboarding";import { hasCloudPublicConfig } from "~/cloud/publicConfig";import { useCloudLinkController } from "~/cloud/useCloudLinkController";import { usePrimarySessionState } from "~/environments/primary";import { useLocalStorage } from "~/hooks/useLocalStorage";import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";import { Button } from "../ui/button";import { Checkbox } from "../ui/checkbox";import { Dialog } from "../ui/dialog";import { Switch } from "../ui/switch";import { toastManager } from "../ui/toast";import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard"; /** * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session * sign-in — sign-out removes the connected relay environments, so each new * session starts with no devices to reach. It first prompts to publish this * environment (managed tunnel + agent activity, both defaulting on) when the * current session is authorized to manage the relay link, then lists the * account's T3 Connect environments so every device can be connected right * away. A cold load with a restored session does not count as a sign-in. */export function ConnectOnboardingDialog() {  if (!hasCloudPublicConfig()) return null;   return <ConfiguredConnectOnboardingDialog />;} type OnboardingStep = "publish" | "devices"; function ConfiguredConnectOnboardingDialog() {  // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as  // signed-out, or its later activation would look like a fresh sign-in.  const { isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });  const [optOutState, setOptOutState] = useLocalStorage(    CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,    EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,    ConnectOnboardingOptOutSchema,  );   const desktopBridge = window.desktopBridge;  const primarySessionState = usePrimarySessionState();  const currentSessionScopes = desktopBridge    ? AuthAdministrativeScopes    : primarySessionState.data?.authenticated      ? (primarySessionState.data.scopes ?? null)      : null;  const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;  // The publish step is only offered when we know the answer; opening the  // wizard before the session state resolves would let the step set change  // mid-flight. A failed session read still opens the wizard — it just means  // no publish step.  const sessionScopesKnown =    Boolean(desktopBridge) ||    primarySessionState.data !== null ||    primarySessionState.error !== null;   const controller = useCloudLinkController();  const showPublishStep = canManageRelay && controller.linkState.target !== null;  const steps: ReadonlyArray<OnboardingStep> = showPublishStep    ? ["publish", "devices"]    : ["devices"];   const [requestedAccount, setRequestedAccount] = useState<string | null>(null);  const [openForAccount, setOpenForAccount] = useState<string | null>(null);  const [step, setStep] = useState<OnboardingStep>("devices");  const [exposeEnvironment, setExposeEnvironment] = useState(true);  const [publishAgentActivity, setPublishAgentActivity] = useState(true);  const [dontShowAgain, setDontShowAgain] = useState(false);  const [isApplying, setIsApplying] = useState(false);  const prefilledFromLinkStateRef = useRef(false);  const observedAccountRef = useRef<string | null | undefined>(undefined);   const optOutAccounts = optOutState.optOutAccounts;   // Every sign-in or account switch that completes during this session  // requests the wizard — account transitions clear the connected relay  // environments, so each new session starts with no devices to reach. A cold  // load observes undefined → account and must not re-prompt.  useEffect(() => {    if (!isLoaded) return;    // A loaded-but-incomplete snapshot (signed in, user id not yet populated)    // must not be recorded as signed-out — the next render would then look    // like a fresh sign-in on a cold load.    if (isSignedIn && !userId) return;    const previousAccount = observedAccountRef.current;    const nextAccount = isSignedIn && userId ? userId : null;    observedAccountRef.current = nextAccount;    if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) {      setRequestedAccount(nextAccount);    }  }, [isLoaded, isSignedIn, userId]);   // A manageable session implies a primary environment, so when the scopes  // allow publishing, wait for the connection target too — otherwise the  // wizard could open on the devices step moments before the publish step  // becomes available and freeze there.  const publishStepDecided = !canManageRelay || controller.linkState.target !== null;   // Open once the session scopes resolve so the step set is stable. Accounts  // that chose "Don't show this again" are skipped.  useEffect(() => {    if (requestedAccount === null || openForAccount !== null) return;    if (optOutAccounts.includes(requestedAccount)) {      setRequestedAccount(null);      return;    }    if (!sessionScopesKnown || !publishStepDecided) return;    setRequestedAccount(null);    prefilledFromLinkStateRef.current = false;    setExposeEnvironment(true);    setPublishAgentActivity(true);    setDontShowAgain(false);    setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices");    setOpenForAccount(requestedAccount);  }, [    canManageRelay,    controller.linkState.target,    openForAccount,    optOutAccounts,    publishStepDecided,    requestedAccount,    sessionScopesKnown,  ]);   // Signing out (or switching accounts) mid-wizard invalidates everything the  // wizard would do — close it and let the sign-in trigger re-evaluate.  useEffect(() => {    if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) {      setOpenForAccount(null);    }    if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) {      setRequestedAccount(null);    }  }, [isSignedIn, openForAccount, requestedAccount, userId]);   // Toggles default on, but an environment that is already linked should show  // its actual configuration instead of silently proposing to rewrite it.  // Only when the link belongs to the account being onboarded, though — after  // an account switch the cached link state can still describe the previous  // account's setup.  const linkStateData = controller.linkState.data;  useEffect(() => {    if (openForAccount === null || prefilledFromLinkStateRef.current || linkStateData === null) {      return;    }    prefilledFromLinkStateRef.current = true;    if (linkStateData.linked && linkStateData.cloudUserId === openForAccount) {      setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked);      setPublishAgentActivity(linkStateData.publishAgentActivity);    }  }, [linkStateData, openForAccount]);   const complete = () => {    // Keep the wizard up while a link request is in flight so its outcome    // (and any failure) stays visible.    if (isApplying) return;    const account = openForAccount;    setOpenForAccount(null);    if (account !== null && dontShowAgain) {      setOptOutState((state) =>        state.optOutAccounts.includes(account)          ? state          : { optOutAccounts: [...state.optOutAccounts, account] },      );    }  };   const applyPublishSelection = async () => {    // The wizard only ever enables — with both toggles off there is nothing to    // apply, and an existing link must not be torn down from onboarding.    if (!exposeEnvironment && !publishAgentActivity) {      setStep("devices");      return;    }    setIsApplying(true);    const ok = await controller.reconcileCloudState({      managedTunnel: exposeEnvironment,      publish: publishAgentActivity,    });    setIsApplying(false);    if (!ok) return;    toastManager.add({      type: "success",      title: "T3 Connect enabled",      description: exposeEnvironment        ? "This environment is available to your other devices through T3 Connect."        : "This environment publishes agent activity to your mobile clients.",    });    setStep("devices");  };   return (    <Dialog      open={openForAccount !== null}      onOpenChange={(open) => {        // Keep the dialog up while a link request is in flight so its outcome        // (and any failure) stays visible.        if (!open && !isApplying) complete();      }}    >      <WizardPopup>        <WizardHeader          title="Set up T3 Connect"          description={            <>              Mesh your devices together — publish this environment and connect the rest, all in one              place.            </>          }        >          {steps.length > 1 ? (            <WizardSteps              steps={steps.map((id) => STEP_LABELS[id])}              currentStep={steps.indexOf(step)}              isStepDisabled={() => isApplying}              onStepChange={(index) => {                const next = steps[index];                if (next) setStep(next);              }}            />          ) : null}        </WizardHeader>        <WizardPanel>          {step === "publish" ? (            <PublishStep              exposeEnvironment={exposeEnvironment}              publishAgentActivity={publishAgentActivity}              disabled={isApplying}              operationError={controller.operationError}              onExposeEnvironmentChange={setExposeEnvironment}              onPublishAgentActivityChange={setPublishAgentActivity}            />          ) : (            <DevicesStep />          )}        </WizardPanel>        <WizardFooter          leading={            <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">              <Checkbox                checked={dontShowAgain}                onCheckedChange={(checked) => setDontShowAgain(checked === true)}              />              Don&apos;t show this again            </label>          }        >          {step === "publish" ? (            <>              <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>                Not now              </Button>              <Button                disabled={isApplying || (controller.linkState.isPending && linkStateData === null)}                onClick={() => void applyPublishSelection()}              >                {isApplying ? "Enabling…" : "Continue"}              </Button>            </>          ) : (            <Button disabled={isApplying} onClick={complete}>              Done            </Button>          )}        </WizardFooter>      </WizardPopup>    </Dialog>  );} const STEP_LABELS: Record<OnboardingStep, string> = {  publish: "Publish",  devices: "Connect devices",}; function PublishStep({  exposeEnvironment,  publishAgentActivity,  disabled,  operationError,  onExposeEnvironmentChange,  onPublishAgentActivityChange,}: {  readonly exposeEnvironment: boolean;  readonly publishAgentActivity: boolean;  readonly disabled: boolean;  readonly operationError: string | null;  readonly onExposeEnvironmentChange: (enabled: boolean) => void;  readonly onPublishAgentActivityChange: (enabled: boolean) => void;}) {  return (    <div className="space-y-3">      <div className="rounded-lg border">        <OnboardingToggleRow          title="Publish this environment"          description="Make this environment available to your other devices through T3 Connect."          checked={exposeEnvironment}          disabled={disabled}          onCheckedChange={onExposeEnvironmentChange}        />        <OnboardingToggleRow          title="Publish agent activity"          description="Send activity from this environment to your mobile clients for push notifications and Live Activities."          checked={publishAgentActivity}          disabled={disabled}          onCheckedChange={onPublishAgentActivityChange}        />      </div>      {operationError ? <p className="text-xs text-destructive">{operationError}</p> : null}    </div>  );} function OnboardingToggleRow({  title,  description,  checked,  disabled,  onCheckedChange,}: {  readonly title: string;  readonly description: string;  readonly checked: boolean;  readonly disabled: boolean;  readonly onCheckedChange: (enabled: boolean) => void;}) {  return (    <div className="flex items-start justify-between gap-4 border-t border-border/60 px-4 py-3 first:border-t-0">      <div className="min-w-0">        <p className="text-sm font-medium">{title}</p>        <p className="mt-1 text-xs text-muted-foreground">{description}</p>      </div>      <Switch        aria-label={title}        checked={checked}        disabled={disabled}        onCheckedChange={onCheckedChange}      />    </div>  );} function DevicesStep() {  const { environments } = useEnvironments();  const primaryEnvironment = usePrimaryEnvironment();  const savedEnvironments = environments.filter(    (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget",  );   return (    <div className="overflow-hidden rounded-lg border">      <CloudEnvironmentConnectRows        primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}        savedEnvironments={savedEnvironments}        showSavedEnvironments        empty={          <p className="px-4 py-6 text-center text-sm text-muted-foreground">            No other environments are published to your account yet. Publish one from another device            and it will show up here.          </p>        }      />    </div>  );}