diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index e0aea76ef04c2..7ebc4d8bfbb8c 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -313,7 +313,7 @@ const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () { baseUrl: runtimeState.value.origin, }).pipe( Effect.flatMap((client) => - client.connect.unlink({ headers: { authorization: `Bearer ${token}` } }), + client.connect.unlink({ headers: { authorization: `Bearer ${token}` }, query: {} }), ), Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT), ), diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index facd9f8912e53..8d7144a6650cd 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -138,6 +138,7 @@ describe("releaseManagedTunnelOnShutdown", () => { readonly address?: HttpServer.HttpServer["Service"]["address"]; readonly respondEffect?: Effect.Effect; readonly getExisting?: CliTokenManager.CloudCliTokenManager["Service"]["getExisting"]; + readonly beforeLinkStateLock?: Effect.Effect; } const writeLauncherState = (update: ServiceUpdateRecord) => @@ -200,7 +201,10 @@ describe("releaseManagedTunnelOnShutdown", () => { tunnelConnected: Stream.empty, inspectConnector: Effect.succeed(null), readinessChanged: Stream.empty, - withLinkStateLock: (effect) => effect, + withLinkStateLock: (effect) => + harness.beforeLinkStateLock + ? harness.beforeLinkStateLock.pipe(Effect.andThen(effect)) + : effect, }), ), Effect.provideService( @@ -248,6 +252,78 @@ describe("releaseManagedTunnelOnShutdown", () => { [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"], ] as const; + it.effect( + "account unlink stops the connector and prevents startup from recreating its link", + () => { + const { store, values } = makeMemorySecretStore([ + ...managedLinkSecrets, + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + yield* link.unlink("user-123"); + + expect(applyConfigCalls).toEqual([null]); + expect(values.has(CLOUD_LINKED_USER_ID)).toBe(false); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false); + expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(false); + expect(values.has(CLOUD_CLI_DESIRED_LINK_SECRET)).toBe(false); + expect(yield* link.reconcileDesiredLinkIfStillDesired("http://127.0.0.1:3773")).toBeNull(); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }, + ); + + it.effect( + "a delayed account unlink preserves a host relinked before it acquired the lock", + () => { + const { store, values } = makeMemorySecretStore([ + ...managedLinkSecrets, + [CLOUD_LINKED_USER_ID, "user-123"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + const error = yield* link.unlink("user-123").pipe(Effect.flip); + + expect(error._tag).toBe("CloudLinkAccountMismatchError"); + expect(applyConfigCalls).toEqual([]); + expect(values.has(CLOUD_CLI_DESIRED_LINK_SECRET)).toBe(true); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); + expect(new TextDecoder().decode(values.get(CLOUD_LINKED_USER_ID))).toBe("other-user"); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + beforeLinkStateLock: store + .set(CLOUD_LINKED_USER_ID, new TextEncoder().encode("other-user")) + .pipe(Effect.orDie), + }), + ); + }, + ); + + it.effect("legacy unlink still stops a host without specifying its account", () => { + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + yield* link.unlink(); + + expect(applyConfigCalls).toEqual([null]); + expect(values.has(CLOUD_CLI_DESIRED_LINK_SECRET)).toBe(false); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + it.effect("does not recreate a link that was unlinked while startup registration retried", () => { const { store, values } = makeMemorySecretStore(managedLinkSecrets); const applyConfigCalls: Array = []; diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 99530612463e1..af7f868268802 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -452,7 +452,12 @@ export class CloudLink extends Context.Service< EnvironmentCloudLinkStateResult, CloudLinkInternalError >; - readonly unlink: () => Effect.Effect; + readonly unlink: ( + expectedCloudUserId?: string, + ) => Effect.Effect< + EnvironmentCloudRelayConfigResult, + CloudLinkInternalError | CloudLinkAccountMismatchError + >; readonly updatePreferences: ( input: EnvironmentCloudPreferencesRequest, ) => Effect.Effect< @@ -1309,9 +1314,18 @@ const make = Effect.gen(function* () { ); const unlink = Effect.fn("environment.cloud.unlink")( - function* () { + function* (expectedCloudUserId?: string) { return yield* endpointRuntime.withLinkStateLock( Effect.gen(function* () { + if (expectedCloudUserId !== undefined) { + const linkedUser = yield* secrets.get(CLOUD_LINKED_USER_ID); + if ( + Option.isNone(linkedUser) || + bytesToString(linkedUser.value) !== expectedCloudUserId + ) { + return yield* new CloudLinkAccountMismatchError({}); + } + } const endpointRuntimeStatus = yield* endpointRuntime.applyConfig(null); yield* Effect.all( [ diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 52612f22825d4..59681f707115c 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -132,9 +132,9 @@ export const layer = HttpApiBuilder.group( .handle("checkReachability", () => requireEnvironmentScope(AuthRelayReadScope).pipe(Effect.andThen(reachability.check)), ) - .handle("unlink", () => + .handle("unlink", ({ query }) => requireEnvironmentScope(AuthRelayWriteScope).pipe( - Effect.andThen(toHttpError(cloudLink.unlink())), + Effect.andThen(toHttpError(cloudLink.unlink(query.expectedCloudUserId))), ), ) .handle("preferences", ({ payload }) => diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 68b2c92c05c1a..6c8c369757c84 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -25,7 +25,11 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; -import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; +import { + readPrimaryCloudLinkState, + unlinkPrimaryEnvironmentFromCloud, + type CloudLinkTarget, +} from "./linkEnvironment"; const TARGET: CloudLinkTarget = { environmentId: "environment-1", @@ -145,6 +149,93 @@ afterEach(() => { }); describe("web cloud link environment client", () => { + it.effect( + "unlinks without relay-read access before deregistering the account from the relay", + () => + Effect.gen(function* () { + const fetchMock = vi + .fn() + .mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + return Promise.resolve( + request.method === "GET" + ? Response.json( + { _tag: "EnvironmentHttpForbiddenError", message: "Missing relay:read" }, + { status: 403 }, + ) + : Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("window", { location: { origin: "http://127.0.0.1:3000" } }); + + yield* withServices( + unlinkPrimaryEnvironmentFromCloud({ + target: TARGET, + clerkToken: "clerk-token", + expectedCloudUserId: "user-1", + }), + ); + + const requests = fetchMock.mock.calls.map(([input, init]) => new Request(input, init)); + expect(requests.map((request) => [request.method, new URL(request.url).pathname])).toEqual([ + ["POST", "/api/connect/unlink"], + ["DELETE", "/v1/client/environment-links/environment-1"], + ]); + expect(new URL(requests[0]!.url).searchParams.get("expectedCloudUserId")).toBe("user-1"); + expect(requests[1]!.headers.get("authorization")).toBe("Bearer clerk-token"); + }), + ); + + it.effect("skips relay deregistration when guarded unlink reports an account mismatch", () => + Effect.gen(function* () { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json( + { _tag: "EnvironmentHttpConflictError", message: "Account mismatch" }, + { status: 409 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("window", { location: { origin: "http://127.0.0.1:3000" } }); + + yield* withServices( + unlinkPrimaryEnvironmentFromCloud({ + target: TARGET, + clerkToken: "clerk-token", + expectedCloudUserId: "user-1", + }), + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const request = new Request(fetchMock.mock.calls[0]![0], fetchMock.mock.calls[0]![1]); + expect(request.method).toBe("POST"); + expect(new URL(request.url).searchParams.get("expectedCloudUserId")).toBe("user-1"); + }), + ); + + it.effect("preserves conflict failures for unguarded unlink", () => + Effect.gen(function* () { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json( + { _tag: "EnvironmentHttpConflictError", message: "Unlink conflict" }, + { status: 409 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("window", { location: { origin: "http://127.0.0.1:3000" } }); + + const error = yield* withServices( + unlinkPrimaryEnvironmentFromCloud({ target: TARGET, clerkToken: "clerk-token" }), + ).pipe(Effect.flip); + + expect(error.message).toContain("Unlink conflict"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }), + ); it.effect("uses desktop bearer auth for primary cloud link state", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index e6e42dc9218b5..3f66d950f8d11 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -220,6 +220,7 @@ export const updatePrimaryCloudPreferences: (input: { export const unlinkPrimaryEnvironmentFromCloud: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null; + readonly expectedCloudUserId?: string; }) => Effect.Effect< void, CloudEnvironmentLinkError, @@ -227,11 +228,26 @@ export const unlinkPrimaryEnvironmentFromCloud: (input: { > = Effect.fn("linkEnvironment.unlinkPrimaryEnvironmentFromCloud")(function* (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null; + readonly expectedCloudUserId?: string; }) { const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); - yield* client.connect - .unlink({ headers: {} }) - .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud."))); + const unlinked = yield* client.connect + .unlink({ + headers: {}, + query: + input.expectedCloudUserId === undefined + ? {} + : { expectedCloudUserId: input.expectedCloudUserId }, + }) + .pipe( + Effect.catchTags({ + // Guarded unlink conflicts mean this host is outside the departing account's scope. + EnvironmentHttpConflictError: (error) => + input.expectedCloudUserId === undefined ? Effect.fail(error) : Effect.succeed(null), + }), + Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")), + ); + if (unlinked === null) return; const configuredRelayUrl = relayUrl(); if (configuredRelayUrl && input.clerkToken) { diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts index 4a3541cd3a102..8d186d56290c7 100644 --- a/apps/web/src/cloud/linkEnvironmentAtoms.ts +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -33,8 +33,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti label: "web:cloud:unlink-primary-environment", scheduler: cloudLinkScheduler, concurrency: cloudLinkConcurrency, - execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => - unlinkPrimaryEnvironmentFromCloud(input), + execute: (input: { + readonly target: CloudLinkTarget; + readonly clerkToken: string | null; + readonly expectedCloudUserId?: string; + }) => unlinkPrimaryEnvironmentFromCloud(input), }); export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connectionAtomRuntime, { diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index 6b66bed808925..e807a7611de40 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,10 +1,11 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon } from "lucide-react"; +import { LogInIcon, LogOutIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; import { T3_CONNECT_ACCOUNT_PAGES } from "./T3ConnectAccountPages"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; +import { useT3ConnectSignOut } from "./useT3ConnectSignOut"; export function T3ConnectSidebarSignIn() { if (!hasCloudPublicConfig()) return null; @@ -20,6 +21,7 @@ export function T3ConnectSidebarAvatar() { function ConfiguredT3ConnectSidebarAvatar() { const { isLoaded, isSignedIn } = useAuth(); + const { signOut, signOutAll, hasOtherSessions } = useT3ConnectSignOut(); if (!isLoaded || !isSignedIn) return null; @@ -30,9 +32,22 @@ function ConfiguredT3ConnectSidebarAvatar() { avatarBox: "size-7", // Clerk's unlayered trigger reset (padding, radius, background) beats layered utilities. userButtonTrigger: "mb-1! rounded-lg! p-1! hover:bg-sidebar-row-hover!", + // Host cleanup must finish before Clerk removes the session or navigates away. + userButtonPopoverActionButton__signOut: { display: "none" }, + userButtonPopoverActionButton__signOutAll: { display: "none" }, }, }} > + + } onClick={signOut} /> + {hasOtherSessions ? ( + } + onClick={signOutAll} + /> + ) : null} + {T3_CONNECT_ACCOUNT_PAGES.map((page) => ( ({ + session: null as { + id: string; + status: "active" | "pending" | "ended"; + user: { id: string }; + getToken: () => Promise; + } | null, + sessions: [] as Array<{ + id: string; + status: "active" | "pending" | "ended"; + user: { id: string }; + getToken: () => Promise; + }>, + source: "window-origin", + httpBaseUrl: "http://localhost:3773", + hosted: false, + localEnabled: true, + linked: true, + signOut: vi.fn(), + unlink: vi.fn(), + refresh: vi.fn(), + toast: vi.fn(), +})); + +vi.mock(import("@clerk/react"), async (original) => ({ + ...(await original()), + useClerk: (() => ({ signOut: state.signOut })) as never, + useSession: (() => ({ session: state.session })) as never, + useSessionList: (() => ({ sessions: state.sessions })) as never, +})); +vi.mock(import("../../cloud/linkEnvironmentAtoms"), () => ({ + unlinkPrimaryEnvironment: {} as never, +})); +vi.mock(import("../../cloud/primaryCloudLinkState"), () => ({ + usePrimaryCloudLinkState: (() => ({ + target: { + environmentId: EnvironmentId.make("local-host"), + label: "Local host", + httpBaseUrl: state.httpBaseUrl, + wsBaseUrl: "ws://localhost:3773", + }, + refresh: state.refresh, + })) as never, +})); +vi.mock(import("../../cloud/publicConfig"), () => ({ + resolveRelayClerkTokenOptions: () => ({ template: "relay", skipCache: true }), +})); +vi.mock(import("../../environments/primary"), () => ({ + isLoopbackHostname: (hostname) => ["localhost", "127.0.0.1", "[::1]"].includes(hostname), + readPrimaryEnvironmentTarget: (() => + state.localEnabled + ? { source: state.source, target: { httpBaseUrl: state.httpBaseUrl } } + : null) as never, +})); +vi.mock(import("../../hostedPairing"), () => ({ + isHostedStaticApp: () => state.hosted, +})); +vi.mock(import("../../state/use-atom-command"), () => ({ + useAtomCommand: (() => state.unlink) as never, +})); +vi.mock(import("../ui/toast"), () => ({ + toastManager: { add: state.toast } as never, +})); + +import { useT3ConnectSignOut } from "./useT3ConnectSignOut"; + +function SignOutActions() { + const actions = useT3ConnectSignOut(); + return ( + <> + + {actions.hasOtherSessions ? : null} + + ); +} + +function setup() { + vi.clearAllMocks(); + state.session = { + id: "session-1", + status: "active", + user: { id: "account-1" }, + getToken: vi.fn(async () => "token-1"), + }; + state.sessions = [state.session]; + state.source = "window-origin"; + state.httpBaseUrl = "http://localhost:3773"; + state.hosted = false; + state.localEnabled = true; + state.linked = true; + state.unlink.mockImplementation(async () => { + state.linked = false; + return AsyncResult.success(undefined); + }); + state.signOut.mockImplementation(async () => { + state.session = null; + }); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("T3 Connect sign-out", () => { + it("keeps the account signed in until its local host has been unlinked", async () => { + setup(); + state.sessions.push({ ...state.session!, id: "ended-session", status: "ended" }); + const unlink = deferred(); + const started = deferred(); + state.unlink.mockImplementation(async () => { + started.resolve(); + await unlink.promise; + state.linked = false; + return AsyncResult.success(undefined); + }); + // These actions do not depend on routes; keeping the router out isolates the auth lifecycle. + await using screen = await render(, { router: false }); + await userEvent.click(screen.getByRole("button", { name: "Sign out", exact: true })); + await started.promise; + await expect + .element(screen.getByRole("button", { name: "Sign out all" })) + .not.toBeInTheDocument(); + + expect(state.session?.id).toBe("session-1"); + expect(state.linked).toBe(true); + expect(state.signOut).not.toHaveBeenCalled(); + + await act(async () => unlink.resolve()); + await vi.waitFor(() => expect(state.session).toBeNull()); + expect(state.linked).toBe(false); + expect(state.unlink).toHaveBeenCalledWith( + expect.objectContaining({ expectedCloudUserId: "account-1", clerkToken: "token-1" }), + ); + }); + + it.each([ + ["hosted web", "https://app.t3.codes", true, true], + ["LAN browser", "http://192.168.1.20:3773", false, true], + ["Tailscale browser", "https://host.example.ts.net", false, true], + ["remote-only desktop", "http://localhost:3773", false, false], + ])( + "signs out %s without unlinking another machine", + async (_label, url, hosted, localEnabled) => { + setup(); + state.httpBaseUrl = url; + state.hosted = hosted; + state.localEnabled = localEnabled; + await using screen = await render(, { router: false }); + await userEvent.click(screen.getByRole("button", { name: "Sign out", exact: true })); + + await vi.waitFor(() => expect(state.session).toBeNull()); + expect(state.unlink).not.toHaveBeenCalled(); + expect(state.linked).toBe(true); + }, + ); + + it("unlinks a desktop-managed host even when WSL supplies a non-loopback origin", async () => { + setup(); + state.source = "desktop-managed"; + state.httpBaseUrl = "http://172.20.0.2:3773"; + await using screen = await render(, { router: false }); + await userEvent.click(screen.getByRole("button", { name: "Sign out", exact: true })); + + await vi.waitFor(() => expect(state.session).toBeNull()); + expect(state.linked).toBe(false); + }); + + it("still stops local exposure when the departing session cannot obtain a relay token", async () => { + setup(); + state.session!.getToken = vi.fn(async () => { + throw new Error("Session expired"); + }); + await using screen = await render(, { router: false }); + await userEvent.click(screen.getByRole("button", { name: "Sign out", exact: true })); + + await vi.waitFor(() => expect(state.session).toBeNull()); + expect(state.linked).toBe(false); + expect(state.unlink).toHaveBeenCalledWith(expect.objectContaining({ clerkToken: null })); + }); + + it("reports local unlink failure before ending the account session", async () => { + setup(); + state.unlink.mockResolvedValue(AsyncResult.failure(Cause.die(new Error("Host unavailable")))); + await using screen = await render(, { router: false }); + await userEvent.click(screen.getByRole("button", { name: "Sign out", exact: true })); + + await vi.waitFor(() => expect(state.toast).toHaveBeenCalled()); + expect(state.session?.id).toBe("session-1"); + expect(state.linked).toBe(true); + }); + + it("checks every departing account when signing out all sessions", async () => { + setup(); + state.sessions.push({ + id: "session-2", + status: "pending", + user: { id: "account-2" }, + getToken: async () => "token-2", + }); + await using screen = await render(, { router: false }); + state.sessions.push({ ...state.session!, id: "ended-session", status: "ended" }); + await screen.rerender(); + await userEvent.click(screen.getByRole("button", { name: "Sign out all" })); + + await vi.waitFor(() => expect(state.session).toBeNull()); + expect(state.unlink.mock.calls.map(([input]) => input.expectedCloudUserId)).toEqual([ + "account-1", + "account-2", + ]); + expect(state.signOut).toHaveBeenCalledWith(undefined, undefined); + }); + + it("does not unlink on account switching, signed-out startup, or unmount", async () => { + setup(); + await using screen = await render(, { router: false }); + state.session = { + id: "session-2", + status: "active", + user: { id: "account-2" }, + getToken: async () => "token-2", + }; + await screen.rerender(); + state.session = null; + await screen.rerender(); + await screen.unmount(); + + expect(state.unlink).not.toHaveBeenCalled(); + expect(state.linked).toBe(true); + }); +}); diff --git a/apps/web/src/components/clerk/useT3ConnectSignOut.ts b/apps/web/src/components/clerk/useT3ConnectSignOut.ts new file mode 100644 index 0000000000000..22dedcecfc286 --- /dev/null +++ b/apps/web/src/components/clerk/useT3ConnectSignOut.ts @@ -0,0 +1,78 @@ +import { useClerk, useSession, useSessionList } from "@clerk/react"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useRef } from "react"; + +import { unlinkPrimaryEnvironment } from "../../cloud/linkEnvironmentAtoms"; +import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; +import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; +import { isLoopbackHostname, readPrimaryEnvironmentTarget } from "../../environments/primary"; +import { isHostedStaticApp } from "../../hostedPairing"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { toastManager } from "../ui/toast"; + +export function useT3ConnectSignOut() { + const clerk = useClerk(); + const { session } = useSession(); + const { sessions } = useSessionList(); + const eligibleSessions = + sessions?.filter((session) => session.status === "active" || session.status === "pending") ?? + []; + const link = usePrimaryCloudLinkState(); + const unlink = useAtomCommand(unlinkPrimaryEnvironment, { reportFailure: false }); + const pending = useRef(false); + + const signOut = async (allSessions: boolean) => { + if (pending.current) return; + pending.current = true; + const result = await settlePromise(async () => { + const primary = readPrimaryEnvironmentTarget(); + const isLocalHost = + !isHostedStaticApp() && + primary !== null && + (primary.source === "desktop-managed" || + isLoopbackHostname(new URL(primary.target.httpBaseUrl).hostname)); + if (isLocalHost && link.target !== null) { + const removedSessions = allSessions ? eligibleSessions : session ? [session] : []; + for (const removedSession of removedSessions) { + if (removedSession.user === null) continue; + const token = await settlePromise(() => + removedSession.getToken(resolveRelayClerkTokenOptions()), + ); + const unlinked = await unlink({ + target: link.target, + clerkToken: token._tag === "Success" ? token.value : null, + expectedCloudUserId: removedSession.user.id, + }); + link.refresh(); + if (unlinked._tag === "Failure") { + if (isAtomCommandInterrupted(unlinked)) return; + throw squashAtomCommandFailure(unlinked); + } + } + } + await clerk.signOut( + undefined, + !allSessions && session ? { sessionId: session.id } : undefined, + ); + }); + pending.current = false; + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not sign out of T3 Connect", + description: error instanceof Error ? error.message : "Try again.", + }); + } + }; + + return { + signOut: () => void signOut(false), + signOutAll: () => void signOut(true), + hasOtherSessions: eligibleSessions.length > 1, + }; +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index c7e69989e0493..a8dab7c795bd8 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -39,6 +39,11 @@ connection. Pull request diffs and provider settings keep working after the previous credential expires. A failed renewal affects that request; it does not disconnect an otherwise healthy conversation. +Signing out of T3 Connect in the host's desktop app or locally hosted web app +disconnects that host from the account before signing out. Signing out on a +phone or remote browser disconnects only that client. Sign in and enable T3 +Connect again to reconnect a host. + ### Scripts and agents `t3 up` asks nothing when you name a choice: `--connect`, `--lan`, or their diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 30c33deebd55c..9a25301b56aa9 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -788,6 +788,7 @@ class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") .add( HttpApiEndpoint.post("unlink", "/api/connect/unlink", { headers: OptionalBearerHeaders, + query: Schema.Struct({ expectedCloudUserId: Schema.optionalKey(TrimmedNonEmptyString) }), success: EnvironmentCloudRelayConfigResult, error: EnvironmentHttpCloudErrors, }).middleware(EnvironmentAuthenticatedAuth),