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 @@
11import { useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
23import type { EnvironmentId } from "@t3tools/contracts";
34import * as Cause from "effect/Cause";
45import { AsyncResult, Atom } from "effect/unstable/reactivity";
......@@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() {
111112
112113export function useRemoteConnections() {
113114 const controller = useConnectionController();
115 const navigation = useNavigation();
114116 const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom);
115117 const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
116118 const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();
......@@ -171,22 +173,37 @@ export function useRemoteConnections() {
171173 if (!environment) {
172174 return;
173175 }
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 }
174200 Alert.alert(
175201 "Remove from this device?",
176202 `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`,
177 [
178 { text: "Cancel", style: "cancel" },
179 {
180 text: "Remove",
181 style: "destructive",
182 onPress: () => {
183 void controller.removeEnvironment(environmentId);
184 },
185 },
186 ],
203 [{ text: "Cancel", style: "cancel" }, remove],
187204 );
188205 },
189 [connectedEnvironments, controller],
206 [connectedEnvironments, controller, navigation],
190207 );
191208
192209 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 @@
11import { UserButton, useAuth } from "@clerk/react";
2import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react";
2import { LogInIcon } from "lucide-react";
33
44import { hasCloudPublicConfig } from "../../cloud/publicConfig";
55import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
6import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
7import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
6import { T3_CONNECT_ACCOUNT_PAGES } from "./T3ConnectAccountPages";
87import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";
98
109export function T3ConnectSidebarSignIn() {
......@@ -33,20 +32,16 @@ function ConfiguredT3ConnectSidebarAvatar() {
3332 },
3433 }}
3534 >
36 <UserButton.UserProfilePage
37 label="Mobile clients"
38 labelIcon={<SmartphoneIcon className="size-4" />}
39 url="mobile-clients"
40 >
41 <MobileClientsUserProfilePage />
42 </UserButton.UserProfilePage>
43 <UserButton.UserProfilePage
44 label="T3 Connect"
45 labelIcon={<ServerIcon className="size-4" />}
46 url="t3-connect"
47 >
48 <T3ConnectUserProfilePage />
49 </UserButton.UserProfilePage>
35 {T3_CONNECT_ACCOUNT_PAGES.map((page) => (
36 <UserButton.UserProfilePage
37 key={page.url}
38 label={page.label}
39 labelIcon={page.icon}
40 url={page.url}
41 >
42 {page.content}
43 </UserButton.UserProfilePage>
44 ))}
5045 </UserButton>
5146 );
5247}
apps/web/src/components/settings/ConnectionsSettings.tsx+35-11
......@@ -143,6 +143,7 @@ import {
143143 supportsServerUpdateThreadContinuation,
144144} from "~/versionSkew";
145145import { hasCloudPublicConfig } from "~/cloud/publicConfig";
146import { RemoveT3ConnectEnvironmentDialog } from "../clerk/RemoveT3ConnectEnvironmentDialog";
146147import { useCloudLinkController } from "~/cloud/useCloudLinkController";
147148import { authEnvironment } from "~/state/auth";
148149import { environmentCatalog } from "~/connection/catalog";
......@@ -2514,18 +2515,8 @@ export function ConnectionsSettings() {
25142515 [setEnvironmentEnabled],
25152516 );
25162517
2517 // Removing forgets the pairing, credentials, and cached threads on this
2518 // device. Switching off is the reversible path, so removal always confirms.
2519 const handleRemoveSavedBackend = useCallback(
2518 const removeSavedBackend = useCallback(
25202519 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 }
25292520 const environmentId = environment.environmentId;
25302521 setRemovingSavedEnvironmentId(environmentId);
25312522 setSavedBackendError(null);
......@@ -2547,6 +2538,28 @@ export function ConnectionsSettings() {
25472538 [removeEnvironment],
25482539 );
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
25502563 const visibleDesktopPairingLinks = desktopPairingLinks;
25512564 const tailscaleHttpsEndpoint = useMemo(
25522565 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,
......@@ -3762,6 +3775,17 @@ export function ConnectionsSettings() {
37623775 savedEnvironments={savedEnvironments}
37633776 />
37643777 </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}
37653789 <LoadBalancingSettings environments={loadBalancingEnvironments} />
37663790 <GitHubRoutingSettings environments={loadBalancingEnvironments} />
37673791 </SettingsPageContainer>
docs/user/remote-access.md+2-1
......@@ -156,7 +156,8 @@ expires.
156156To remove an environment from T3 Connect, open your account menu's **T3 Connect**
157157page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
158158revokes 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
161162When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's
162163tunnel after it stays offline for several minutes. The environment stays linked