import { useAuth } from "@clerk/react";
import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts";
import { CheckIcon } from "lucide-react";
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 { cn } from "~/lib/utils";
import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "../ui/dialog";
import { Switch } from "../ui/switch";
import { toastManager } from "../ui/toast";
/**
* 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 ;
}
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 = showPublishStep
? ["publish", "devices"]
: ["devices"];
const [requestedAccount, setRequestedAccount] = useState(null);
const [openForAccount, setOpenForAccount] = useState(null);
const [step, setStep] = useState("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(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 (
);
}
const STEP_LABELS: Record = {
publish: "Publish",
devices: "Connect devices",
};
function OnboardingStepper({
steps,
currentStep,
disabled,
onStepSelect,
}: {
readonly steps: ReadonlyArray;
readonly currentStep: OnboardingStep;
readonly disabled: boolean;
readonly onStepSelect: (step: OnboardingStep) => void;
}) {
const currentIndex = steps.indexOf(currentStep);
return (