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

@@ -265,23 +265,8 @@ export default function ProfilePage(): ReactElement {
</p>
)}
{user?.workspaceRole && (
<span
style={{
display: "inline-block",
marginTop: 8,
padding: "3px 10px",
borderRadius: "var(--r)",
background: "rgba(47, 128, 255, 0.1)",
color: "var(--ms-blue-400)",
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "capitalize",
}}
>
{user.workspaceRole}
</span>
)}
{/* Workspace role badge — placeholder until workspace context API
provides role data via GET /api/workspaces */}
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { useAuth } from "@/lib/auth/auth-context";
import { useChat } from "@/hooks/useChat";
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useWorkspaceId } from "@/lib/hooks";
import { MessageList } from "./MessageList";
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
import { ChatEmptyState } from "./ChatEmptyState";
@@ -89,10 +90,10 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
...(initialProjectId !== undefined && { projectId: initialProjectId }),
});
// Use the actual workspace ID for the WebSocket room subscription.
// Read workspace ID from localStorage (set by auth-context after session check).
// Cookie-based auth (withCredentials) handles authentication, so no explicit
// token is needed here — pass an empty string as the token placeholder.
const workspaceId = user?.currentWorkspaceId ?? user?.workspaceId ?? "";
const workspaceId = useWorkspaceId() ?? "";
const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
const { isCommand, executeCommand } = useOrchestratorCommands();

View File

@@ -464,7 +464,7 @@ function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
const displayName = user?.name ?? "User";
const initials = getInitials(displayName);
const role = user?.workspaceRole ?? "Member";
const role = "Member";
return (
<footer

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);