mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
fix(auth): 'Sign in as a different user' performs full logout (#1179)
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>
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export { LoginPage, validateCliCallback } from "./login-page";
|
||||
export { useLogout } from "./use-logout";
|
||||
|
||||
63
packages/views/auth/use-logout.ts
Normal file
63
packages/views/auth/use-logout.ts
Normal 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]);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user