fix(api,web): separate workspace context from auth session (#551)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #551.
This commit is contained in:
2026-02-28 15:14:29 +00:00
committed by jason.woltje
parent d2c51eda91
commit 128431ba58
21 changed files with 604 additions and 79 deletions

View File

@@ -15,3 +15,4 @@ export * from "./personalities";
export * from "./telemetry";
export * from "./dashboard";
export * from "./projects";
export * from "./workspaces";

View File

@@ -0,0 +1,26 @@
/**
* Workspaces API Client
* User-scoped workspace discovery — does NOT require X-Workspace-Id header.
*/
import { apiGet } from "./client";
/**
* A workspace entry from the user's membership list.
* Matches WorkspaceResponseDto from the API.
*/
export interface UserWorkspace {
id: string;
name: string;
ownerId: string;
role: string;
createdAt: string;
}
/**
* Fetch all workspaces the authenticated user is a member of.
* The API auto-provisions a default workspace if the user has none.
*/
export async function fetchUserWorkspaces(): Promise<UserWorkspace[]> {
return apiGet<UserWorkspace[]>("/api/workspaces");
}

View File

@@ -10,7 +10,13 @@ vi.mock("../api/client", () => ({
apiPost: vi.fn(),
}));
// Mock the workspaces API client
vi.mock("../api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
}));
const { apiGet, apiPost } = await import("../api/client");
const { fetchUserWorkspaces } = await import("../api/workspaces");
/** Helper: returns a date far in the future (1 hour from now) for session mocks */
function futureExpiry(): string {
@@ -739,19 +745,26 @@ describe("AuthContext", (): void => {
vi.restoreAllMocks();
});
it("should persist currentWorkspaceId to localStorage after session check", async (): Promise<void> => {
it("should call fetchUserWorkspaces after successful session check", async (): Promise<void> => {
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() },
});
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
{
id: "ws-1",
name: "My Workspace",
ownerId: "user-1",
role: "OWNER",
createdAt: "2026-01-01",
},
]);
render(
<AuthProvider>
@@ -763,25 +776,36 @@ describe("AuthContext", (): void => {
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
});
// currentWorkspaceId takes priority over workspaceId
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"mosaic-workspace-id",
"ws-current-123"
);
expect(fetchUserWorkspaces).toHaveBeenCalledTimes(1);
});
it("should fall back to workspaceId when currentWorkspaceId is absent", async (): Promise<void> => {
it("should persist the first workspace ID to localStorage", async (): Promise<void> => {
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() },
});
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
{
id: "ws-abc-123",
name: "My Workspace",
ownerId: "user-1",
role: "OWNER",
createdAt: "2026-01-01",
},
{
id: "ws-def-456",
name: "Second Workspace",
ownerId: "other",
role: "MEMBER",
createdAt: "2026-02-01",
},
]);
render(
<AuthProvider>
@@ -793,24 +817,21 @@ describe("AuthContext", (): void => {
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"mosaic-workspace-id",
"ws-default-456"
);
expect(localStorageMock.setItem).toHaveBeenCalledWith("mosaic-workspace-id", "ws-abc-123");
});
it("should not write to localStorage when no workspace ID is present on user", async (): Promise<void> => {
it("should not write localStorage when fetchUserWorkspaces returns empty array", async (): Promise<void> => {
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() },
});
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([]);
render(
<AuthProvider>
@@ -828,18 +849,53 @@ describe("AuthContext", (): void => {
);
});
it("should remove workspace ID from localStorage on sign-out", async (): Promise<void> => {
it("should handle fetchUserWorkspaces failure gracefully — auth still succeeds", async (): Promise<void> => {
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(fetchUserWorkspaces).mockRejectedValueOnce(new Error("Network error"));
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
});
// Auth succeeded despite workspace fetch failure
expect(screen.getByTestId("auth-error")).toHaveTextContent("none");
});
it("should remove workspace ID from localStorage on sign-out", async (): Promise<void> => {
const mockUser: AuthUser = {
id: "user-1",
email: "test@example.com",
name: "Test User",
};
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
{
id: "ws-1",
name: "My Workspace",
ownerId: "user-1",
role: "OWNER",
createdAt: "2026-01-01",
},
]);
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
render(

View File

@@ -11,6 +11,7 @@ import {
} from "react";
import type { AuthUser, AuthSession } from "@mosaic/shared";
import { apiGet, apiPost } from "../api/client";
import { fetchUserWorkspaces } from "../api/workspaces";
import { IS_MOCK_AUTH_MODE } from "../config";
import { parseAuthError } from "./auth-errors";
@@ -134,10 +135,18 @@ 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);
// Fetch the user's workspace memberships and persist the default.
// Workspace context is an application concern, not an auth concern —
// BetterAuth does not return workspace fields on the session user.
try {
const workspaces = await fetchUserWorkspaces();
const defaultWorkspace = workspaces[0];
if (defaultWorkspace) {
persistWorkspaceId(defaultWorkspace.id);
}
} catch (wsError) {
logAuthError("Failed to fetch workspaces after session check", wsError);
}
// Track session expiry timestamp
expiresAtRef.current = new Date(session.session.expiresAt);