diff --git a/apps/web/src/cloud/forkUnlinkThisPc.test.tsx b/apps/web/src/cloud/forkUnlinkThisPc.test.tsx new file mode 100644 index 000000000..b0742e854 --- /dev/null +++ b/apps/web/src/cloud/forkUnlinkThisPc.test.tsx @@ -0,0 +1,251 @@ +import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + signedInUserId: null as string | null, + linkState: null as EnvironmentCloudLinkStateResult | null, + getToken: vi.fn<() => Promise>(), + link: vi.fn(), + unlink: vi.fn(), + preferences: vi.fn(), + refreshDiscovery: vi.fn(), + refreshLink: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@clerk/react", () => ({ + useAuth: () => ({ + getToken: testState.getToken, + isSignedIn: testState.signedInUserId !== null, + userId: testState.signedInUserId, + }), +})); +vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } })); +vi.mock("../state/relay", () => ({ + relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery }, +})); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("./linkEnvironmentAtoms", () => ({ + linkPrimaryEnvironment: testState.link, + unlinkPrimaryEnvironment: testState.unlink, + updatePrimaryEnvironmentPreferences: testState.preferences, +})); +vi.mock("./primaryCloudLinkState", () => ({ + usePrimaryCloudLinkState: () => ({ + target, + data: testState.linkState, + error: null, + isPending: false, + refresh: testState.refreshLink, + }), +})); +vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) })); + +import { useCloudLinkController } from "./useCloudLinkController"; + +const target = { + environmentId: "primary", + label: "Primary", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773/ws", +}; + +// The state the report describes: a link is stored, but there is no managed +// tunnel and publishing is off, so both switches show OFF. +const linkedWithBothSwitchesOff: EnvironmentCloudLinkStateResult = { + linked: true, + cloudUserId: "account-that-linked", + relayUrl: "https://relay.example.com", + relayIssuer: "https://relay.example.com", + managedTunnelActive: false, + publishAgentActivity: false, +}; + +const accountConflict = new Error( + "Could not configure environment relay access: This environment is already linked to a different cloud account. Unlink it before switching accounts.", +); + +let renderer: ReactTestRenderer | null = null; +let controller: ReturnType | null = null; + +function ControllerProbe() { + const value = useCloudLinkController(); + useLayoutEffect(() => { + controller = value; + }); + return null; +} + +async function mountController() { + await act(() => { + renderer = create(); + }); + if (controller === null) throw new Error("Controller is not mounted."); + return controller; +} + +function mounted() { + if (controller === null) throw new Error("Controller is not mounted."); + return controller; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(console, "error").mockImplementation(() => undefined); + testState.signedInUserId = "account-signed-in"; + testState.linkState = linkedWithBothSwitchesOff; + testState.getToken.mockReset().mockResolvedValue("clerk-token"); + for (const command of [ + testState.link, + testState.unlink, + testState.preferences, + testState.refreshDiscovery, + ]) { + command.mockReset().mockResolvedValue(AsyncResult.success({})); + } + testState.refreshLink.mockReset(); + testState.toast.mockReset(); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + controller = null; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("unlink this PC", () => { + it("unlinks a PC linked under another account with both switches off, without a relay token", async () => { + const stuck = await mountController(); + expect(stuck.linked).toBe(true); + expect(stuck.managedTunnelActive).toBe(false); + expect(stuck.publishAgentActivity).toBe(false); + expect(stuck.linkedAccountDiffers).toBe(true); + + let unlinked = false; + await act(async () => { + unlinked = await mounted().unlinkThisPc(); + }); + + expect(unlinked).toBe(true); + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.getToken).not.toHaveBeenCalled(); + expect(testState.link).not.toHaveBeenCalled(); + expect(testState.preferences).not.toHaveBeenCalled(); + expect(testState.refreshLink).toHaveBeenCalled(); + }); + + it("unlinks while signed out of T3 Connect", async () => { + testState.signedInUserId = null; + const signedOut = await mountController(); + expect(signedOut.linkedAccountDiffers).toBe(false); + + let unlinked = false; + await act(async () => { + unlinked = await mounted().unlinkThisPc(); + }); + + expect(unlinked).toBe(true); + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.getToken).not.toHaveBeenCalled(); + expect(testState.refreshDiscovery).not.toHaveBeenCalled(); + }); + + it("also revokes at the relay when the signed-in account made the link", async () => { + testState.signedInUserId = "account-that-linked"; + const sameAccount = await mountController(); + expect(sameAccount.linkedAccountDiffers).toBe(false); + + await act(async () => { + await mounted().unlinkThisPc(); + }); + + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ + target, + clerkToken: "clerk-token", + }); + }); + + it("still unlinks locally when the relay token cannot be read", async () => { + testState.signedInUserId = "account-that-linked"; + testState.getToken.mockRejectedValue(new Error("token read failed")); + await mountController(); + + let unlinked = false; + await act(async () => { + unlinked = await mounted().unlinkThisPc(); + }); + + expect(unlinked).toBe(true); + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + }); + + it("reports a failed unlink and leaves the link in place", async () => { + testState.unlink.mockResolvedValue( + AsyncResult.failure(Cause.fail(new Error("Could not unlink the environment from cloud."))), + ); + await mountController(); + + let unlinked = true; + await act(async () => { + unlinked = await mounted().unlinkThisPc(); + }); + + expect(unlinked).toBe(false); + expect(mounted().operationError).toBe("Could not unlink the environment from cloud."); + expect(testState.refreshLink).toHaveBeenCalled(); + }); + + it("points the different-account conflict at the unlink control", async () => { + testState.link.mockResolvedValue(AsyncResult.failure(Cause.fail(accountConflict))); + await mountController(); + + let linked = true; + await act(async () => { + linked = await mounted().reconcileCloudState({ managedTunnel: true, publish: false }); + }); + + expect(linked).toBe(false); + expect(testState.link).toHaveBeenCalledOnce(); + expect(mounted().operationError).toContain('Use "Unlink this PC" in Settings > Connections'); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + description: expect.stringContaining('Use "Unlink this PC" in Settings > Connections'), + }), + ); + }); + + it("clears a shown conflict once the PC is unlinked", async () => { + testState.link.mockResolvedValue(AsyncResult.failure(Cause.fail(accountConflict))); + await mountController(); + await act(async () => { + await mounted().reconcileCloudState({ managedTunnel: true, publish: false }); + }); + expect(mounted().operationError).not.toBeNull(); + + await act(async () => { + await mounted().unlinkThisPc(); + }); + + expect(mounted().operationError).toBeNull(); + }); + + it("leaves other link failures worded as they were", async () => { + testState.link.mockResolvedValue( + AsyncResult.failure(Cause.fail(new Error("Could not obtain environment link proof."))), + ); + await mountController(); + + await act(async () => { + await mounted().reconcileCloudState({ managedTunnel: true, publish: false }); + }); + + expect(mounted().operationError).toBe("Could not obtain environment link proof."); + }); +}); diff --git a/apps/web/src/cloud/forkUnlinkThisPc.ts b/apps/web/src/cloud/forkUnlinkThisPc.ts new file mode 100644 index 000000000..c03205cea --- /dev/null +++ b/apps/web/src/cloud/forkUnlinkThisPc.ts @@ -0,0 +1,100 @@ +import { useAuth } from "@clerk/react"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { relayEnvironmentDiscovery } from "../state/relay"; +import { useAtomCommand } from "../state/use-atom-command"; +import { unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom } from "./linkEnvironmentAtoms"; +import type { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; +import { resolveRelayClerkTokenOptions } from "./publicConfig"; + +export const UNLINK_THIS_PC_LABEL = "Unlink this PC"; + +// The environment server's wording when a relay config names another account +// than the stored link (validateLinkedCloudUser in apps/server/src/cloud). +const LINKED_ACCOUNT_CONFLICT = "linked to a different cloud account"; + +/** + * Rewrites the server's different-account conflict so it names the control + * that clears it. Every other failure message passes through unchanged. + */ +export function describeCloudLinkFailure(message: string): string { + return message.includes(LINKED_ACCOUNT_CONFLICT) + ? `This PC is already linked to a different T3 Connect account. Use "${UNLINK_THIS_PC_LABEL}" in Settings > Connections, then try again.` + : message; +} + +/** + * True only when both account ids are known and differ. An unknown id on + * either side is not a mismatch. + */ +function isLinkedToDifferentAccount(input: { + readonly linkedCloudUserId: string | null | undefined; + readonly signedInUserId: string | null | undefined; +}): boolean { + return Boolean( + input.linkedCloudUserId && + input.signedInUserId && + input.linkedCloudUserId !== input.signedInUserId, + ); +} + +/** + * The fork's explicit unlink for the primary environment. Unlike + * `reconcileCloudState`, it does not depend on what the switches show, so it + * is reachable from every linked state, and it never needs a relay token: the + * local unlink always runs, and the relay revoke is attempted only when the + * signed-in account is not known to differ from the one that made the link. + */ +export function useForkUnlinkThisPc(input: { + readonly linkState: ReturnType; + readonly clearFailure: () => void; + readonly reportFailure: (cause: unknown) => void; +}) { + const { getToken, isSignedIn, userId } = useAuth(); + const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { + reportFailure: false, + }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { + reportFailure: false, + }); + const linkedAccountDiffers = isLinkedToDifferentAccount({ + linkedCloudUserId: input.linkState.data?.cloudUserId, + signedInUserId: userId, + }); + + const unlinkThisPc = async (): Promise => { + input.clearFailure(); + const target = input.linkState.target; + if (!target) { + input.reportFailure(new Error("Local environment is not ready yet.")); + return false; + } + const canRevokeAtRelay = Boolean(isSignedIn) && !linkedAccountDiffers; + const tokenResult = canRevokeAtRelay + ? await settlePromise(() => getToken(resolveRelayClerkTokenOptions())) + : null; + const unlinkResult = await unlinkPrimaryEnvironment({ + target, + clerkToken: tokenResult?._tag === "Success" ? (tokenResult.value ?? null) : null, + }); + input.linkState.refresh(); + if (unlinkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(unlinkResult)) { + input.reportFailure(squashAtomCommandFailure(unlinkResult)); + } + return false; + } + // The local link is gone at this point. The relay list only exists for a + // signed-in account, and a failed refresh of it does not undo the unlink. + if (isSignedIn) { + await refreshRelayEnvironments(); + } + return true; + }; + + return { linkedAccountDiffers, unlinkThisPc }; +} diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index d91596880..f7b5b29bb 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -10,6 +10,7 @@ import { useState } from "react"; import { toastManager } from "../components/ui/toast"; import { relayEnvironmentDiscovery } from "../state/relay"; import { useAtomCommand } from "../state/use-atom-command"; +import { describeCloudLinkFailure, useForkUnlinkThisPc } from "./forkUnlinkThisPc"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, @@ -51,7 +52,9 @@ export function useCloudLinkController() { const [operationError, setOperationError] = useState(null); const reportUpdateFailure = (cause: unknown) => { - const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; + const message = describeCloudLinkFailure( + cause instanceof Error ? cause.message : "Could not update T3 Connect access.", + ); const traceId = findErrorTraceId(cause); console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); @@ -76,6 +79,12 @@ export function useCloudLinkController() { primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; const linked = primaryCloudLinkState.data?.linked ?? false; + // Fork: an unlink that does not depend on what the switches show. + const { linkedAccountDiffers, unlinkThisPc } = useForkUnlinkThisPc({ + linkState: primaryCloudLinkState, + clearFailure: () => setOperationError(null), + reportFailure: reportUpdateFailure, + }); const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise => { setOperationError(null); @@ -155,9 +164,11 @@ export function useCloudLinkController() { isSignedIn, linkState: primaryCloudLinkState, linked, + linkedAccountDiffers, managedTunnelActive, publishAgentActivity, operationError, reconcileCloudState, + unlinkThisPc, }; } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index defd65cbf..08eba8ff4 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -75,6 +75,7 @@ import { formatDesktopSshTarget, } from "./EnvironmentRow"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { ForkUnlinkThisPcRow } from "./ForkUnlinkThisPcRow"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; import { Input } from "../ui/input"; @@ -1683,20 +1684,24 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b const { isSignedIn, linkState: primaryCloudLinkState, + linked, + linkedAccountDiffers, managedTunnelActive, publishAgentActivity, operationError, reconcileCloudState, + unlinkThisPc, } = useCloudLinkController(); const [isUpdating, setIsUpdating] = useState(false); const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); + const [isUnlinking, setIsUnlinking] = useState(false); const disabledReason = !isSignedIn ? "Sign in to T3 Connect to manage this environment." : !canManageRelay ? "Your session does not have permission to manage T3 Connect access." : null; - const isBusy = isUpdating || isUpdatingPreference; + const isBusy = isUpdating || isUpdatingPreference || isUnlinking; const updateManagedTunnel = async (enabled: boolean) => { setIsUpdating(true); @@ -1770,6 +1775,14 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b /> } /> + ); } diff --git a/apps/web/src/components/settings/ForkUnlinkThisPcRow.tsx b/apps/web/src/components/settings/ForkUnlinkThisPcRow.tsx new file mode 100644 index 000000000..d0490ade0 --- /dev/null +++ b/apps/web/src/components/settings/ForkUnlinkThisPcRow.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; + +import { UNLINK_THIS_PC_LABEL } from "~/cloud/forkUnlinkThisPc"; + +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsRow } from "./settingsLayout"; + +/** + * Shown under the T3 Connect switches whenever this environment holds a link, + * whatever the switches show. Needs permission to manage T3 Connect, but not a + * T3 Connect sign-in. + */ +export function ForkUnlinkThisPcRow({ + linked, + linkedAccountDiffers, + canManageRelay, + disabled, + unlinkThisPc, + onBusyChange, +}: { + readonly linked: boolean; + readonly linkedAccountDiffers: boolean; + readonly canManageRelay: boolean; + readonly disabled: boolean; + readonly unlinkThisPc: () => Promise; + readonly onBusyChange: (busy: boolean) => void; +}) { + const [isUnlinking, setIsUnlinking] = useState(false); + if (!linked) return null; + + const unlink = async () => { + setIsUnlinking(true); + onBusyChange(true); + const ok = await unlinkThisPc(); + if (ok) { + toastManager.add({ + type: "success", + title: "This PC is unlinked", + description: "The T3 Connect link stored on this PC was removed.", + }); + } + setIsUnlinking(false); + onBusyChange(false); + }; + + const control = ( + + ); + + return ( + + {control}} /> + + Your session does not have permission to manage T3 Connect access. + + + ) + } + /> + ); +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 12658d3e0..90a349f7f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -158,6 +158,11 @@ 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. +If switching T3 Connect on fails because this PC is already linked to a +different T3 Connect account, open **Settings → Connections** on the host and +choose **Unlink this PC**, then switch T3 Connect on again. It removes the link +stored on the PC and needs no sign-in to the account that made it. + On a command-line host, `t3 connect unlink` disables exposure while retaining your login; `t3 connect logout` also clears that login. Background-service [removal](./background-service.md#manage-the-service) is separate. diff --git a/fork-features.json b/fork-features.json index 46e4aefb3..c6aec6131 100644 --- a/fork-features.json +++ b/fork-features.json @@ -588,6 +588,20 @@ "packages/client-runtime/src/providerSkills.test.ts" ], "patchesUpstream": true + }, + { + "name": "t3-connect-unlink-this-pc", + "description": "Settings > Connections shows an Unlink this PC row whenever the environment holds a T3 Connect link. It clears the link stored on this PC with the local unlink call, needs no relay token and no sign-in to the account that made the link, and still needs the relay:write scope. Upstream only unlinks when a switch that shows ON is turned OFF, so a link with no tunnel and publishing off, or one made under another account, could not be removed. The different-account conflict error names the row, and the row says so when the stored link belongs to another account than the one signed in.", + "keep": "forkUnlinkThisPc.ts, its test and ForkUnlinkThisPcRow.tsx are ours whole. In useCloudLinkController.ts these survive: the describeCloudLinkFailure call around the failure message, the useForkUnlinkThisPc call, and linkedAccountDiffers and unlinkThisPc in the returned object. In ConnectionsSettings.tsx these survive: the ForkUnlinkThisPcRow import, the row rendered after the Publish agent activity row in ConfiguredCloudLinkRow, and the isUnlinking state that feeds isBusy. The Unlink this PC paragraph under Manage or revoke access in remote-access.md survives. Everything else in those three files takes upstream.", + "files": [ + "apps/web/src/cloud/forkUnlinkThisPc.ts", + "apps/web/src/components/settings/ForkUnlinkThisPcRow.tsx", + "apps/web/src/cloud/useCloudLinkController.ts", + "apps/web/src/components/settings/ConnectionsSettings.tsx", + "docs/user/remote-access.md" + ], + "test": "apps/web/src/cloud/forkUnlinkThisPc.test.tsx", + "patchesUpstream": true } ] }