diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts
index 1664dc7461aad376599fbbf617ca764cf9527aa7..ce309f5b06fd938f4cac2206b4059cb83fea776e 100644
--- a/apps/mobile/src/state/use-remote-environment-registry.ts
+++ b/apps/mobile/src/state/use-remote-environment-registry.ts
@@ -1,4 +1,5 @@
import { useAtomValue } from "@effect/atom-react";
+import { useNavigation } from "@react-navigation/native";
import type { EnvironmentId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
@@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() {
export function useRemoteConnections() {
const controller = useConnectionController();
+ const navigation = useNavigation();
const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom);
const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom);
const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus();
@@ -171,22 +173,37 @@ export function useRemoteConnections() {
if (!environment) {
return;
}
+ const remove = {
+ text: "Remove",
+ style: "destructive",
+ onPress: () => {
+ void controller.removeEnvironment(environmentId);
+ },
+ } as const;
+ // Removing a T3 Connect environment here leaves its account registration
+ // and host space, so point to where it can be deregistered.
+ if (environment.isRelayManaged) {
+ Alert.alert(
+ "Remove from this device?",
+ `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.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Open T3 Account",
+ onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }),
+ },
+ remove,
+ ],
+ );
+ return;
+ }
Alert.alert(
"Remove from this device?",
`Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`,
- [
- { text: "Cancel", style: "cancel" },
- {
- text: "Remove",
- style: "destructive",
- onPress: () => {
- void controller.removeEnvironment(environmentId);
- },
- },
- ],
+ [{ text: "Cancel", style: "cancel" }, remove],
);
},
- [connectedEnvironments, controller],
+ [connectedEnvironments, controller, navigation],
);
return {
diff --git a/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx b/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5e6d6e00cba793dbb576a225d0b7f14b093176b6
--- /dev/null
+++ b/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx
@@ -0,0 +1,78 @@
+import { useState } from "react";
+
+import {
+ AlertDialog,
+ AlertDialogClose,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogPopup,
+ AlertDialogTitle,
+} from "../ui/alert-dialog";
+import { Button, InlineButton } from "../ui/button";
+import { useT3ConnectAccountPage } from "./T3ConnectAccountPages";
+
+/**
+ * Confirms removing a T3 Connect environment from this device. Removal here
+ * leaves the account registration (and its host space) in place, so the dialog
+ * says so and links to the account page where it can be deregistered.
+ */
+export function RemoveT3ConnectEnvironmentDialog({
+ environmentLabel,
+ onCancel,
+ onConfirm,
+}: {
+ /** The environment awaiting confirmation; null keeps the dialog closed. */
+ readonly environmentLabel: string | null;
+ readonly onCancel: () => void;
+ readonly onConfirm: () => void;
+}) {
+ const accountPage = useT3ConnectAccountPage();
+ // Keep the label through the close animation.
+ const [shownLabel, setShownLabel] = useState(environmentLabel);
+ if (environmentLabel !== null && environmentLabel !== shownLabel) setShownLabel(environmentLabel);
+ const openAccountPage = accountPage.open;
+
+ return (
+ <>
+ {
+ if (!open) onCancel();
+ }}
+ >
+
+
+ Remove {shownLabel} from this device?
+
+ This forgets its pairing, credentials, and cached threads here.
+
+
+ It stays on your T3 Connect account and keeps its host space. Deregister it in{" "}
+ {openAccountPage ? (
+ {
+ onCancel();
+ openAccountPage();
+ }}
+ >
+ T3 Connect settings
+
+ ) : (
+ "T3 Connect settings"
+ )}{" "}
+ to free it.
+
+
+
+ }>Cancel
+
+
+
+
+ {accountPage.portals}
+ >
+ );
+}
diff --git a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..44b423493a183f3290558885e309402e8d7b42cc
--- /dev/null
+++ b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx
@@ -0,0 +1,66 @@
+import { useAuth, useClerk } from "@clerk/react";
+import { ServerIcon, SmartphoneIcon } from "lucide-react";
+import { type ReactNode, useCallback, useState } from "react";
+import { createPortal } from "react-dom";
+
+import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
+import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
+
+/** Custom pages in the Clerk account modal, in menu order. */
+export const T3_CONNECT_ACCOUNT_PAGES = [
+ {
+ label: "Mobile clients",
+ url: "mobile-clients",
+ icon: ,
+ content: ,
+ },
+ {
+ label: "T3 Connect",
+ url: "t3-connect",
+ icon: ,
+ content: ,
+ },
+] as const;
+
+type PortalTargets = Readonly>;
+
+/**
+ * Opens the Clerk account modal on the T3 Connect page from outside the
+ * UserButton. Clerk mounts custom pages into DOM nodes it owns, so the caller
+ * must keep `portals` rendered for as long as the modal can be open.
+ */
+export function useT3ConnectAccountPage(): {
+ readonly open: (() => void) | null;
+ readonly portals: ReactNode;
+} {
+ const clerk = useClerk();
+ const { isSignedIn } = useAuth();
+ const [targets, setTargets] = useState({});
+
+ const open = useCallback(() => {
+ const setTarget = (key: string, element: HTMLDivElement | undefined) =>
+ setTargets((current) => ({ ...current, [key]: element }));
+ clerk.openUserProfile({
+ __experimental_startPath: "/t3-connect",
+ customPages: T3_CONNECT_ACCOUNT_PAGES.map((page) => ({
+ label: page.label,
+ url: page.url,
+ mount: (element: HTMLDivElement) => setTarget(`content:${page.url}`, element),
+ unmount: () => setTarget(`content:${page.url}`, undefined),
+ mountIcon: (element: HTMLDivElement) => setTarget(`icon:${page.url}`, element),
+ unmountIcon: () => setTarget(`icon:${page.url}`, undefined),
+ })),
+ });
+ }, [clerk]);
+
+ const portals = T3_CONNECT_ACCOUNT_PAGES.flatMap((page) => {
+ const content = targets[`content:${page.url}`];
+ const icon = targets[`icon:${page.url}`];
+ return [
+ content ? createPortal(page.content, content, `content:${page.url}`) : null,
+ icon ? createPortal(page.icon, icon, `icon:${page.url}`) : null,
+ ];
+ });
+
+ return { open: isSignedIn ? open : null, portals };
+}
diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx
index 9dfd8dce13b1bedf84edc1b5658d614a032a33fb..e75ce33ceaf327b32e7e1db3610766def459feb5 100644
--- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx
+++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx
@@ -1,10 +1,9 @@
import { UserButton, useAuth } from "@clerk/react";
-import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react";
+import { LogInIcon } from "lucide-react";
import { hasCloudPublicConfig } from "../../cloud/publicConfig";
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
-import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage";
-import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage";
+import { T3_CONNECT_ACCOUNT_PAGES } from "./T3ConnectAccountPages";
import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt";
export function T3ConnectSidebarSignIn() {
@@ -33,20 +32,16 @@ function ConfiguredT3ConnectSidebarAvatar() {
},
}}
>
- }
- url="mobile-clients"
- >
-
-
- }
- url="t3-connect"
- >
-
-
+ {T3_CONNECT_ACCOUNT_PAGES.map((page) => (
+
+ {page.content}
+
+ ))}
);
}
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index cf37a4167c4d856d59e0bbc951c48c5a8e707e60..8e27f4193d74bf4b4880710588951a1cbc8f8d5b 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -143,6 +143,7 @@ import {
supportsServerUpdateThreadContinuation,
} from "~/versionSkew";
import { hasCloudPublicConfig } from "~/cloud/publicConfig";
+import { RemoveT3ConnectEnvironmentDialog } from "../clerk/RemoveT3ConnectEnvironmentDialog";
import { useCloudLinkController } from "~/cloud/useCloudLinkController";
import { authEnvironment } from "~/state/auth";
import { environmentCatalog } from "~/connection/catalog";
@@ -2514,18 +2515,8 @@ export function ConnectionsSettings() {
[setEnvironmentEnabled],
);
- // Removing forgets the pairing, credentials, and cached threads on this
- // device. Switching off is the reversible path, so removal always confirms.
- const handleRemoveSavedBackend = useCallback(
+ const removeSavedBackend = useCallback(
async (environment: EnvironmentPresentation) => {
- // Fail closed: no mounted confirm host means no removal.
- const confirmed = await requestConfirmDialog(
- `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`,
- { variant: "destructive" },
- );
- if (confirmed !== true) {
- return;
- }
const environmentId = environment.environmentId;
setRemovingSavedEnvironmentId(environmentId);
setSavedBackendError(null);
@@ -2547,6 +2538,28 @@ export function ConnectionsSettings() {
[removeEnvironment],
);
+ // Removing forgets the pairing, credentials, and cached threads on this
+ // device. Switching off is the reversible path, so removal always confirms.
+ // T3 Connect environments get their own dialog: removing one here leaves its
+ // account registration, so it points to where that can be deregistered.
+ const [pendingT3ConnectRemoval, setPendingT3ConnectRemoval] =
+ useState(null);
+ const handleRemoveSavedBackend = useCallback(
+ async (environment: EnvironmentPresentation) => {
+ if (environment.relayManaged && hasCloudPublicConfig()) {
+ setPendingT3ConnectRemoval(environment);
+ return;
+ }
+ // Fail closed: no mounted confirm host means no removal.
+ const confirmed = await requestConfirmDialog(
+ `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`,
+ { variant: "destructive" },
+ );
+ if (confirmed === true) await removeSavedBackend(environment);
+ },
+ [removeSavedBackend],
+ );
+
const visibleDesktopPairingLinks = desktopPairingLinks;
const tailscaleHttpsEndpoint = useMemo(
() => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,
@@ -3762,6 +3775,17 @@ export function ConnectionsSettings() {
savedEnvironments={savedEnvironments}
/>
+ {hasCloudPublicConfig() ? (
+ setPendingT3ConnectRemoval(null)}
+ onConfirm={() => {
+ if (!pendingT3ConnectRemoval) return;
+ setPendingT3ConnectRemoval(null);
+ void removeSavedBackend(pendingT3ConnectRemoval);
+ }}
+ />
+ ) : null}
diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md
index bac23103f0bf2b09accf25f0afe86e750b0f262f..f3fb96b830f959c2b79e9797bf03b738bb8c6cd8 100644
--- a/docs/user/remote-access.md
+++ b/docs/user/remote-access.md
@@ -156,7 +156,8 @@ expires.
To remove an environment from T3 Connect, open your account menu's **T3 Connect**
page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
revokes its cloud access and frees its host space even when the environment is
-offline or has been wiped.
+offline or has been wiped. Removing an environment from a device's connection
+settings only forgets it on that device; it stays registered to your account.
When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's
tunnel after it stays offline for several minutes. The environment stays linked