| 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,4 +1,5 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigation } from "@react-navigation/native"; | ||
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import * as Cause from "effect/Cause"; | 4 | import * as Cause from "effect/Cause"; |
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | @@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() { | ... | @@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() { |
| 111 | 112 | ||
| 112 | export function useRemoteConnections() { | 113 | export 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 | ); |
| 191 | 208 | ||
| 192 | return { | 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,10 +1,9 @@ |
| 1 | import { UserButton, useAuth } from "@clerk/react"; | 1 | import { UserButton, useAuth } from "@clerk/react"; |
| 2 | import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react"; | 2 | import { LogInIcon } from "lucide-react"; |
| 3 | 3 | ||
| 4 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; | 4 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; |
| 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; | 5 | import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; |
| 6 | import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; | 6 | import { T3_CONNECT_ACCOUNT_PAGES } from "./T3ConnectAccountPages"; |
| 7 | import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; | ||
| 8 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; | 7 | import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; |
| 9 | 8 | ||
| 10 | export function T3ConnectSidebarSignIn() { | 9 | export function T3ConnectSidebarSignIn() { |
| ... | @@ -33,20 +32,16 @@ function ConfiguredT3ConnectSidebarAvatar() { | ... | @@ -33,20 +32,16 @@ function ConfiguredT3ConnectSidebarAvatar() { |
| 33 | }, | 32 | }, |
| 34 | }} | 33 | }} |
| 35 | > | 34 | > |
| 36 | <UserButton.UserProfilePage | 35 | {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.UserProfilePage | 42 | {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"; |
| 145 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 145 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 146 | import { RemoveT3ConnectEnvironmentDialog } from "../clerk/RemoveT3ConnectEnvironmentDialog"; | ||
| 146 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; | 147 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; |
| 147 | import { authEnvironment } from "~/state/auth"; | 148 | import { authEnvironment } from "~/state/auth"; |
| 148 | import { environmentCatalog } from "~/connection/catalog"; | 149 | import { environmentCatalog } from "~/connection/catalog"; |
| ... | @@ -2514,18 +2515,8 @@ export function ConnectionsSettings() { | ... | @@ -2514,18 +2515,8 @@ export function ConnectionsSettings() { |
| 2514 | [setEnvironmentEnabled], | 2515 | [setEnvironmentEnabled], |
| 2515 | ); | 2516 | ); |
| 2516 | 2517 | ||
| 2517 | // Removing forgets the pairing, credentials, and cached threads on this | 2518 | 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 | ); |
| 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 | 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. |
| 156 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** | 156 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 157 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This | 157 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 158 | revokes its cloud access and frees its host space even when the environment is | 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 | When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's | 162 | When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's |
| 162 | tunnel after it stays offline for several minutes. The environment stays linked | 163 | tunnel after it stays offline for several minutes. The environment stays linked |