1import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary";
2import { useAuth } from "@clerk/react";
3import { useAtomValue } from "@effect/atom-react";
4import { AuthRelayReadScope, AuthRelayWriteScope, type AuthSessionState } from "@t3tools/contracts";
5import { AsyncResult, Atom } from "effect/reactivity";
6import { useEffect, useRef, useState } from "react";
7
8import {
9 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
10 ConnectOnboardingOptOutSchema,
11 EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
12} from "~/cloud/connectOnboarding";
13import { hasCloudPublicConfig } from "~/cloud/publicConfig";
14import { useCloudLinkController } from "~/cloud/useCloudLinkController";
15import { useLocalStorage } from "~/hooks/useLocalStorage";
16import {
17 useEnvironments,
18 usePrimaryEnvironment,
19 usePrimaryEnvironmentId,
20} from "~/state/environments";
21import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session";
22import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
23import { Button } from "../ui/button";
24import { Checkbox } from "../ui/checkbox";
25import { Dialog } from "../ui/dialog";
26import { Switch } from "../ui/switch";
27import { toastManager } from "../ui/toast";
28import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard";
29
30/**
31 * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session
32 * sign-in — sign-out removes the connected relay environments, so each new
33 * session starts with no devices to reach. It first prompts to publish this
34 * environment (managed tunnel + agent activity, both defaulting on) when the
35 * current session is authorized to manage the relay link, then lists the
36 * account's T3 Connect environments so every device can be connected right
37 * away. A cold load with a restored session does not count as a sign-in.
38 */
39export function ConnectOnboardingDialog() {
40 if (!hasCloudPublicConfig()) return null;
41
42 return (
43 <CloudAuthBoundary>
44 <ConfiguredConnectOnboardingDialog />
45 </CloudAuthBoundary>
46 );
47}
48
49type OnboardingStep = "publish" | "devices";
50
51const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>());
52
53function ConfiguredConnectOnboardingDialog() {
54 // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as
55 // signed-out, or its later activation would look like a fresh sign-in.
56 const { isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
57 const [optOutState, setOptOutState] = useLocalStorage(
58 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
59 EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
60 ConnectOnboardingOptOutSchema,
61 );
62
63 const { isReady: environmentsReady } = useEnvironments();
64 const primaryEnvironmentId = usePrimaryEnvironmentId();
65 const primarySessionState = useAtomValue(
66 primaryEnvironmentId === null
67 ? EMPTY_SESSION_STATE_ATOM
68 : environmentSession.sessionStateAtom(primaryEnvironmentId),
69 );
70 const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope);
71 const canWriteRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope);
72 const canManageRelay = canReadRelay && canWriteRelay;
73 // The publish step is only offered when we know the answer; opening the
74 // wizard before the session state resolves would let the step set change
75 // mid-flight. A failed session read still opens the wizard — it just means
76 // no publish step.
77 const sessionScopesKnown =
78 primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial";
79
80 const controller = useCloudLinkController();
81 const showPublishStep = canManageRelay && controller.linkState.target !== null;
82 const steps: ReadonlyArray<OnboardingStep> = showPublishStep
83 ? ["publish", "devices"]
84 : ["devices"];
85
86 const [requestedAccount, setRequestedAccount] = useState<string | null>(null);
87 const [openForAccount, setOpenForAccount] = useState<string | null>(null);
88 const [step, setStep] = useState<OnboardingStep>("devices");
89 const [exposeEnvironment, setExposeEnvironment] = useState(true);
90 const [publishAgentActivity, setPublishAgentActivity] = useState(true);
91 const [dontShowAgain, setDontShowAgain] = useState(false);
92 const [isApplying, setIsApplying] = useState(false);
93 const prefilledFromLinkStateRef = useRef(false);
94 const observedAccountRef = useRef<string | null | undefined>(undefined);
95
96 useEffect(() => {
97 if (step === "publish" && sessionScopesKnown && !showPublishStep && !isApplying) {
98 setStep("devices");
99 }
100 }, [isApplying, sessionScopesKnown, showPublishStep, step]);
101
102 const optOutAccounts = optOutState.optOutAccounts;
103
104 // Every sign-in or account switch that completes during this session
105 // requests the wizard — account transitions clear the connected relay
106 // environments, so each new session starts with no devices to reach. A cold
107 // load observes undefined → account and must not re-prompt.
108 useEffect(() => {
109 if (!isLoaded) return;
110 // A loaded-but-incomplete snapshot (signed in, user id not yet populated)
111 // must not be recorded as signed-out — the next render would then look
112 // like a fresh sign-in on a cold load.
113 if (isSignedIn && !userId) return;
114 const previousAccount = observedAccountRef.current;
115 const nextAccount = isSignedIn && userId ? userId : null;
116 observedAccountRef.current = nextAccount;
117 if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) {
118 setRequestedAccount(nextAccount);
119 }
120 }, [isLoaded, isSignedIn, userId]);
121
122 // A manageable session implies a primary environment, so when the scopes
123 // allow publishing, wait for the connection target too — otherwise the
124 // wizard could open on the devices step moments before the publish step
125 // becomes available and freeze there.
126 const publishStepDecided = !canManageRelay || controller.linkState.target !== null;
127
128 // Open once the session scopes resolve so the step set is stable. Accounts
129 // that chose "Don't show this again" are skipped.
130 useEffect(() => {
131 if (requestedAccount === null || openForAccount !== null) return;
132 if (optOutAccounts.includes(requestedAccount)) {
133 setRequestedAccount(null);
134 return;
135 }
136 if (!sessionScopesKnown || !publishStepDecided) return;
137 setRequestedAccount(null);
138 prefilledFromLinkStateRef.current = false;
139 setExposeEnvironment(true);
140 setPublishAgentActivity(true);
141 setDontShowAgain(false);
142 setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices");
143 setOpenForAccount(requestedAccount);
144 }, [
145 canManageRelay,
146 controller.linkState.target,
147 openForAccount,
148 optOutAccounts,
149 publishStepDecided,
150 requestedAccount,
151 sessionScopesKnown,
152 ]);
153
154 // Signing out (or switching accounts) mid-wizard invalidates everything the
155 // wizard would do — close it and let the sign-in trigger re-evaluate.
156 useEffect(() => {
157 if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) {
158 setOpenForAccount(null);
159 }
160 if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) {
161 setRequestedAccount(null);
162 }
163 }, [isSignedIn, openForAccount, requestedAccount, userId]);
164
165 // Toggles default on, but an environment that is already linked should show
166 // its actual configuration instead of silently proposing to rewrite it.
167 // Only when the link belongs to the account being onboarded, though — after
168 // an account switch the cached link state can still describe the previous
169 // account's setup.
170 const linkStateData = controller.linkState.data;
171 useEffect(() => {
172 if (openForAccount === null || prefilledFromLinkStateRef.current || linkStateData === null) {
173 return;
174 }
175 prefilledFromLinkStateRef.current = true;
176 if (
177 linkStateData.linked &&
178 linkStateData.linkRevoked !== true &&
179 linkStateData.cloudUserId === openForAccount
180 ) {
181 setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked);
182 setPublishAgentActivity(linkStateData.publishAgentActivity);
183 }
184 }, [linkStateData, openForAccount]);
185
186 const complete = () => {
187 // Keep the wizard up while a link request is in flight so its outcome
188 // (and any failure) stays visible.
189 if (isApplying) return;
190 const account = openForAccount;
191 setOpenForAccount(null);
192 if (account !== null && dontShowAgain) {
193 setOptOutState((state) =>
194 state.optOutAccounts.includes(account)
195 ? state
196 : { optOutAccounts: [...state.optOutAccounts, account] },
197 );
198 }
199 };
200
201 const applyPublishSelection = async () => {
202 if (isApplying) return;
203 if (
204 primaryEnvironmentId === null ||
205 !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) ||
206 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
207 ) {
208 setStep("devices");
209 return;
210 }
211 if (linkStateData === null || controller.linkState.error !== null) return;
212 // The wizard only ever enables — with both toggles off there is nothing to
213 // apply, and an existing link must not be torn down from onboarding.
214 if (!exposeEnvironment && !publishAgentActivity) {
215 setStep("devices");
216 return;
217 }
218 setIsApplying(true);
219 const ok = await controller.reconcileCloudState({
220 managedTunnel: exposeEnvironment,
221 publish: publishAgentActivity,
222 });
223 setIsApplying(false);
224 if (!ok) return;
225 toastManager.add({
226 type: "success",
227 title: "T3 Connect enabled",
228 description: exposeEnvironment
229 ? "This environment is available to your other devices through T3 Connect."
230 : "This environment publishes agent activity to your mobile clients.",
231 });
232 setStep("devices");
233 };
234
235 return (
236 <Dialog
237 open={openForAccount !== null}
238 onOpenChange={(open) => {
239 // Keep the dialog up while a link request is in flight so its outcome
240 // (and any failure) stays visible.
241 if (!open && !isApplying) complete();
242 }}
243 >
244 <WizardPopup>
245 <WizardHeader
246 title="Set up T3 Connect"
247 description={
248 <>
249 Mesh your devices together — publish this environment and connect the rest, all in one
250 place.
251 </>
252 }
253 >
254 {steps.length > 1 ? (
255 <WizardSteps
256 steps={steps.map((id) => STEP_LABELS[id])}
257 currentStep={steps.indexOf(step)}
258 isStepDisabled={() => isApplying}
259 onStepChange={(index) => {
260 const next = steps[index];
261 if (
262 next === "publish" &&
263 (primaryEnvironmentId === null ||
264 !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) ||
265 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope))
266 )
267 return;
268 if (next) setStep(next);
269 }}
270 />
271 ) : null}
272 </WizardHeader>
273 <WizardPanel>
274 {step === "publish" ? (
275 <PublishStep
276 exposeEnvironment={exposeEnvironment}
277 publishAgentActivity={publishAgentActivity}
278 disabled={isApplying || !canManageRelay || linkStateData === null}
279 operationError={controller.operationError ?? controller.linkState.error}
280 onExposeEnvironmentChange={(enabled) => {
281 if (
282 primaryEnvironmentId !== null &&
283 readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) &&
284 readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
285 )
286 setExposeEnvironment(enabled);
287 }}
288 onPublishAgentActivityChange={(enabled) => {
289 if (
290 primaryEnvironmentId !== null &&
291 readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) &&
292 readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
293 )
294 setPublishAgentActivity(enabled);
295 }}
296 />
297 ) : (
298 <DevicesStep />
299 )}
300 </WizardPanel>
301 <WizardFooter
302 leading={
303 <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
304 <Checkbox
305 checked={dontShowAgain}
306 onCheckedChange={(checked) => setDontShowAgain(checked === true)}
307 />
308 Don&apos;t show this again
309 </label>
310 }
311 >
312 {step === "publish" ? (
313 <>
314 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
315 Not now
316 </Button>
317 <Button
318 disabled={isApplying || !canManageRelay || linkStateData === null}
319 onClick={() => void applyPublishSelection()}
320 >
321 {isApplying ? "Enabling…" : "Continue"}
322 </Button>
323 </>
324 ) : (
325 <Button disabled={isApplying} onClick={complete}>
326 Done
327 </Button>
328 )}
329 </WizardFooter>
330 </WizardPopup>
331 </Dialog>
332 );
333}
334
335const STEP_LABELS: Record<OnboardingStep, string> = {
336 publish: "Publish",
337 devices: "Connect devices",
338};
339
340function PublishStep({
341 exposeEnvironment,
342 publishAgentActivity,
343 disabled,
344 operationError,
345 onExposeEnvironmentChange,
346 onPublishAgentActivityChange,
347}: {
348 readonly exposeEnvironment: boolean;
349 readonly publishAgentActivity: boolean;
350 readonly disabled: boolean;
351 readonly operationError: string | null;
352 readonly onExposeEnvironmentChange: (enabled: boolean) => void;
353 readonly onPublishAgentActivityChange: (enabled: boolean) => void;
354}) {
355 return (
356 <div className="space-y-3">
357 <div className="rounded-lg border">
358 <OnboardingToggleRow
359 title="Publish this environment"
360 description="Make this environment available to your other devices through T3 Connect."
361 checked={exposeEnvironment}
362 disabled={disabled}
363 onCheckedChange={onExposeEnvironmentChange}
364 />
365 <OnboardingToggleRow
366 title="Publish agent activity"
367 description="Send activity from this environment to your mobile clients for push notifications and Live Activities."
368 checked={publishAgentActivity}
369 disabled={disabled}
370 onCheckedChange={onPublishAgentActivityChange}
371 />
372 </div>
373 {operationError ? <p className="text-xs text-destructive">{operationError}</p> : null}
374 </div>
375 );
376}
377
378function OnboardingToggleRow({
379 title,
380 description,
381 checked,
382 disabled,
383 onCheckedChange,
384}: {
385 readonly title: string;
386 readonly description: string;
387 readonly checked: boolean;
388 readonly disabled: boolean;
389 readonly onCheckedChange: (enabled: boolean) => void;
390}) {
391 return (
392 <div className="flex items-start justify-between gap-4 border-t border-border/60 px-4 py-3 first:border-t-0">
393 <div className="min-w-0">
394 <p className="text-sm font-medium">{title}</p>
395 <p className="mt-1 text-xs text-muted-foreground">{description}</p>
396 </div>
397 <Switch
398 aria-label={title}
399 checked={checked}
400 disabled={disabled}
401 onCheckedChange={onCheckedChange}
402 />
403 </div>
404 );
405}
406
407function DevicesStep() {
408 const { environments } = useEnvironments();
409 const primaryEnvironment = usePrimaryEnvironment();
410 const savedEnvironments = environments.filter(
411 (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget",
412 );
413
414 return (
415 <div className="overflow-hidden rounded-lg border">
416 <CloudEnvironmentConnectRows
417 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
418 savedEnvironments={savedEnvironments}
419 showSavedEnvironments
420 empty={
421 <p className="px-4 py-6 text-center text-sm text-muted-foreground">
422 No other environments are published to your account yet. Publish one from another device
423 and it will show up here.
424 </p>
425 }
426 />
427 </div>
428 );
429}