authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-28 13:02:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-28 13:02:08-07:00
logba79610d166dcc7a9b8beb013d18304bc6261307
tree7edca30932ba72dfa0f76b5e83034bc4d0ae62b9
parented57bed8e71b2e89a9350c94bda0dacc71cd4817
signature Signed by PGP key B5690EEEBB952194

fix(web,mobile): say that removing a T3 Connect environment keeps its account registration (#14127)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 221 insertions(+), 40 deletions(-)

apps/mobile/src/state/use-remote-environment-registry.ts+28-11
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
2import type { EnvironmentId } from "@t3tools/contracts";3import type { EnvironmentId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";5import { AsyncResult, Atom } from "effect/unstable/reactivity";
...@@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() {...@@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() {
111112
112export function useRemoteConnections() {113export function useRemoteConnections() {
113 const controller = useConnectionController();114 const controller = useConnectionController();
115 const navigation = useNavigation();
114 const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom);116 const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom);
115 const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);117 const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
116 const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();118 const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();
...@@ -171,22 +173,37 @@ export function useRemoteConnections() {...@@ -171,22 +173,37 @@ export function useRemoteConnections() {
171 if (!environment) {173 if (!environment) {
172 return;174 return;
173 }175 }
176 const remove = {
177 text: "Remove",
178 style: "destructive",
179 onPress: () => {
180 void controller.removeEnvironment(environmentId);
181 },
182 } as const;
183 // Removing a T3 Connect environment here leaves its account registration
184 // and host space, so point to where it can be deregistered.
185 if (environment.isRelayManaged) {
186 Alert.alert(
187 "Remove from this device?",
188 `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`,
189 [
190 { text: "Cancel", style: "cancel" },
191 {
192 text: "Open T3 Account",
193 onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }),
194 },
195 remove,
196 ],
197 );
198 return;
199 }
174 Alert.alert(200 Alert.alert(
175 "Remove from this device?",201 "Remove from this device?",
176 `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`,202 `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`,
177 [203 [{ text: "Cancel", style: "cancel" }, remove],
178 { text: "Cancel", style: "cancel" },
179 {
180 text: "Remove",
181 style: "destructive",
182 onPress: () => {
183 void controller.removeEnvironment(environmentId);
184 },
185 },
186 ],
187 );204 );
188 },205 },
189 [connectedEnvironments, controller],206 [connectedEnvironments, controller, navigation],
190 );207 );
191208
192 return {209 return {
apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx created+78
...@@ -0,0 +1,78 @@
1import { useState } from "react";
2
3import {
4 AlertDialog,
5 AlertDialogClose,
6 AlertDialogDescription,
7 AlertDialogFooter,
8 AlertDialogHeader,
9 AlertDialogPopup,
10 AlertDialogTitle,
11} from "../ui/alert-dialog";
12import { Button, InlineButton } from "../ui/button";
13import { useT3ConnectAccountPage } from "./T3ConnectAccountPages";
14
15/**
16 * Confirms removing a T3 Connect environment from this device. Removal here
17 * leaves the account registration (and its host space) in place, so the dialog
18 * says so and links to the account page where it can be deregistered.
19 */
20export function RemoveT3ConnectEnvironmentDialog({
21 environmentLabel,
22 onCancel,
23 onConfirm,
24}: {
25 /** The environment awaiting confirmation; null keeps the dialog closed. */
26 readonly environmentLabel: string | null;
27 readonly onCancel: () => void;
28 readonly onConfirm: () => void;
29}) {
30 const accountPage = useT3ConnectAccountPage();
31 // Keep the label through the close animation.
32 const [shownLabel, setShownLabel] = useState(environmentLabel);
33 if (environmentLabel !== null && environmentLabel !== shownLabel) setShownLabel(environmentLabel);
34 const openAccountPage = accountPage.open;
35
36 return (
37 <>
38 <AlertDialog
39 open={environmentLabel !== null}
40 onOpenChange={(open) => {
41 if (!open) onCancel();
42 }}
43 >
44 <AlertDialogPopup>
45 <AlertDialogHeader>
46 <AlertDialogTitle>Remove {shownLabel} from this device?</AlertDialogTitle>
47 <AlertDialogDescription>
48 This forgets its pairing, credentials, and cached threads here.
49 </AlertDialogDescription>
50 <AlertDialogDescription>
51 It stays on your T3 Connect account and keeps its host space. Deregister it in{" "}
52 {openAccountPage ? (
53 <InlineButton
54 onClick={() => {
55 onCancel();
56 openAccountPage();
57 }}
58 >
59 T3 Connect settings
60 </InlineButton>
61 ) : (
62 "T3 Connect settings"
63 )}{" "}
64 to free it.
65 </AlertDialogDescription>
66 </AlertDialogHeader>
67 <AlertDialogFooter>
68 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
69 <Button variant="destructive" onClick={onConfirm}>
70 Remove from this device
71 </Button>
72 </AlertDialogFooter>
73 </AlertDialogPopup>
74 </AlertDialog>
75 {accountPage.portals}
76 </>
77 );
78}
apps/web/src/components/clerk/T3ConnectAccountPages.tsx created+66
...@@ -0,0 +1,66 @@
1import { useAuth, useClerk } from "@clerk/react";
2import { ServerIcon, SmartphoneIcon } from "lucide-react";
3import { type ReactNode, useCallback, useState } from "react";
4import { createPortal } from "react-dom";
5
6import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
7import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
8
9/** Custom pages in the Clerk account modal, in menu order. */
10export const T3_CONNECT_ACCOUNT_PAGES = [
11 {
12 label: "Mobile clients",
13 url: "mobile-clients",
14 icon: <SmartphoneIcon className="size-4" />,
15 content: <MobileClientsUserProfilePage />,
16 },
17 {
18 label: "T3 Connect",
19 url: "t3-connect",
20 icon: <ServerIcon className="size-4" />,
21 content: <T3ConnectUserProfilePage />,
22 },
23] as const;
24
25type PortalTargets = Readonly<Record<string, HTMLDivElement | undefined>>;
26
27/**
28 * Opens the Clerk account modal on the T3 Connect page from outside the
29 * UserButton. Clerk mounts custom pages into DOM nodes it owns, so the caller
30 * must keep `portals` rendered for as long as the modal can be open.
31 */
32export function useT3ConnectAccountPage(): {
33 readonly open: (() => void) | null;
34 readonly portals: ReactNode;
35} {
36 const clerk = useClerk();
37 const { isSignedIn } = useAuth();
38 const [targets, setTargets] = useState<PortalTargets>({});
39
40 const open = useCallback(() => {
41 const setTarget = (key: string, element: HTMLDivElement | undefined) =>
42 setTargets((current) => ({ ...current, [key]: element }));
43 clerk.openUserProfile({
44 __experimental_startPath: "/t3-connect",
45 customPages: T3_CONNECT_ACCOUNT_PAGES.map((page) => ({
46 label: page.label,
47 url: page.url,
48 mount: (element: HTMLDivElement) => setTarget(`content:${page.url}`, element),
49 unmount: () => setTarget(`content:${page.url}`, undefined),
50 mountIcon: (element: HTMLDivElement) => setTarget(`icon:${page.url}`, element),
51 unmountIcon: () => setTarget(`icon:${page.url}`, undefined),
52 })),
53 });
54 }, [clerk]);
55
56 const portals = T3_CONNECT_ACCOUNT_PAGES.flatMap((page) => {
57 const content = targets[`content:${page.url}`];
58 const icon = targets[`icon:${page.url}`];
59 return [
60 content ? createPortal(page.content, content, `content:${page.url}`) : null,
61 icon ? createPortal(page.icon, icon, `icon:${page.url}`) : null,
62 ];
63 });
64
65 return { open: isSignedIn ? open : null, portals };
66}
apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx+12-17
...@@ -1,10 +1,9 @@...@@ -1,10 +1,9 @@
1import { UserButton, useAuth } from "@clerk/react";1import { UserButton, useAuth } from "@clerk/react";
2import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react";2import { LogInIcon } from "lucide-react";
33
4import { hasCloudPublicConfig } from "../../cloud/publicConfig";4import { hasCloudPublicConfig } from "../../cloud/publicConfig";
5import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";5import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
6import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";6import { T3_CONNECT_ACCOUNT_PAGES } from "./T3ConnectAccountPages";
7import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
8import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";7import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";
98
10export function T3ConnectSidebarSignIn() {9export function T3ConnectSidebarSignIn() {
...@@ -33,20 +32,16 @@ function ConfiguredT3ConnectSidebarAvatar() {...@@ -33,20 +32,16 @@ function ConfiguredT3ConnectSidebarAvatar() {
33 },32 },
34 }}33 }}
35 >34 >
36 <UserButton.UserProfilePage35 {T3_CONNECT_ACCOUNT_PAGES.map((page) => (
37 label="Mobile clients"36 <UserButton.UserProfilePage
38 labelIcon={<SmartphoneIcon className="size-4" />}37 key={page.url}
39 url="mobile-clients"38 label={page.label}
40 >39 labelIcon={page.icon}
41 <MobileClientsUserProfilePage />40 url={page.url}
42 </UserButton.UserProfilePage>41 >
43 <UserButton.UserProfilePage42 {page.content}
44 label="T3 Connect"43 </UserButton.UserProfilePage>
45 labelIcon={<ServerIcon className="size-4" />}44 ))}
46 url="t3-connect"
47 >
48 <T3ConnectUserProfilePage />
49 </UserButton.UserProfilePage>
50 </UserButton>45 </UserButton>
51 );46 );
52}47}
apps/web/src/components/settings/ConnectionsSettings.tsx+35-11
...@@ -143,6 +143,7 @@ import {...@@ -143,6 +143,7 @@ import {
143 supportsServerUpdateThreadContinuation,143 supportsServerUpdateThreadContinuation,
144} from "~/versionSkew";144} from "~/versionSkew";
145import { hasCloudPublicConfig } from "~/cloud/publicConfig";145import { hasCloudPublicConfig } from "~/cloud/publicConfig";
146import { RemoveT3ConnectEnvironmentDialog } from "../clerk/RemoveT3ConnectEnvironmentDialog";
146import { useCloudLinkController } from "~/cloud/useCloudLinkController";147import { useCloudLinkController } from "~/cloud/useCloudLinkController";
147import { authEnvironment } from "~/state/auth";148import { authEnvironment } from "~/state/auth";
148import { environmentCatalog } from "~/connection/catalog";149import { environmentCatalog } from "~/connection/catalog";
...@@ -2514,18 +2515,8 @@ export function ConnectionsSettings() {...@@ -2514,18 +2515,8 @@ export function ConnectionsSettings() {
2514 [setEnvironmentEnabled],2515 [setEnvironmentEnabled],
2515 );2516 );
25162517
2517 // Removing forgets the pairing, credentials, and cached threads on this2518 const removeSavedBackend = useCallback(
2518 // device. Switching off is the reversible path, so removal always confirms.
2519 const handleRemoveSavedBackend = useCallback(
2520 async (environment: EnvironmentPresentation) => {2519 async (environment: EnvironmentPresentation) => {
2521 // Fail closed: no mounted confirm host means no removal.
2522 const confirmed = await requestConfirmDialog(
2523 `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`,
2524 { variant: "destructive" },
2525 );
2526 if (confirmed !== true) {
2527 return;
2528 }
2529 const environmentId = environment.environmentId;2520 const environmentId = environment.environmentId;
2530 setRemovingSavedEnvironmentId(environmentId);2521 setRemovingSavedEnvironmentId(environmentId);
2531 setSavedBackendError(null);2522 setSavedBackendError(null);
...@@ -2547,6 +2538,28 @@ export function ConnectionsSettings() {...@@ -2547,6 +2538,28 @@ export function ConnectionsSettings() {
2547 [removeEnvironment],2538 [removeEnvironment],
2548 );2539 );
25492540
2541 // Removing forgets the pairing, credentials, and cached threads on this
2542 // device. Switching off is the reversible path, so removal always confirms.
2543 // T3 Connect environments get their own dialog: removing one here leaves its
2544 // account registration, so it points to where that can be deregistered.
2545 const [pendingT3ConnectRemoval, setPendingT3ConnectRemoval] =
2546 useState<EnvironmentPresentation | null>(null);
2547 const handleRemoveSavedBackend = useCallback(
2548 async (environment: EnvironmentPresentation) => {
2549 if (environment.relayManaged && hasCloudPublicConfig()) {
2550 setPendingT3ConnectRemoval(environment);
2551 return;
2552 }
2553 // Fail closed: no mounted confirm host means no removal.
2554 const confirmed = await requestConfirmDialog(
2555 `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`,
2556 { variant: "destructive" },
2557 );
2558 if (confirmed === true) await removeSavedBackend(environment);
2559 },
2560 [removeSavedBackend],
2561 );
2562
2550 const visibleDesktopPairingLinks = desktopPairingLinks;2563 const visibleDesktopPairingLinks = desktopPairingLinks;
2551 const tailscaleHttpsEndpoint = useMemo(2564 const tailscaleHttpsEndpoint = useMemo(
2552 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,2565 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,
...@@ -3762,6 +3775,17 @@ export function ConnectionsSettings() {...@@ -3762,6 +3775,17 @@ export function ConnectionsSettings() {
3762 savedEnvironments={savedEnvironments}3775 savedEnvironments={savedEnvironments}
3763 />3776 />
3764 </SettingsSection>3777 </SettingsSection>
3778 {hasCloudPublicConfig() ? (
3779 <RemoveT3ConnectEnvironmentDialog
3780 environmentLabel={pendingT3ConnectRemoval?.label ?? null}
3781 onCancel={() => setPendingT3ConnectRemoval(null)}
3782 onConfirm={() => {
3783 if (!pendingT3ConnectRemoval) return;
3784 setPendingT3ConnectRemoval(null);
3785 void removeSavedBackend(pendingT3ConnectRemoval);
3786 }}
3787 />
3788 ) : null}
3765 <LoadBalancingSettings environments={loadBalancingEnvironments} />3789 <LoadBalancingSettings environments={loadBalancingEnvironments} />
3766 <GitHubRoutingSettings environments={loadBalancingEnvironments} />3790 <GitHubRoutingSettings environments={loadBalancingEnvironments} />
3767 </SettingsPageContainer>3791 </SettingsPageContainer>
docs/user/remote-access.md+2-1
...@@ -156,7 +156,8 @@ expires....@@ -156,7 +156,8 @@ expires.
156To remove an environment from T3 Connect, open your account menu's **T3 Connect**156To remove an environment from T3 Connect, open your account menu's **T3 Connect**
157page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This157page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
158revokes its cloud access and frees its host space even when the environment is158revokes its cloud access and frees its host space even when the environment is
159offline or has been wiped.159offline or has been wiped. Removing an environment from a device's connection
160settings only forgets it on that device; it stays registered to your account.
160161
161When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's162When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's
162tunnel after it stays offline for several minutes. The environment stays linked163tunnel after it stays offline for several minutes. The environment stays linked