| 1 | import { CloudAuthBoundary } from "../../cloud/CloudAuthBoundary"; |
| 2 | import { useAuth } from "@clerk/react"; |
| 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | import { AuthRelayReadScope, AuthRelayWriteScope, type AuthSessionState } from "@t3tools/contracts"; |
| 5 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 6 | import { useEffect, useRef, useState } from "react"; |
| 7 | |
| 8 | import { |
| 9 | CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, |
| 10 | ConnectOnboardingOptOutSchema, |
| 11 | EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, |
| 12 | } from "~/cloud/connectOnboarding"; |
| 13 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 14 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; |
| 15 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 16 | import { |
| 17 | useEnvironments, |
| 18 | usePrimaryEnvironment, |
| 19 | usePrimaryEnvironmentId, |
| 20 | } from "~/state/environments"; |
| 21 | import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 22 | import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; |
| 23 | import { Button } from "../ui/button"; |
| 24 | import { Checkbox } from "../ui/checkbox"; |
| 25 | import { Dialog } from "../ui/dialog"; |
| 26 | import { Switch } from "../ui/switch"; |
| 27 | import { toastManager } from "../ui/toast"; |
| 28 | import { 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 | */ |
| 39 | export function ConnectOnboardingDialog() { |
| 40 | if (!hasCloudPublicConfig()) return null; |
| 41 | |
| 42 | return ( |
| 43 | <CloudAuthBoundary> |
| 44 | <ConfiguredConnectOnboardingDialog /> |
| 45 | </CloudAuthBoundary> |
| 46 | ); |
| 47 | } |
| 48 | |
| 49 | type OnboardingStep = "publish" | "devices"; |
| 50 | |
| 51 | const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>()); |
| 52 | |
| 53 | function 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 | |
| 335 | const STEP_LABELS: Record<OnboardingStep, string> = { |
| 336 | publish: "Publish", |
| 337 | devices: "Connect devices", |
| 338 | }; |
| 339 | |
| 340 | function 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 | |
| 378 | function 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 | |
| 407 | function 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 | } |