From f3d20fd50d7364bea4b23ddfaa541a4e357cfe1e Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:43:10 +0800 Subject: [PATCH] fix(auth): 'Sign in as a different user' performs full logout (#1179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NoAccessPage button previously only called nav.push('/login'), leaving the session cookie, React Query cache, and local auth state intact. AuthInitializer then silently re-authenticates and bounces the user right back to the workspace URL — the button appeared broken. Extract the logout flow (clear per-workspace storage, clear cookies, clear multica_tabs, queryClient.clear(), authStore.logout(), navigate to /login) into a shared useLogout() hook in packages/views/auth/. AppSidebar and NoAccessPage both use it now; any future logout entry point can too. Co-authored-by: Claude Opus 4.6 (1M context) --- packages/views/auth/index.ts | 1 + packages/views/auth/use-logout.ts | 63 +++++++++++++++++++ packages/views/layout/app-sidebar.tsx | 33 +--------- .../views/workspace/no-access-page.test.tsx | 18 +++++- packages/views/workspace/no-access-page.tsx | 4 +- 5 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 packages/views/auth/use-logout.ts diff --git a/packages/views/auth/index.ts b/packages/views/auth/index.ts index 979798bee1..b449949a95 100644 --- a/packages/views/auth/index.ts +++ b/packages/views/auth/index.ts @@ -1 +1,2 @@ export { LoginPage, validateCliCallback } from "./login-page"; +export { useLogout } from "./use-logout"; diff --git a/packages/views/auth/use-logout.ts b/packages/views/auth/use-logout.ts new file mode 100644 index 0000000000..fdc19ebde7 --- /dev/null +++ b/packages/views/auth/use-logout.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "@multica/core/auth"; +import { workspaceKeys } from "@multica/core/workspace/queries"; +import { clearWorkspaceStorage, defaultStorage } from "@multica/core/platform"; +import { paths } from "@multica/core/paths"; +import type { Workspace } from "@multica/core/types"; +import { useNavigation } from "../navigation"; + +/** + * Performs a complete logout: clears per-workspace client storage, legacy + * cookies, the desktop tab state, the entire React Query cache, the + * in-memory auth store, and finally navigates to /login. Wraps what was + * previously duplicated in app-sidebar's logout handler so NoAccessPage's + * "Sign in as a different user" and any future entry point can use the + * same flow. + * + * Without a unified logout, callers that only do `navigate('/login')` + * leave the auth cookie + React Query cache + local storage intact — + * AuthInitializer then silently re-authenticates the user on the login + * page and redirects them back where they came from. + */ +export function useLogout() { + const queryClient = useQueryClient(); + const authLogout = useAuthStore((s) => s.logout); + const { push } = useNavigation(); + + return useCallback(() => { + // Clear workspace-scoped storage for every workspace this user has + // access to, BEFORE clearing the React Query cache (which holds the + // workspace list). Otherwise per-workspace drafts/chat/etc would leak + // to the next user on this device. + const cachedWorkspaces = + queryClient.getQueryData(workspaceKeys.list()) ?? []; + for (const ws of cachedWorkspaces) { + clearWorkspaceStorage(defaultStorage, ws.slug); + } + + // Clear the last-workspace-slug cookie. Otherwise on a shared device + // the next user gets redirected by the proxy to the previous user's + // last workspace, then bounced to NoAccessPage — confusing. + if (typeof document !== "undefined") { + document.cookie = + "last_workspace_slug=; path=/; max-age=0; SameSite=Lax"; + } + + // Clear desktop tab state. Tab paths can contain workspace slugs and + // issue UUIDs that must not survive across user sessions on a shared + // machine. No-op on web (web doesn't write this key). + defaultStorage.removeItem("multica_tabs"); + + queryClient.clear(); + authLogout(); + + // Navigate to /login explicitly. authLogout() clears state but doesn't + // move the URL — without this the caller might be on a workspace URL + // which renders null (layout gates on user) and leaves the user + // stuck on a blank page. + push(paths.login()); + }, [queryClient, authLogout, push]); +} diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index a6aaf57642..38f5f919fb 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -75,8 +75,8 @@ import { useModalStore } from "@multica/core/modals"; import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks"; import { pinListOptions } from "@multica/core/pins/queries"; import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations"; -import type { PinnedItem, Workspace } from "@multica/core/types"; -import { clearWorkspaceStorage, defaultStorage } from "@multica/core/platform"; +import type { PinnedItem } from "@multica/core/types"; +import { useLogout } from "../auth"; // Nav items reference WorkspacePaths method names so they can be resolved // against the current workspace slug at render time (see AppSidebar body). @@ -196,7 +196,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } const { pathname, push } = useNavigation(); const user = useAuthStore((s) => s.user); const userId = useAuthStore((s) => s.user?.id); - const authLogout = useAuthStore((s) => s.logout); + const logout = useLogout(); const workspace = useCurrentWorkspace(); const p = useWorkspacePaths(); const { data: workspaces = [] } = useQuery(workspaceListOptions()); @@ -262,33 +262,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() }); }, }); - const logout = () => { - // Clear workspace-scoped storage for every workspace this user has access to, - // before clearing the React Query cache (which holds the workspace list). - // Otherwise per-workspace drafts/chat/etc would leak to the next user on this device. - const cachedWorkspaces = - queryClient.getQueryData(workspaceKeys.list()) ?? []; - for (const ws of cachedWorkspaces) { - clearWorkspaceStorage(defaultStorage, ws.slug); - } - // Clear the last-workspace-slug cookie. Otherwise on a shared device the - // next user gets redirected by the proxy to the previous user's last - // workspace (then bounced to NoAccessPage by the layout — confusing). - if (typeof document !== "undefined") { - document.cookie = "last_workspace_slug=; path=/; max-age=0; SameSite=Lax"; - } - // Clear desktop tab state. Tab paths can contain issue UUIDs which must - // not survive across user sessions on a shared machine. No-op on web - // (web doesn't write this key). - defaultStorage.removeItem("multica_tabs"); - queryClient.clear(); - authLogout(); - // Navigate to /login explicitly. authLogout() clears state but doesn't - // move the URL, and the current URL is a workspace-scoped path that - // means nothing without auth — without this redirect the user stays on - // /{slug}/... and the layout renders null (blank screen). - push(paths.login()); - }; // Global "C" shortcut to open create-issue modal (like Linear) useEffect(() => { diff --git a/packages/views/workspace/no-access-page.test.tsx b/packages/views/workspace/no-access-page.test.tsx index 4237e8824e..2a477f0c19 100644 --- a/packages/views/workspace/no-access-page.test.tsx +++ b/packages/views/workspace/no-access-page.test.tsx @@ -3,12 +3,21 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { NoAccessPage } from "./no-access-page"; const navigate = vi.fn(); +const logout = vi.fn(); + vi.mock("../navigation", () => ({ useNavigation: () => ({ push: navigate, replace: navigate }), })); +vi.mock("../auth", () => ({ + useLogout: () => logout, +})); + describe("NoAccessPage", () => { - beforeEach(() => navigate.mockReset()); + beforeEach(() => { + navigate.mockReset(); + logout.mockReset(); + }); it("renders generic message that doesn't leak existence", () => { render(); @@ -23,11 +32,14 @@ describe("NoAccessPage", () => { expect(navigate).toHaveBeenCalledWith("/"); }); - it("navigates to login on 'Sign in as a different user'", () => { + it("fully logs out on 'Sign in as a different user' instead of just navigating", () => { render(); fireEvent.click( screen.getByRole("button", { name: /sign in as a different user/i }), ); - expect(navigate).toHaveBeenCalledWith("/login"); + expect(logout).toHaveBeenCalledTimes(1); + // Should NOT just navigate to /login — that would leave the session + // cookie + auth state intact and AuthInitializer would re-auth. + expect(navigate).not.toHaveBeenCalledWith("/login"); }); }); diff --git a/packages/views/workspace/no-access-page.tsx b/packages/views/workspace/no-access-page.tsx index 14dfb74ef3..d698777a07 100644 --- a/packages/views/workspace/no-access-page.tsx +++ b/packages/views/workspace/no-access-page.tsx @@ -3,6 +3,7 @@ import { Button } from "@multica/ui/components/ui/button"; import { paths } from "@multica/core/paths"; import { useNavigation } from "../navigation"; +import { useLogout } from "../auth"; /** * Rendered when the workspace slug in the URL does not resolve to a workspace @@ -12,6 +13,7 @@ import { useNavigation } from "../navigation"; */ export function NoAccessPage() { const nav = useNavigation(); + const logout = useLogout(); return (
@@ -26,7 +28,7 @@ export function NoAccessPage() { -