From 61179782a9d3663d8a0fde94863631063f72ec3a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 27 Feb 2026 05:19:31 -0600 Subject: [PATCH] fix(web): persist workspace ID in localStorage from auth flow Set mosaic-workspace-id in localStorage when the auth session loads successfully, so useWorkspaceId and apiRequest can read it without a separate fetch. Uses currentWorkspaceId when available, falls back to workspaceId. Clears the key on sign-out to prevent stale workspace context on subsequent unauthenticated requests. Adds four targeted unit tests to auth-context.test.tsx covering each persistence scenario. Closes SS-WS-003 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/lib/auth/auth-context.test.tsx | 171 ++++++++++++++++++++ apps/web/src/lib/auth/auth-context.tsx | 45 ++++++ 2 files changed, 216 insertions(+) diff --git a/apps/web/src/lib/auth/auth-context.test.tsx b/apps/web/src/lib/auth/auth-context.test.tsx index f2a1861..6ad2b72 100644 --- a/apps/web/src/lib/auth/auth-context.test.tsx +++ b/apps/web/src/lib/auth/auth-context.test.tsx @@ -691,4 +691,175 @@ describe("AuthContext", (): void => { }); }); }); + + describe("workspace ID persistence", (): void => { + // --------------------------------------------------------------------------- + // localStorage mock for workspace persistence tests + // --------------------------------------------------------------------------- + interface MockLocalStorage { + getItem: ReturnType; + setItem: ReturnType; + removeItem: ReturnType; + clear: ReturnType; + readonly length: number; + key: ReturnType; + } + + let localStorageMock: MockLocalStorage; + + beforeEach((): void => { + let store: Record = {}; + localStorageMock = { + getItem: vi.fn((key: string): string | null => store[key] ?? null), + setItem: vi.fn((key: string, value: string): void => { + store[key] = value; + }), + removeItem: vi.fn((key: string): void => { + store = Object.fromEntries(Object.entries(store).filter(([k]) => k !== key)); + }), + clear: vi.fn((): void => { + store = {}; + }), + get length(): number { + return Object.keys(store).length; + }, + key: vi.fn((_index: number): string | null => null), + }; + + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, + }); + + vi.resetAllMocks(); + }); + + afterEach((): void => { + vi.restoreAllMocks(); + }); + + it("should persist currentWorkspaceId to localStorage after session check", async (): Promise => { + const mockUser: AuthUser = { + id: "user-1", + email: "test@example.com", + name: "Test User", + currentWorkspaceId: "ws-current-123", + workspaceId: "ws-default-456", + }; + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + + // currentWorkspaceId takes priority over workspaceId + expect(localStorageMock.setItem).toHaveBeenCalledWith( + "mosaic-workspace-id", + "ws-current-123" + ); + }); + + it("should fall back to workspaceId when currentWorkspaceId is absent", async (): Promise => { + const mockUser: AuthUser = { + id: "user-1", + email: "test@example.com", + name: "Test User", + workspaceId: "ws-default-456", + }; + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + "mosaic-workspace-id", + "ws-default-456" + ); + }); + + it("should not write to localStorage when no workspace ID is present on user", async (): Promise => { + const mockUser: AuthUser = { + id: "user-1", + email: "test@example.com", + name: "Test User", + // no workspaceId or currentWorkspaceId + }; + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + "mosaic-workspace-id", + expect.anything() + ); + }); + + it("should remove workspace ID from localStorage on sign-out", async (): Promise => { + const mockUser: AuthUser = { + id: "user-1", + email: "test@example.com", + name: "Test User", + currentWorkspaceId: "ws-current-123", + }; + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, + }); + vi.mocked(apiPost).mockResolvedValueOnce({ success: true }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + + const signOutButton = screen.getByRole("button", { name: "Sign Out" }); + signOutButton.click(); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); + }); + + expect(localStorageMock.removeItem).toHaveBeenCalledWith("mosaic-workspace-id"); + }); + }); }); diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx index 1282b0f..f3ec119 100644 --- a/apps/web/src/lib/auth/auth-context.tsx +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -24,6 +24,43 @@ const SESSION_EXPIRY_WARNING_MINUTES = 5; /** Interval in milliseconds to check session expiry */ const SESSION_CHECK_INTERVAL_MS = 60_000; + +/** + * localStorage key for the active workspace ID. + * Must match the WORKSPACE_KEY constant in useLayout.ts and the key read + * by apiRequest in client.ts. + */ +const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id"; + +/** + * Persist the workspace ID to localStorage so it is available to + * useWorkspaceId and apiRequest on the next render / request cycle. + * Silently ignores localStorage errors (private browsing, storage full). + */ +function persistWorkspaceId(workspaceId: string | undefined): void { + if (typeof window === "undefined") return; + try { + if (workspaceId) { + localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId); + } + } catch { + // localStorage unavailable — not fatal + } +} + +/** + * Remove the workspace ID from localStorage on sign-out so stale workspace + * context is not sent on subsequent unauthenticated requests. + */ +function clearWorkspaceId(): void { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(WORKSPACE_STORAGE_KEY); + } catch { + // localStorage unavailable — not fatal + } +} + const MOCK_AUTH_USER: AuthUser = { id: "dev-user-local", email: "dev@localhost", @@ -97,6 +134,11 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem setUser(session.user); setAuthError(null); + // Persist workspace ID to localStorage so useWorkspaceId and apiRequest + // can pick it up without re-fetching the session. + // Prefer currentWorkspaceId (the user's active workspace) over workspaceId. + persistWorkspaceId(session.user.currentWorkspaceId ?? session.user.workspaceId); + // Track session expiry timestamp expiresAtRef.current = new Date(session.session.expiresAt); @@ -128,6 +170,9 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem setUser(null); expiresAtRef.current = null; setSessionExpiring(false); + // Clear persisted workspace ID so stale context is not sent on + // subsequent unauthenticated API requests. + clearWorkspaceId(); } }, []);