diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index e0d458b4b..c40c66662 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -6,6 +6,7 @@ import { EnvironmentCloudEndpointUnavailableError, EnvironmentCloudLinkStateResult, EnvironmentCloudRelayConfigResult, + type EnvironmentConflictReason, EnvironmentHttpApi, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, @@ -240,6 +241,7 @@ function validateLinkedCloudUser(input: { new EnvironmentHttpConflictError({ message: "This environment is already linked to a different cloud account. Unlink it before switching accounts.", + reason: "linked_to_different_cloud_account" satisfies EnvironmentConflictReason, }), ); }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c2432b889..933002b60 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3236,11 +3236,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const replacementBody = yield* responseJsonEffect<{ readonly _tag?: string; readonly message?: string; + readonly reason?: string; }>(replacementResponse); assert.equal(firstResponse.status, 200); assert.equal(replacementResponse.status, 409); assert.equal(replacementBody._tag, "EnvironmentHttpConflictError"); + assert.equal(replacementBody.reason, "linked_to_different_cloud_account"); assert.equal( replacementBody.message, "This environment is already linked to a different cloud account. Unlink it before switching accounts.", diff --git a/apps/web/src/cloud/forkRevokeRelayLink.ts b/apps/web/src/cloud/forkRevokeRelayLink.ts new file mode 100644 index 000000000..ef20df39f --- /dev/null +++ b/apps/web/src/cloud/forkRevokeRelayLink.ts @@ -0,0 +1,29 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { type CloudLinkTarget, readPrimaryCloudLinkState } from "./linkEnvironment"; +import { resolveCloudPublicConfig } from "./publicConfig"; + +/** + * Removes the token's own relay entry for an environment that was just + * unlinked locally. Run it after the local unlink, never before: the token may + * arrive late, so it first checks that the environment has not been linked + * again in the meantime and leaves the relay alone when it has. + */ +export const revokeRelayLinkAfterLocalUnlink = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:revoke-relay-link-after-local-unlink", + execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string }) => + Effect.gen(function* () { + if (!resolveCloudPublicConfig().relayUrl) return; + const linkState = yield* readPrimaryCloudLinkState({ target: input.target }); + if (linkState?.linked) return; + const relayClient = yield* ManagedRelay.ManagedRelayClient; + yield* relayClient.unlinkEnvironment({ + clerkToken: input.clerkToken, + environmentId: EnvironmentId.make(input.target.environmentId), + }); + }), +}); diff --git a/apps/web/src/cloud/forkUnlinkThisPc.test.tsx b/apps/web/src/cloud/forkUnlinkThisPc.test.tsx index b0742e854..a5785e723 100644 --- a/apps/web/src/cloud/forkUnlinkThisPc.test.tsx +++ b/apps/web/src/cloud/forkUnlinkThisPc.test.tsx @@ -1,16 +1,30 @@ -import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; +import { + type EnvironmentCloudLinkStateResult, + EnvironmentHttpConflictError, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; 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 primaryTarget = { + environmentId: "primary", + label: "Primary", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773/ws", +}; + const testState = vi.hoisted(() => ({ signedInUserId: null as string | null, + target: null as typeof primaryTarget | null, linkState: null as EnvironmentCloudLinkStateResult | null, + linkError: null as string | null, getToken: vi.fn<() => Promise>(), link: vi.fn(), unlink: vi.fn(), + revoke: vi.fn(), preferences: vi.fn(), refreshDiscovery: vi.fn(), refreshLink: vi.fn(), @@ -29,6 +43,7 @@ vi.mock("../state/relay", () => ({ relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery }, })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("./forkRevokeRelayLink", () => ({ revokeRelayLinkAfterLocalUnlink: testState.revoke })); vi.mock("./linkEnvironmentAtoms", () => ({ linkPrimaryEnvironment: testState.link, unlinkPrimaryEnvironment: testState.unlink, @@ -36,9 +51,9 @@ vi.mock("./linkEnvironmentAtoms", () => ({ })); vi.mock("./primaryCloudLinkState", () => ({ usePrimaryCloudLinkState: () => ({ - target, + target: testState.target, data: testState.linkState, - error: null, + error: testState.linkError, isPending: false, refresh: testState.refreshLink, }), @@ -47,15 +62,10 @@ vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) })) import { useCloudLinkController } from "./useCloudLinkController"; -const target = { - environmentId: "primary", - label: "Primary", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773/ws", -}; +const target = primaryTarget; -// The state the report describes: a link is stored, but there is no managed -// tunnel and publishing is off, so both switches show OFF. +// 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", @@ -65,9 +75,52 @@ const linkedWithBothSwitchesOff: EnvironmentCloudLinkStateResult = { 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.", -); +// The state the report describes: a link made under another account, with the +// tunnel up and publishing on, so both switches show ON. +const linkedWithBothSwitchesOn: EnvironmentCloudLinkStateResult = { + ...linkedWithBothSwitchesOff, + managedTunnelActive: true, + publishAgentActivity: true, +}; + +const CONFLICT_MESSAGE = + "This environment is already linked to a different cloud account. Unlink it before switching accounts."; + +// The shape linkEnvironment.ts reports: its own error wrapping the decoded +// environment server error. +function linkFailure(message: string, cause: unknown) { + return AsyncResult.failure(Cause.fail(new Error(message, { cause }))); +} + +const decodeConflict = Schema.decodeUnknownSync(EnvironmentHttpConflictError); + +// Decoded from the 409 body as it arrives on the wire. +const conflict = (reason?: string) => + linkFailure( + `Could not configure environment relay access: ${CONFLICT_MESSAGE}`, + decodeConflict({ + _tag: "EnvironmentHttpConflictError", + message: CONFLICT_MESSAGE, + ...(reason === undefined ? {} : { reason }), + }), + ); +const accountConflict = () => conflict("linked_to_different_cloud_account"); + +const unlinkedToast = expect.objectContaining({ type: "success", title: "This PC is unlinked" }); +const otherAccountWarning = expect.objectContaining({ + type: "warning", + title: "Another account still lists this PC", +}); +const signedOutWarning = expect.objectContaining({ + type: "warning", + title: "T3 Connect still lists this PC", + description: expect.stringContaining("You are signed out of T3 Connect"), +}); +const ownAccountWarning = expect.objectContaining({ + type: "warning", + title: "T3 Connect may still list this PC", +}); +const anyWarning = expect.objectContaining({ type: "warning" }); let renderer: ReactTestRenderer | null = null; let controller: ReturnType | null = null; @@ -88,20 +141,40 @@ async function mountController() { return controller; } +// Renders again after the account, the environment or the link state changed. +async function rerender() { + await act(() => renderer?.update()); +} + function mounted() { if (controller === null) throw new Error("Controller is not mounted."); return controller; } +// Runs a request, then lets the task that adds the unlink toasts run. +async function request(run: () => Promise) { + let ok = false; + await act(async () => { + ok = await run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return ok; +} + +const unlinkThisPc = () => request(() => mounted().unlinkThisPc()); + beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.spyOn(console, "error").mockImplementation(() => undefined); testState.signedInUserId = "account-signed-in"; + testState.target = primaryTarget; testState.linkState = linkedWithBothSwitchesOff; + testState.linkError = null; testState.getToken.mockReset().mockResolvedValue("clerk-token"); for (const command of [ testState.link, testState.unlink, + testState.revoke, testState.preferences, testState.refreshDiscovery, ]) { @@ -120,97 +193,154 @@ afterEach(async () => { }); describe("unlink this PC", () => { - it("unlinks a PC linked under another account with both switches off, without a relay token", async () => { + it("unlinks a PC linked under another account with both switches off", 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(await unlinkThisPc()).toBe(true); - expect(unlinked).toBe(true); + // The local unlink carries no token. The revoke follows with the signed-in + // account's token, so the relay can drop the entry that account's refused + // link attempts left behind. expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); - expect(testState.getToken).not.toHaveBeenCalled(); + expect(testState.revoke).toHaveBeenCalledExactlyOnceWith({ + target, + clerkToken: "clerk-token", + }); expect(testState.link).not.toHaveBeenCalled(); expect(testState.preferences).not.toHaveBeenCalled(); expect(testState.refreshLink).toHaveBeenCalled(); + expect(testState.toast).toHaveBeenCalledWith(unlinkedToast); + expect(testState.toast).toHaveBeenCalledWith(otherAccountWarning); + }); + + it("unlinks when the relay token read never settles", async () => { + testState.getToken.mockReturnValue(new Promise(() => undefined)); + await mountController(); + + expect(await unlinkThisPc()).toBe(true); + + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.revoke).not.toHaveBeenCalled(); + expect(testState.toast).toHaveBeenCalledWith(unlinkedToast); + expect(testState.toast).toHaveBeenCalledWith(otherAccountWarning); }); - it("unlinks while signed out of T3 Connect", async () => { + it("unlinks while signed out and does not call the linking account another account", async () => { testState.signedInUserId = null; const signedOut = await mountController(); expect(signedOut.linkedAccountDiffers).toBe(false); - let unlinked = false; - await act(async () => { - unlinked = await mounted().unlinkThisPc(); - }); + expect(await unlinkThisPc()).toBe(true); - expect(unlinked).toBe(true); expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); expect(testState.getToken).not.toHaveBeenCalled(); + expect(testState.revoke).not.toHaveBeenCalled(); expect(testState.refreshDiscovery).not.toHaveBeenCalled(); + expect(testState.toast).toHaveBeenCalledWith(signedOutWarning); + expect(testState.toast).not.toHaveBeenCalledWith(otherAccountWarning); }); - it("also revokes at the relay when the signed-in account made the link", async () => { + it("revokes at the relay without a warning 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(await unlinkThisPc()).toBe(true); - expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ + expect(testState.revoke).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: "clerk-token", }); + expect(testState.toast).toHaveBeenCalledExactlyOnceWith(unlinkedToast); }); - it("still unlinks locally when the relay token cannot be read", async () => { + it("says the user's own account may still list this PC when the revoke fails", async () => { + testState.signedInUserId = "account-that-linked"; + testState.revoke.mockResolvedValue(linkFailure("unlink relay environment failed", null)); + await mountController(); + + expect(await unlinkThisPc()).toBe(true); + + expect(testState.toast).toHaveBeenCalledWith(unlinkedToast); + expect(testState.toast).toHaveBeenCalledWith(ownAccountWarning); + }); + + it("says the user's own account may still list this PC 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(await unlinkThisPc()).toBe(true); - expect(unlinked).toBe(true); expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.revoke).not.toHaveBeenCalled(); + expect(testState.toast).toHaveBeenCalledWith(ownAccountWarning); }); 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."))), + linkFailure("Could not unlink the environment from cloud.", null), ); await mountController(); - let unlinked = true; - await act(async () => { - unlinked = await mounted().unlinkThisPc(); - }); + expect(await unlinkThisPc()).toBe(false); - expect(unlinked).toBe(false); expect(mounted().operationError).toBe("Could not unlink the environment from cloud."); expect(testState.refreshLink).toHaveBeenCalled(); + expect(testState.revoke).not.toHaveBeenCalled(); + expect(testState.toast).not.toHaveBeenCalledWith(unlinkedToast); + expect(testState.toast).not.toHaveBeenCalledWith(anyWarning); }); - it("points the different-account conflict at the unlink control", async () => { - testState.link.mockResolvedValue(AsyncResult.failure(Cause.fail(accountConflict))); - await mountController(); + it("offers the unlink when the link state cannot be read, without claiming a link", async () => { + testState.linkState = null; + testState.linkError = "Could not read environment cloud link state."; + const unreadable = await mountController(); + expect(unreadable.linked).toBe(false); + expect(unreadable.unlinkOffer).toBe("unknown"); - let linked = true; - await act(async () => { - linked = await mounted().reconcileCloudState({ managedTunnel: true, publish: false }); + expect(await unlinkThisPc()).toBe(true); + + expect(testState.unlink).toHaveBeenCalledOnce(); + expect(testState.toast).toHaveBeenCalledExactlyOnceWith({ + type: "success", + title: "No T3 Connect link is stored on this PC", + description: "If this PC held a link, it was removed.", + }); + }); + + it("offers no unlink when the link state loaded and holds no link", async () => { + testState.linkState = { ...linkedWithBothSwitchesOff, linked: false, cloudUserId: null }; + + expect((await mountController()).unlinkOffer).toBe("none"); + }); +}); + +describe("different-account conflict", () => { + const switchTunnelOn = () => + act(async () => { + await mounted().reconcileCloudState({ managedTunnel: true, publish: false }); }); - expect(linked).toBe(false); + it("points at the unlink control by its typed reason", async () => { + // Reworded on purpose: the hint must not depend on the server's prose. + testState.link.mockResolvedValue( + linkFailure( + "Could not configure environment relay access: Linked elsewhere.", + new EnvironmentHttpConflictError({ + message: "Linked elsewhere.", + reason: "linked_to_different_cloud_account", + }), + ), + ); + await mountController(); + + await switchTunnelOn(); + expect(testState.link).toHaveBeenCalledOnce(); expect(mounted().operationError).toContain('Use "Unlink this PC" in Settings > Connections'); expect(testState.toast).toHaveBeenCalledWith( @@ -221,31 +351,241 @@ describe("unlink this PC", () => { ); }); - it("clears a shown conflict once the PC is unlinked", async () => { - testState.link.mockResolvedValue(AsyncResult.failure(Cause.fail(accountConflict))); + it("shows an older server's conflict as the server worded it", async () => { + // A server that predates the typed reason sends the message alone. + testState.link.mockResolvedValue(conflict()); await mountController(); - await act(async () => { - await mounted().reconcileCloudState({ managedTunnel: true, publish: false }); - }); + + await switchTunnelOn(); + + expect(mounted().operationError).toBe( + `Could not configure environment relay access: ${CONFLICT_MESSAGE}`, + ); + }); + + it("shows a newer server's unknown reason as the server worded it", async () => { + testState.link.mockResolvedValue(conflict("a_reason_added_later")); + await mountController(); + + await switchTunnelOn(); + + expect(mounted().operationError).toBe( + `Could not configure environment relay access: ${CONFLICT_MESSAGE}`, + ); + }); + + it("clears once the PC is unlinked", async () => { + testState.link.mockResolvedValue(accountConflict()); + await mountController(); + await switchTunnelOn(); expect(mounted().operationError).not.toBeNull(); - await act(async () => { - await mounted().unlinkThisPc(); - }); + await unlinkThisPc(); expect(mounted().operationError).toBeNull(); }); it("leaves other link failures worded as they were", async () => { + testState.link.mockResolvedValue(linkFailure("Could not obtain environment link proof.", null)); + await mountController(); + + await switchTunnelOn(); + + expect(mounted().operationError).toBe("Could not obtain environment link proof."); + }); +}); + +// The reported path: the PC is linked under another account and both switches +// show ON. Switching T3 Connect off first needs a relink, which is refused, so +// that switch stays ON. Switching publishing off next must still unlink. +describe("the Settings switches", () => { + beforeEach(() => { + testState.linkState = linkedWithBothSwitchesOn; + }); + + // What ConnectionsSettings requests: the switch that moved, plus what the + // server holds for the other one. + const switchTunnelOff = () => + request(() => mounted().reconcileSettingsSwitches({ managedTunnel: false, publish: true })); + const switchPublishingOff = () => + request(() => mounted().reconcileSettingsSwitches({ managedTunnel: true, publish: false })); + const switchLastOff = () => + request(() => mounted().reconcileSettingsSwitches({ managedTunnel: false, publish: false })); + const expectLinkKept = () => { + expect(testState.unlink).not.toHaveBeenCalled(); + expect(testState.preferences).toHaveBeenCalledExactlyOnceWith({ + target, + publishAgentActivity: false, + }); + }; + + it("unlink when this PC refused the T3 Connect switch-off for another account", async () => { + testState.link.mockResolvedValue(accountConflict()); + await mountController(); + + expect(await switchTunnelOff()).toBe(false); + expect(mounted().operationError).toContain('Use "Unlink this PC"'); + expect(testState.unlink).not.toHaveBeenCalled(); + + expect(await switchPublishingOff()).toBe(true); + + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.preferences).not.toHaveBeenCalled(); + expect(mounted().operationError).toBeNull(); + expect(testState.toast).toHaveBeenCalledWith(unlinkedToast); + expect(testState.toast).toHaveBeenCalledWith(otherAccountWarning); + }); + + it("still say so when the relay list cannot be refreshed after that unlink", async () => { + testState.link.mockResolvedValue(accountConflict()); + testState.refreshDiscovery.mockResolvedValue(linkFailure("relay list failed", null)); + await mountController(); + + await switchTunnelOff(); + expect(await switchPublishingOff()).toBe(true); + + expect(testState.toast).toHaveBeenCalledWith(unlinkedToast); + expect(testState.toast).toHaveBeenCalledWith(otherAccountWarning); + }); + + it("keep the link when the T3 Connect switch-off failed for a network error", async () => { testState.link.mockResolvedValue( - AsyncResult.failure(Cause.fail(new Error("Could not obtain environment link proof."))), + linkFailure("https://relay.example.com/v1/client/environment-links failed", null), ); await mountController(); + expect(await switchTunnelOff()).toBe(false); + await switchPublishingOff(); + + expectLinkKept(); + }); + + it("keep the link when the T3 Connect switch-off failed for a conflict without the typed reason", async () => { + testState.link.mockResolvedValue(conflict()); + await mountController(); + + await switchTunnelOff(); + await switchPublishingOff(); + + expectLinkKept(); + }); + + it("keep the link when the T3 Connect switch-off failed on the relay token", async () => { + testState.getToken.mockRejectedValueOnce(new Error("token read failed")); + await mountController(); + + expect(await switchTunnelOff()).toBe(false); + await switchPublishingOff(); + + expectLinkKept(); + }); + + it("keep the link when the T3 Connect switch-off failed for a signed-out session", async () => { + testState.getToken.mockResolvedValueOnce(null); + await mountController(); + + expect(await switchTunnelOff()).toBe(false); + await switchPublishingOff(); + + expectLinkKept(); + }); + + it("forget a refused switch-off when the signed-in account changes", async () => { + testState.link.mockResolvedValue(accountConflict()); + await mountController(); + + await switchTunnelOff(); + testState.signedInUserId = "a-third-account"; + await rerender(); + await switchPublishingOff(); + + expectLinkKept(); + }); + + it("forget a refused switch-off when the environment changes", async () => { + testState.link.mockResolvedValue(accountConflict()); + await mountController(); + + await switchTunnelOff(); + testState.target = { ...primaryTarget, environmentId: "another-environment" }; + await rerender(); + await switchPublishingOff(); + + expect(testState.unlink).not.toHaveBeenCalled(); + expect(testState.preferences).toHaveBeenCalledOnce(); + }); + + it("forget a refused switch-off when the link state changes", async () => { + testState.link.mockResolvedValue(accountConflict()); + await mountController(); + + await switchTunnelOff(); + testState.linkState = { ...linkedWithBothSwitchesOn, cloudUserId: "relinked-elsewhere" }; + await rerender(); + await switchPublishingOff(); + + expectLinkKept(); + }); + + it("forget a refused switch-off once another request goes through", async () => { + testState.link.mockResolvedValue(accountConflict()); + await mountController(); + + await switchTunnelOff(); + await act(async () => { + await mounted().reconcileSettingsSwitches({ managedTunnel: true, publish: true }); + }); + testState.preferences.mockClear(); + await switchPublishingOff(); + + expectLinkKept(); + }); + + it("keep the link when publishing goes off without a refused switch-off", async () => { + await mountController(); + + await switchPublishingOff(); + + expectLinkKept(); + }); + + it("never carry a refused switch-off into onboarding", async () => { + testState.link.mockResolvedValue(accountConflict()); + await mountController(); + + await switchTunnelOff(); + // ConnectOnboardingDialog applies its selection through reconcileCloudState. await act(async () => { await mounted().reconcileCloudState({ managedTunnel: true, publish: false }); }); - expect(mounted().operationError).toBe("Could not obtain environment link proof."); + expectLinkKept(); + }); + + it("unlink and warn about the other account when the last switch goes off", async () => { + testState.linkState = { ...linkedWithBothSwitchesOn, publishAgentActivity: false }; + await mountController(); + + expect(await switchLastOff()).toBe(true); + + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.revoke).toHaveBeenCalledExactlyOnceWith({ + target, + clerkToken: "clerk-token", + }); + // The switch adds its own success toast, so only the warning comes from here. + expect(testState.toast).toHaveBeenCalledExactlyOnceWith(otherAccountWarning); + }); + + it("unlink when the last switch goes off and the relay token read never settles", async () => { + testState.signedInUserId = "account-that-linked"; + testState.linkState = { ...linkedWithBothSwitchesOn, publishAgentActivity: false }; + testState.getToken.mockReturnValue(new Promise(() => undefined)); + await mountController(); + + expect(await switchLastOff()).toBe(true); + + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.toast).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/cloud/forkUnlinkThisPc.ts b/apps/web/src/cloud/forkUnlinkThisPc.ts index c03205cea..5b598092e 100644 --- a/apps/web/src/cloud/forkUnlinkThisPc.ts +++ b/apps/web/src/cloud/forkUnlinkThisPc.ts @@ -1,30 +1,62 @@ import { useAuth } from "@clerk/react"; +import { type EnvironmentConflictReason, EnvironmentHttpConflictError } from "@t3tools/contracts"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import * as Schema from "effect/Schema"; +import { useRef } from "react"; +import { toastManager } from "../components/ui/toast"; import { relayEnvironmentDiscovery } from "../state/relay"; import { useAtomCommand } from "../state/use-atom-command"; +import { revokeRelayLinkAfterLocalUnlink } from "./forkRevokeRelayLink"; +import type { CloudLinkTarget } from "./linkEnvironment"; import { unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom } from "./linkEnvironmentAtoms"; import type { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; +import type { CloudLinkDesiredState } from "./useCloudLinkController"; 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"; +const isEnvironmentConflict = Schema.is(EnvironmentHttpConflictError); + +// What the environment server reports when a relay config names another +// account than the stored link (validateLinkedCloudUser in apps/server/src/cloud). +const LINKED_TO_DIFFERENT_ACCOUNT: EnvironmentConflictReason = "linked_to_different_cloud_account"; + +// Matched on the typed reason, never on the message. A reason this client does +// not know, or none at all from an older server, is not a match. +function isLinkedAccountConflict(cause: unknown): boolean { + if (isEnvironmentConflict(cause)) { + return cause.reason === LINKED_TO_DIFFERENT_ACCOUNT; + } + return typeof cause === "object" && cause !== null && "cause" in cause + ? isLinkedAccountConflict(cause.cause) + : false; +} /** - * Rewrites the server's different-account conflict so it names the control - * that clears it. Every other failure message passes through unchanged. + * Words T3 Connect failures for the controller and remembers whether the last + * one was the different-account conflict. That conflict is reworded to name + * the control that clears it. Every other failure keeps its own message. */ -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; +export function useForkLinkFailures() { + const accountConflictSeen = useRef(false); + const describeFailure = (cause: unknown, message: string) => { + accountConflictSeen.current = isLinkedAccountConflict(cause); + return accountConflictSeen.current + ? `This PC is already linked to a different T3 Connect account. Use "${UNLINK_THIS_PC_LABEL}" in Settings > Connections, then try again.` + : message; + }; + return { + describeFailure, + forgetLast: () => { + accountConflictSeen.current = false; + }, + lastWasAccountConflict: () => accountConflictSeen.current, + }; } /** @@ -43,14 +75,70 @@ function isLinkedToDifferentAccount(input: { } /** - * 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. + * Whether to offer the unlink: "linked" when a link is stored, "unknown" when + * the link state could not be read, "none" when there is no link to remove. + */ +export type UnlinkThisPcOffer = "linked" | "unknown" | "none"; + +type UnlinkToast = Parameters[0]; + +const UNLINKED_TITLE = "This PC is unlinked"; + +const UNLINKED_BY_ROW: UnlinkToast = { + type: "success", + title: UNLINKED_TITLE, + description: "The T3 Connect link stored on this PC was removed.", +}; + +const UNLINKED_BY_SWITCHES: UnlinkToast = { + type: "success", + title: UNLINKED_TITLE, + description: + "T3 Connect and agent activity are both off, so the T3 Connect link stored on this PC was removed.", +}; + +const UNLINKED_UNKNOWN_LINK: UnlinkToast = { + type: "success", + title: "No T3 Connect link is stored on this PC", + description: "If this PC held a link, it was removed.", +}; + +// The relay keys a link by account and environment, so a revoke only removes +// the signed-in account's own entry. Each warning below covers one way the +// entry of the account that made the link can outlive the local unlink. +const OTHER_ACCOUNT_STILL_LISTS_THIS_PC: UnlinkToast = { + type: "warning", + title: "Another account still lists this PC", + description: + "The T3 Connect account that linked this PC still lists it. To remove it there, sign in as that account and choose Deregister.", +}; + +const SIGNED_OUT_ACCOUNT_STILL_LISTS_THIS_PC: UnlinkToast = { + type: "warning", + title: "T3 Connect still lists this PC", + description: + "You are signed out of T3 Connect, so the account that linked this PC still lists it. To remove it there, sign in as that account and choose Deregister.", +}; + +const OWN_ACCOUNT_MAY_STILL_LIST_THIS_PC: UnlinkToast = { + type: "warning", + title: "T3 Connect may still list this PC", + description: + "This PC could not be removed from your T3 Connect account. If your account still lists it, choose Deregister there.", +}; + +type UnlinkSource = "row" | "switches" | "refused-switch-off"; + +/** + * The fork's unlink for the primary environment: the "Unlink this PC" row and + * the Settings switches. The local unlink always runs first and needs no relay + * token. The relay revoke follows on its own and never holds the unlink up. */ export function useForkUnlinkThisPc(input: { readonly linkState: ReturnType; + readonly managedTunnelActive: boolean; + readonly linkFailures: ReturnType; + readonly reconcileCloudState: (desired: CloudLinkDesiredState) => Promise; readonly clearFailure: () => void; readonly reportFailure: (cause: unknown) => void; }) { @@ -58,6 +146,9 @@ export function useForkUnlinkThisPc(input: { const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { reportFailure: false, }); + const revokeRelayLink = useAtomCommand(revokeRelayLinkAfterLocalUnlink, { + reportFailure: false, + }); const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { reportFailure: false, }); @@ -65,22 +156,32 @@ export function useForkUnlinkThisPc(input: { linkedCloudUserId: input.linkState.data?.cloudUserId, signedInUserId: userId, }); + const unlinkOffer: UnlinkThisPcOffer = input.linkState.data?.linked + ? "linked" + : input.linkState.error !== null + ? "unknown" + : "none"; - const unlinkThisPc = async (): Promise => { + const revokeAtRelay = async (target: CloudLinkTarget, warnWhenNotRevoked: boolean) => { + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + const clerkToken = tokenResult._tag === "Success" ? tokenResult.value : null; + const revoked = clerkToken + ? (await revokeRelayLink({ target, clerkToken }))._tag === "Success" + : false; + if (!revoked && warnWhenNotRevoked) { + toastManager.add(OWN_ACCOUNT_MAY_STILL_LIST_THIS_PC); + } + await refreshRelayEnvironments(); + }; + + const unlink = async (source: UnlinkSource): 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, - }); + const unlinkResult = await unlinkPrimaryEnvironment({ target, clerkToken: null }); input.linkState.refresh(); if (unlinkResult._tag === "Failure") { if (!isAtomCommandInterrupted(unlinkResult)) { @@ -88,13 +189,88 @@ export function useForkUnlinkThisPc(input: { } 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. + const knownLink = unlinkOffer === "linked"; + const toasts = [ + // The switch that asked for "both off" adds its own success toast. + ...(source === "switches" + ? [] + : source === "refused-switch-off" + ? [UNLINKED_BY_SWITCHES] + : knownLink + ? [UNLINKED_BY_ROW] + : [UNLINKED_UNKNOWN_LINK]), + ...(!knownLink + ? [] + : linkedAccountDiffers + ? [OTHER_ACCOUNT_STILL_LISTS_THIS_PC] + : isSignedIn + ? [] + : [SIGNED_OUT_ACCOUNT_STILL_LISTS_THIS_PC]), + ]; + // A switch adds its own toast as soon as this resolves. Adding ours one + // task later keeps them on top of it, where the warning can be read. + setTimeout(() => { + for (const toast of toasts) { + toastManager.add(toast); + } + }, 0); + // With another account signed in, the revoke clears the publish-only entry + // that account's refused link attempts left at the relay. if (isSignedIn) { - await refreshRelayEnvironments(); + void revokeAtRelay(target, knownLink && !linkedAccountDiffers); } return true; }; - return { linkedAccountDiffers, unlinkThisPc }; + // The state a T3 Connect switch-off was refused in. A carried switch-off only + // counts while the account, the environment and the link are all unchanged. + const refusedTunnelOffIn = useRef(null); + const switchState = JSON.stringify([ + userId ?? null, + input.linkState.target?.environmentId ?? null, + input.linkState.data, + ]); + + /** + * `reconcileCloudState` for the two Settings switches. Never use it from + * onboarding, which must not tear down a link. + * + * Switching T3 Connect off while publishing stays on needs a relink, which + * this PC refuses when another account is signed in. The switch then still + * shows ON, so switching publishing off next would only write the preference + * and keep the link. Here that next request unlinks instead, as long as it + * directly follows the refusal and nothing changed in between. + */ + const reconcileSettingsSwitches = async (requested: CloudLinkDesiredState): Promise => { + const carriesRefusedTunnelOff = + refusedTunnelOffIn.current === switchState && + input.managedTunnelActive && + requested.managedTunnel && + !requested.publish; + refusedTunnelOffIn.current = null; + if (carriesRefusedTunnelOff) { + return unlink("refused-switch-off"); + } + if (!requested.managedTunnel && !requested.publish) { + return unlink("switches"); + } + input.linkFailures.forgetLast(); + const ok = await input.reconcileCloudState(requested); + if ( + !ok && + input.managedTunnelActive && + !requested.managedTunnel && + input.linkFailures.lastWasAccountConflict() + ) { + refusedTunnelOffIn.current = switchState; + } + return ok; + }; + + return { + linkedAccountDiffers, + reconcileSettingsSwitches, + unlinkOffer, + unlinkThisPc: () => unlink("row"), + }; } diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index f7b5b29bb..7992565c0 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -10,7 +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 { useForkLinkFailures, useForkUnlinkThisPc } from "./forkUnlinkThisPc"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, @@ -50,9 +50,11 @@ export function useCloudLinkController() { ); const primaryCloudLinkState = usePrimaryCloudLinkState(); const [operationError, setOperationError] = useState(null); + const forkLinkFailures = useForkLinkFailures(); const reportUpdateFailure = (cause: unknown) => { - const message = describeCloudLinkFailure( + const message = forkLinkFailures.describeFailure( + cause, cause instanceof Error ? cause.message : "Could not update T3 Connect access.", ); const traceId = findErrorTraceId(cause); @@ -79,12 +81,6 @@ 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); @@ -160,6 +156,17 @@ export function useCloudLinkController() { return true; }; + // Fork: an unlink that does not depend on what the switches show. + const { linkedAccountDiffers, reconcileSettingsSwitches, unlinkOffer, unlinkThisPc } = + useForkUnlinkThisPc({ + linkState: primaryCloudLinkState, + managedTunnelActive, + linkFailures: forkLinkFailures, + reconcileCloudState, + clearFailure: () => setOperationError(null), + reportFailure: reportUpdateFailure, + }); + return { isSignedIn, linkState: primaryCloudLinkState, @@ -169,6 +176,8 @@ export function useCloudLinkController() { publishAgentActivity, operationError, reconcileCloudState, + reconcileSettingsSwitches, + unlinkOffer, unlinkThisPc, }; } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 08eba8ff4..c205148bd 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1684,12 +1684,12 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b const { isSignedIn, linkState: primaryCloudLinkState, - linked, linkedAccountDiffers, managedTunnelActive, publishAgentActivity, operationError, - reconcileCloudState, + reconcileSettingsSwitches: reconcileCloudState, + unlinkOffer, unlinkThisPc, } = useCloudLinkController(); const [isUpdating, setIsUpdating] = useState(false); @@ -1776,7 +1776,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b } /> void; }) { const [isUnlinking, setIsUnlinking] = useState(false); - if (!linked) return null; + if (offer === "none") 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.", - }); - } + await unlinkThisPc(); setIsUnlinking(false); onBusyChange(false); }; @@ -60,9 +52,11 @@ export function ForkUnlinkThisPcRow({ 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.", + "description": "Settings > Connections shows an Unlink this PC row whenever the environment holds a T3 Connect link, and when the link state cannot be read. 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. The local unlink always runs first; the relay revoke follows on its own with the signed-in account's token and never holds the unlink up. Upstream only unlinks when the last switch that shows ON is turned OFF, and switching T3 Connect off while publishing is on needs a relink that this PC refuses for another account, so that switch stayed ON and the link stayed. Here, for the two Settings switches only, a T3 Connect switch-off refused for the typed different-account reason is carried into the request that directly follows it, so switching publishing off next unlinks, as long as the account, the environment and the link state are unchanged. Onboarding keeps upstream's reconcileCloudState and never unlinks. The different-account conflict carries a typed reason on the wire, the error names the row by that reason, and the row says so when the stored link belongs to another account than the one signed in. After an unlink the user is told when the account that made the link still lists this PC.", + "keep": "forkUnlinkThisPc.ts, its test, forkRevokeRelayLink.ts and ForkUnlinkThisPcRow.tsx are ours whole. In useCloudLinkController.ts these survive: the useForkLinkFailures call and its describeFailure call around the failure message, the useForkUnlinkThisPc call after reconcileCloudState, and linkedAccountDiffers, reconcileSettingsSwitches, unlinkOffer and unlinkThisPc in the returned object. reconcileCloudState itself is returned as upstream wrote it. In ConnectionsSettings.tsx these survive: the ForkUnlinkThisPcRow import, the row rendered after the Publish agent activity row in ConfiguredCloudLinkRow, the isUnlinking state that feeds isBusy, and reconcileSettingsSwitches taken from the controller in place of reconcileCloudState. In environmentHttp.ts these survive: EnvironmentConflictReason, its type, and the optional reason string on EnvironmentHttpConflictError. In apps/server/src/cloud/http.ts the reason on the conflict that validateLinkedCloudUser returns survives, with its type import, and in server.test.ts the assertion on it. The Unlink this PC paragraph under Manage or revoke access in remote-access.md survives. Everything else in those files takes upstream.", "files": [ "apps/web/src/cloud/forkUnlinkThisPc.ts", + "apps/web/src/cloud/forkRevokeRelayLink.ts", "apps/web/src/components/settings/ForkUnlinkThisPcRow.tsx", "apps/web/src/cloud/useCloudLinkController.ts", "apps/web/src/components/settings/ConnectionsSettings.tsx", + "packages/contracts/src/environmentHttp.ts", + "apps/server/src/cloud/http.ts", + "apps/server/src/server.test.ts", "docs/user/remote-access.md" ], "test": "apps/web/src/cloud/forkUnlinkThisPc.test.tsx", diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index a71bd6046..c8be956f3 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -81,6 +81,10 @@ export const EnvironmentOperationForbiddenReason = Schema.Literals([ ]); export type EnvironmentOperationForbiddenReason = typeof EnvironmentOperationForbiddenReason.Type; +// Fork: why a 409 was returned, so clients match a value and not the message. +export const EnvironmentConflictReason = Schema.Literals(["linked_to_different_cloud_account"]); +export type EnvironmentConflictReason = typeof EnvironmentConflictReason.Type; + export const EnvironmentInternalErrorReason = Schema.Literals([ "bootstrap_validation_failed", "browser_session_issuance_failed", @@ -279,6 +283,9 @@ export class EnvironmentHttpConflictError extends Schema.TaggedError