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'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> );}
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'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>
);
}