Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
a7db217415 fix(auth): 'Sign in as a different user' performs full logout
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) <noreply@anthropic.com>
2026-04-16 19:40:00 +08:00
5 changed files with 85 additions and 34 deletions

View File

@@ -1 +1,2 @@
export { LoginPage, validateCliCallback } from "./login-page";
export { useLogout } from "./use-logout";

View File

@@ -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<Workspace[]>(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]);
}

View File

@@ -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<Workspace[]>(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(() => {

View File

@@ -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(<NoAccessPage />);
@@ -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(<NoAccessPage />);
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");
});
});

View File

@@ -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 (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 px-6 text-center">
<div className="space-y-2">
@@ -26,7 +28,7 @@ export function NoAccessPage() {
<Button onClick={() => nav.push(paths.root())}>
Go to my workspaces
</Button>
<Button variant="outline" onClick={() => nav.push(paths.login())}>
<Button variant="outline" onClick={logout}>
Sign in as a different user
</Button>
</div>