diff --git a/apps/web/src/components/kanban/KanbanBoard.tsx b/apps/web/src/components/kanban/KanbanBoard.tsx index 0363690..bf721d9 100644 --- a/apps/web/src/components/kanban/KanbanBoard.tsx +++ b/apps/web/src/components/kanban/KanbanBoard.tsx @@ -8,6 +8,7 @@ import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { KanbanColumn } from "./KanbanColumn"; import { TaskCard } from "./TaskCard"; +import { apiPatch } from "@/lib/api/client"; interface KanbanBoardProps { tasks: Task[]; @@ -93,19 +94,9 @@ export function KanbanBoard({ tasks, onStatusChange }: KanbanBoardProps): React. const task = (tasks || []).find((t) => t.id === taskId); if (task && task.status !== newStatus) { - // Call PATCH /api/tasks/:id to update status + // Call PATCH /api/tasks/:id to update status (using API client for CSRF protection) try { - const response = await fetch(`/api/tasks/${taskId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ status: newStatus }), - }); - - if (!response.ok) { - throw new Error(`Failed to update task status: ${response.statusText}`); - } + await apiPatch(`/api/tasks/${taskId}`, { status: newStatus }); // Optionally call the callback for parent component to refresh if (onStatusChange) { diff --git a/apps/web/src/components/knowledge/ImportExportActions.tsx b/apps/web/src/components/knowledge/ImportExportActions.tsx index 88b3772..ae337f6 100644 --- a/apps/web/src/components/knowledge/ImportExportActions.tsx +++ b/apps/web/src/components/knowledge/ImportExportActions.tsx @@ -2,6 +2,7 @@ import { useState, useRef } from "react"; import { Upload, Download, Loader2, CheckCircle2, XCircle } from "lucide-react"; +import { apiPostFormData } from "@/lib/api/client"; interface ImportResult { filename: string; @@ -63,17 +64,8 @@ export function ImportExportActions({ const formData = new FormData(); formData.append("file", file); - const response = await fetch("/api/knowledge/import", { - method: "POST", - body: formData, - }); - - if (!response.ok) { - const error = (await response.json()) as { message?: string }; - throw new Error(error.message ?? "Import failed"); - } - - const result = (await response.json()) as ImportResponse; + // Use API client to ensure CSRF token is included + const result = await apiPostFormData("/api/knowledge/import", formData); setImportResult(result); // Notify parent component diff --git a/apps/web/src/components/widgets/ActiveProjectsWidget.tsx b/apps/web/src/components/widgets/ActiveProjectsWidget.tsx index 383ef1f..cc64a5d 100644 --- a/apps/web/src/components/widgets/ActiveProjectsWidget.tsx +++ b/apps/web/src/components/widgets/ActiveProjectsWidget.tsx @@ -6,6 +6,7 @@ import { useState, useEffect } from "react"; import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react"; import type { WidgetProps } from "@mosaic/shared"; +import { apiPost } from "@/lib/api/client"; interface ActiveProject { id: string; @@ -43,14 +44,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps): useEffect(() => { const fetchProjects = async (): Promise => { try { - const response = await fetch("/api/widgets/data/active-projects", { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - if (response.ok) { - const data = (await response.json()) as ActiveProject[]; - setProjects(data); - } + // Use API client to ensure CSRF token is included + const data = await apiPost("/api/widgets/data/active-projects"); + setProjects(data); } catch (error) { console.error("Failed to fetch active projects:", error); } finally { @@ -71,14 +67,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps): useEffect(() => { const fetchAgentSessions = async (): Promise => { try { - const response = await fetch("/api/widgets/data/agent-chains", { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - if (response.ok) { - const data = (await response.json()) as AgentSession[]; - setAgentSessions(data); - } + // Use API client to ensure CSRF token is included + const data = await apiPost("/api/widgets/data/agent-chains"); + setAgentSessions(data); } catch (error) { console.error("Failed to fetch agent sessions:", error); } finally { diff --git a/apps/web/src/components/widgets/__tests__/ActiveProjectsWidget.test.tsx b/apps/web/src/components/widgets/__tests__/ActiveProjectsWidget.test.tsx index ef0f7db..094e059 100644 --- a/apps/web/src/components/widgets/__tests__/ActiveProjectsWidget.test.tsx +++ b/apps/web/src/components/widgets/__tests__/ActiveProjectsWidget.test.tsx @@ -3,26 +3,48 @@ * Following TDD principles */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { ActiveProjectsWidget } from "../ActiveProjectsWidget"; import userEvent from "@testing-library/user-event"; +import { clearCsrfToken } from "@/lib/api/client"; // Mock fetch for API calls global.fetch = vi.fn() as typeof global.fetch; +// Helper to create mock CSRF token response +const mockCsrfResponse = (): Response => + ({ + ok: true, + json: () => Promise.resolve({ token: "test-csrf-token" }), + }) as Response; + describe("ActiveProjectsWidget", (): void => { beforeEach((): void => { vi.clearAllMocks(); + clearCsrfToken(); // Clear cached CSRF token between tests + }); + + afterEach((): void => { + vi.resetAllMocks(); }); it("should render loading state initially", (): void => { - vi.mocked(global.fetch).mockImplementation( - () => - new Promise(() => { - // Intentionally empty - creates a never-resolving promise for loading state - }) - ); + // First call returns CSRF token, subsequent calls never resolve (loading state) + let csrfReturned = false; + vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { + const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; + + // Return CSRF token on first request + if (urlString.includes("csrf") && !csrfReturned) { + csrfReturned = true; + return Promise.resolve(mockCsrfResponse()); + } + // All other requests never resolve + return new Promise(() => { + // Intentionally empty - creates a never-resolving promise for loading state + }); + }); render(); @@ -57,6 +79,10 @@ describe("ActiveProjectsWidget", (): void => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; + // Return CSRF token + if (urlString.includes("csrf")) { + return Promise.resolve(mockCsrfResponse()); + } if (urlString.includes("active-projects")) { return Promise.resolve({ ok: true, @@ -103,6 +129,10 @@ describe("ActiveProjectsWidget", (): void => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; + // Return CSRF token + if (urlString.includes("csrf")) { + return Promise.resolve(mockCsrfResponse()); + } if (urlString.includes("active-projects")) { return Promise.resolve({ ok: true, @@ -127,12 +157,18 @@ describe("ActiveProjectsWidget", (): void => { }); it("should handle empty states", async (): Promise => { - vi.mocked(global.fetch).mockImplementation(() => - Promise.resolve({ + vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { + const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; + + // Return CSRF token + if (urlString.includes("csrf")) { + return Promise.resolve(mockCsrfResponse()); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve([]), - } as Response) - ); + } as Response); + }); render(); @@ -161,6 +197,10 @@ describe("ActiveProjectsWidget", (): void => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; + // Return CSRF token + if (urlString.includes("csrf")) { + return Promise.resolve(mockCsrfResponse()); + } if (urlString.includes("agent-chains")) { return Promise.resolve({ ok: true, @@ -207,6 +247,10 @@ describe("ActiveProjectsWidget", (): void => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; + // Return CSRF token + if (urlString.includes("csrf")) { + return Promise.resolve(mockCsrfResponse()); + } if (urlString.includes("agent-chains")) { return Promise.resolve({ ok: true, @@ -251,6 +295,10 @@ describe("ActiveProjectsWidget", (): void => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; + // Return CSRF token + if (urlString.includes("csrf")) { + return Promise.resolve(mockCsrfResponse()); + } if (urlString.includes("active-projects")) { return Promise.resolve({ ok: true, diff --git a/apps/web/src/hooks/__tests__/useLayouts.test.tsx b/apps/web/src/hooks/__tests__/useLayouts.test.tsx index d842e1a..2647bde 100644 --- a/apps/web/src/hooks/__tests__/useLayouts.test.tsx +++ b/apps/web/src/hooks/__tests__/useLayouts.test.tsx @@ -3,16 +3,24 @@ * Following TDD principles */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; // We'll implement this hook import { useLayouts, useCreateLayout, useUpdateLayout, useDeleteLayout } from "../useLayouts"; +import { clearCsrfToken } from "@/lib/api/client"; global.fetch = vi.fn(); +// Helper to create mock CSRF token response +const mockCsrfResponse = (): Response => + ({ + ok: true, + json: () => Promise.resolve({ token: "test-csrf-token" }), + }) as Response; + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -29,6 +37,11 @@ const createWrapper = () => { describe("useLayouts", (): void => { beforeEach((): void => { vi.clearAllMocks(); + clearCsrfToken(); // Clear cached CSRF token between tests + }); + + afterEach((): void => { + vi.resetAllMocks(); }); it("should fetch layouts on mount", async (): Promise => { @@ -82,6 +95,11 @@ describe("useLayouts", (): void => { describe("useCreateLayout", (): void => { beforeEach((): void => { vi.clearAllMocks(); + clearCsrfToken(); // Clear cached CSRF token between tests + }); + + afterEach((): void => { + vi.resetAllMocks(); }); it("should create a new layout", async (): Promise => { @@ -92,10 +110,13 @@ describe("useCreateLayout", (): void => { layout: [], }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: () => mockLayout, - }); + // Mock CSRF token fetch first, then the actual POST request + (global.fetch as ReturnType) + .mockResolvedValueOnce(mockCsrfResponse()) + .mockResolvedValueOnce({ + ok: true, + json: () => mockLayout, + }); const { result } = renderHook(() => useCreateLayout(), { wrapper: createWrapper(), @@ -113,7 +134,10 @@ describe("useCreateLayout", (): void => { }); it("should handle creation errors", async (): Promise => { - (global.fetch as ReturnType).mockRejectedValueOnce(new Error("API Error")); + // Mock CSRF token fetch succeeds but the actual POST fails + (global.fetch as ReturnType) + .mockResolvedValueOnce(mockCsrfResponse()) + .mockRejectedValueOnce(new Error("API Error")); const { result } = renderHook(() => useCreateLayout(), { wrapper: createWrapper(), @@ -133,6 +157,11 @@ describe("useCreateLayout", (): void => { describe("useUpdateLayout", (): void => { beforeEach((): void => { vi.clearAllMocks(); + clearCsrfToken(); // Clear cached CSRF token between tests + }); + + afterEach((): void => { + vi.resetAllMocks(); }); it("should update an existing layout", async (): Promise => { @@ -143,10 +172,13 @@ describe("useUpdateLayout", (): void => { layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }], }; - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: () => mockLayout, - }); + // Mock CSRF token fetch first, then the actual PATCH request + (global.fetch as ReturnType) + .mockResolvedValueOnce(mockCsrfResponse()) + .mockResolvedValueOnce({ + ok: true, + json: () => mockLayout, + }); const { result } = renderHook(() => useUpdateLayout(), { wrapper: createWrapper(), @@ -165,7 +197,10 @@ describe("useUpdateLayout", (): void => { }); it("should handle update errors", async (): Promise => { - (global.fetch as ReturnType).mockRejectedValueOnce(new Error("API Error")); + // Mock CSRF token fetch succeeds but the actual PATCH fails + (global.fetch as ReturnType) + .mockResolvedValueOnce(mockCsrfResponse()) + .mockRejectedValueOnce(new Error("API Error")); const { result } = renderHook(() => useUpdateLayout(), { wrapper: createWrapper(), @@ -185,13 +220,21 @@ describe("useUpdateLayout", (): void => { describe("useDeleteLayout", (): void => { beforeEach((): void => { vi.clearAllMocks(); + clearCsrfToken(); // Clear cached CSRF token between tests + }); + + afterEach((): void => { + vi.resetAllMocks(); }); it("should delete a layout", async (): Promise => { - (global.fetch as ReturnType).mockResolvedValueOnce({ - ok: true, - json: () => ({ success: true }), - }); + // Mock CSRF token fetch first, then the actual DELETE request + (global.fetch as ReturnType) + .mockResolvedValueOnce(mockCsrfResponse()) + .mockResolvedValueOnce({ + ok: true, + json: () => ({ success: true }), + }); const { result } = renderHook(() => useDeleteLayout(), { wrapper: createWrapper(), @@ -205,7 +248,10 @@ describe("useDeleteLayout", (): void => { }); it("should handle deletion errors", async (): Promise => { - (global.fetch as ReturnType).mockRejectedValueOnce(new Error("API Error")); + // Mock CSRF token fetch succeeds but the actual DELETE fails + (global.fetch as ReturnType) + .mockResolvedValueOnce(mockCsrfResponse()) + .mockRejectedValueOnce(new Error("API Error")); const { result } = renderHook(() => useDeleteLayout(), { wrapper: createWrapper(), diff --git a/apps/web/src/hooks/useLayouts.ts b/apps/web/src/hooks/useLayouts.ts index f6c62e9..7ae6547 100644 --- a/apps/web/src/hooks/useLayouts.ts +++ b/apps/web/src/hooks/useLayouts.ts @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query"; import type { UserLayout, WidgetPlacement } from "@mosaic/shared"; +import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client"; const LAYOUTS_KEY = ["layouts"]; @@ -30,11 +31,7 @@ export function useLayouts(): UseQueryResult { return useQuery({ queryKey: LAYOUTS_KEY, queryFn: async (): Promise => { - const response = await fetch("/api/layouts"); - if (!response.ok) { - throw new Error("Failed to fetch layouts"); - } - return response.json() as Promise; + return apiGet("/api/layouts"); }, }); } @@ -46,11 +43,7 @@ export function useLayout(id: string): UseQueryResult { return useQuery({ queryKey: [...LAYOUTS_KEY, id], queryFn: async (): Promise => { - const response = await fetch(`/api/layouts/${id}`); - if (!response.ok) { - throw new Error("Failed to fetch layout"); - } - return response.json() as Promise; + return apiGet(`/api/layouts/${id}`); }, enabled: !!id, }); @@ -63,36 +56,20 @@ export function useDefaultLayout(): UseQueryResult { return useQuery({ queryKey: [...LAYOUTS_KEY, "default"], queryFn: async (): Promise => { - const response = await fetch("/api/layouts/default"); - if (!response.ok) { - throw new Error("Failed to fetch default layout"); - } - return response.json() as Promise; + return apiGet("/api/layouts/default"); }, }); } /** - * Create a new layout + * Create a new layout (uses API client for CSRF protection) */ export function useCreateLayout(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (data: CreateLayoutData): Promise => { - const response = await fetch("/api/layouts", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error("Failed to create layout"); - } - - return response.json() as Promise; + return apiPost("/api/layouts", data); }, onSuccess: (): void => { // Invalidate layouts cache to refetch @@ -102,26 +79,14 @@ export function useCreateLayout(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id, ...data }: UpdateLayoutData): Promise => { - const response = await fetch(`/api/layouts/${id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error("Failed to update layout"); - } - - return response.json() as Promise; + return apiPatch(`/api/layouts/${id}`, data); }, onSuccess: (_data, variables): void => { // Invalidate affected queries @@ -132,22 +97,14 @@ export function useUpdateLayout(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (id: string): Promise => { - const response = await fetch(`/api/layouts/${id}`, { - method: "DELETE", - }); - - if (!response.ok) { - throw new Error("Failed to delete layout"); - } - - await response.json(); + await apiDelete(`/api/layouts/${id}`); }, onSuccess: (): void => { // Invalidate layouts cache to refetch diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 2a6308a..1077570 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -214,3 +214,57 @@ export async function apiDelete(endpoint: string, workspaceId?: string): Prom } return apiRequest(endpoint, options); } + +/** + * POST request helper for FormData uploads + * Note: This does not set Content-Type header to allow browser to set multipart/form-data boundary + */ +export async function apiPostFormData( + endpoint: string, + formData: FormData, + workspaceId?: string +): Promise { + const url = `${API_BASE_URL}${endpoint}`; + const headers: Record = {}; + + // Add workspace ID header if provided + if (workspaceId) { + headers["X-Workspace-Id"] = workspaceId; + } + + // Add CSRF token for state-changing request + const token = await ensureCsrfToken(); + headers["X-CSRF-Token"] = token; + + const response = await fetch(url, { + method: "POST", + headers, + body: formData, + credentials: "include", + }); + + if (!response.ok) { + const error: ApiError = await response.json().catch( + (): ApiError => ({ + code: "UNKNOWN_ERROR", + message: response.statusText || "An unknown error occurred", + }) + ); + + // Handle CSRF token mismatch - refresh token and retry once + if ( + response.status === 403 && + (error.code === "CSRF_ERROR" || error.message.includes("CSRF")) + ) { + // Refresh CSRF token + await fetchCsrfToken(); + + // Retry the request with new token (recursive call) + return apiPostFormData(endpoint, formData, workspaceId); + } + + throw new Error(error.message); + } + + return response.json() as Promise; +}