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 ( { // Keep the dialog up while a link request is in flight so its outcome // (and any failure) stays visible. if (!open && !isApplying) complete(); }} > Set up T3 Connect Mesh your devices together — publish this environment and connect the rest, all in one place. {steps.length > 1 ? ( ) : null} {step === "publish" ? ( ) : ( )}
{step === "publish" ? ( <> ) : ( )}
); } 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 (
{steps.map((step, index) => ( ))}
); } 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 (
{operationError ?

{operationError}

: null}
); } 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 (

{title}

{description}

); } function DevicesStep() { const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const savedEnvironments = environments.filter( (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", ); return (
No other environments are published to your account yet. Publish one from another device and it will show up here.

} />
); }