fix(api,web): separate workspace context from auth session (#551)
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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from "./personalities";
|
||||
export * from "./telemetry";
|
||||
export * from "./dashboard";
|
||||
export * from "./projects";
|
||||
export * from "./workspaces";
|
||||
|
||||
26
apps/web/src/lib/api/workspaces.ts
Normal file
26
apps/web/src/lib/api/workspaces.ts
Normal 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");
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user