| author | |
| committer | |
| log | ba79610d166dcc7a9b8beb013d18304bc6261307 |
| tree | 7edca30932ba72dfa0f76b5e83034bc4d0ae62b9 |
| parent | ed57bed8e71b2e89a9350c94bda0dacc71cd4817 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 4 | import * as Cause from "effect/Cause"; |
| 4 | 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() { |
| 111 | 112 | |
| 112 | 113 | export function useRemoteConnections() { |
| 113 | 114 | const controller = useConnectionController(); |
| 115 | const navigation = useNavigation(); | |
| 114 | 116 | const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); |
| 115 | 117 | const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); |
| 116 | 118 | const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); |
| ... | ... | @@ -171,22 +173,37 @@ export function useRemoteConnections() { |
| 171 | 173 | if (!environment) { |
| 172 | 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 | 200 | Alert.alert( |
| 175 | 201 | "Remove from this device?", |
| 176 | 202 | `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], | |
| 187 | 204 | ); |
| 188 | 205 | }, |
| 189 | [connectedEnvironments, controller], | |
| 206 | [connectedEnvironments, controller, navigation], | |
| 190 | 207 | ); |
| 191 | 208 | |
| 192 | 209 | return { |
apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx created+78| ... | ... | @@ -0,0 +1,78 @@ |
| 1 | import { useState } from "react"; | |
| 2 | ||
| 3 | import { | |
| 4 | AlertDialog, | |
| 5 | AlertDialogClose, | |
| 6 | AlertDialogDescription, | |
| 7 | AlertDialogFooter, | |
| 8 | AlertDialogHeader, | |
| 9 | AlertDialogPopup, | |
| 10 | AlertDialogTitle, | |
| 11 | } from "../ui/alert-dialog"; | |
| 12 | import { Button, InlineButton } from "../ui/button"; | |
| 13 | import { 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 | */ | |
| 20 | export 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 @@ |
| 1 | import { useAuth, useClerk } from "@clerk/react"; | |
| 2 | import { ServerIcon, SmartphoneIcon } from "lucide-react"; | |
| 3 | import { type ReactNode, useCallback, useState } from "react"; | |
| 4 | import { createPortal } from "react-dom"; | |
| 5 | ||
| 6 | import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; | |
| 7 | import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; | |
| 8 | ||
| 9 | /** Custom pages in the Clerk account modal, in menu order. */ | |
| 10 | export 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 | ||
| 25 | type 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 | */ | |
| 32 | export 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 | 1 | import { UserButton, useAuth } from "@clerk/react"; |
| 2 | import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react"; | |
| 2 | import { LogInIcon } from "lucide-react"; | |
| 3 | 3 | |
| 4 | 4 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; |
| 5 | 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; |
| 6 | import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; | |
| 7 | import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; | |
| 6 | import { T3_CONNECT_ACCOUNT_PAGES } from "./T3ConnectAccountPages"; | |
| 8 | 7 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; |
| 9 | 8 | |
| 10 | 9 | export function T3ConnectSidebarSignIn() { |
| ... | ... | @@ -33,20 +32,16 @@ function ConfiguredT3ConnectSidebarAvatar() { |
| 33 | 32 | }, |
| 34 | 33 | }} |
| 35 | 34 | > |
| 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 | ))} | |
| 50 | 45 | </UserButton> |
| 51 | 46 | ); |
| 52 | 47 | } |
apps/web/src/components/settings/ConnectionsSettings.tsx+35-11| ... | ... | @@ -143,6 +143,7 @@ import { |
| 143 | 143 | supportsServerUpdateThreadContinuation, |
| 144 | 144 | } from "~/versionSkew"; |
| 145 | 145 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 146 | import { RemoveT3ConnectEnvironmentDialog } from "../clerk/RemoveT3ConnectEnvironmentDialog"; | |
| 146 | 147 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; |
| 147 | 148 | import { authEnvironment } from "~/state/auth"; |
| 148 | 149 | import { environmentCatalog } from "~/connection/catalog"; |
| ... | ... | @@ -2514,18 +2515,8 @@ export function ConnectionsSettings() { |
| 2514 | 2515 | [setEnvironmentEnabled], |
| 2515 | 2516 | ); |
| 2516 | 2517 | |
| 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( | |
| 2520 | 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 | 2520 | const environmentId = environment.environmentId; |
| 2530 | 2521 | setRemovingSavedEnvironmentId(environmentId); |
| 2531 | 2522 | setSavedBackendError(null); |
| ... | ... | @@ -2547,6 +2538,28 @@ export function ConnectionsSettings() { |
| 2547 | 2538 | [removeEnvironment], |
| 2548 | 2539 | ); |
| 2549 | 2540 | |
| 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 | 2563 | const visibleDesktopPairingLinks = desktopPairingLinks; |
| 2551 | 2564 | const tailscaleHttpsEndpoint = useMemo( |
| 2552 | 2565 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, |
| ... | ... | @@ -3762,6 +3775,17 @@ export function ConnectionsSettings() { |
| 3762 | 3775 | savedEnvironments={savedEnvironments} |
| 3763 | 3776 | /> |
| 3764 | 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 | 3789 | <LoadBalancingSettings environments={loadBalancingEnvironments} /> |
| 3766 | 3790 | <GitHubRoutingSettings environments={loadBalancingEnvironments} /> |
| 3767 | 3791 | </SettingsPageContainer> |
docs/user/remote-access.md+2-1| ... | ... | @@ -156,7 +156,8 @@ expires. |
| 156 | 156 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 157 | 157 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 158 | 158 | revokes its cloud access and frees its host space even when the environment is |
| 159 | offline or has been wiped. | |
| 159 | offline or has been wiped. Removing an environment from a device's connection | |
| 160 | settings only forgets it on that device; it stays registered to your account. | |
| 160 | 161 | |
| 161 | 162 | When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's |
| 162 | 163 | tunnel after it stays offline for several minutes. The environment stays linked |