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