fix(#338): Route all state-changing fetch() calls through API client

- Replace raw fetch() with apiPost/apiPatch/apiDelete in:
  - ImportExportActions.tsx: POST for file imports
  - KanbanBoard.tsx: PATCH for task status updates
  - ActiveProjectsWidget.tsx: POST for widget data fetches
  - useLayouts.ts: POST/PATCH/DELETE for layout management
- Add apiPostFormData() method to API client for FormData uploads
- Ensures CSRF token is included in all state-changing requests
- Update tests to mock CSRF token fetch for API client usage

Refs #338

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-05 17:06:23 -06:00
parent 5ae07f7a84
commit 344e5df3bb
7 changed files with 198 additions and 119 deletions

View File

@@ -8,6 +8,7 @@ import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { KanbanColumn } from "./KanbanColumn"; import { KanbanColumn } from "./KanbanColumn";
import { TaskCard } from "./TaskCard"; import { TaskCard } from "./TaskCard";
import { apiPatch } from "@/lib/api/client";
interface KanbanBoardProps { interface KanbanBoardProps {
tasks: Task[]; tasks: Task[];
@@ -93,19 +94,9 @@ export function KanbanBoard({ tasks, onStatusChange }: KanbanBoardProps): React.
const task = (tasks || []).find((t) => t.id === taskId); const task = (tasks || []).find((t) => t.id === taskId);
if (task && task.status !== newStatus) { 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 { try {
const response = await fetch(`/api/tasks/${taskId}`, { await apiPatch(`/api/tasks/${taskId}`, { status: newStatus });
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}`);
}
// Optionally call the callback for parent component to refresh // Optionally call the callback for parent component to refresh
if (onStatusChange) { if (onStatusChange) {

View File

@@ -2,6 +2,7 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { Upload, Download, Loader2, CheckCircle2, XCircle } from "lucide-react"; import { Upload, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { apiPostFormData } from "@/lib/api/client";
interface ImportResult { interface ImportResult {
filename: string; filename: string;
@@ -63,17 +64,8 @@ export function ImportExportActions({
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const response = await fetch("/api/knowledge/import", { // Use API client to ensure CSRF token is included
method: "POST", const result = await apiPostFormData<ImportResponse>("/api/knowledge/import", formData);
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;
setImportResult(result); setImportResult(result);
// Notify parent component // Notify parent component

View File

@@ -6,6 +6,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react"; import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared"; import type { WidgetProps } from "@mosaic/shared";
import { apiPost } from "@/lib/api/client";
interface ActiveProject { interface ActiveProject {
id: string; id: string;
@@ -43,14 +44,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
useEffect(() => { useEffect(() => {
const fetchProjects = async (): Promise<void> => { const fetchProjects = async (): Promise<void> => {
try { try {
const response = await fetch("/api/widgets/data/active-projects", { // Use API client to ensure CSRF token is included
method: "POST", const data = await apiPost<ActiveProject[]>("/api/widgets/data/active-projects");
headers: { "Content-Type": "application/json" }, setProjects(data);
});
if (response.ok) {
const data = (await response.json()) as ActiveProject[];
setProjects(data);
}
} catch (error) { } catch (error) {
console.error("Failed to fetch active projects:", error); console.error("Failed to fetch active projects:", error);
} finally { } finally {
@@ -71,14 +67,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
useEffect(() => { useEffect(() => {
const fetchAgentSessions = async (): Promise<void> => { const fetchAgentSessions = async (): Promise<void> => {
try { try {
const response = await fetch("/api/widgets/data/agent-chains", { // Use API client to ensure CSRF token is included
method: "POST", const data = await apiPost<AgentSession[]>("/api/widgets/data/agent-chains");
headers: { "Content-Type": "application/json" }, setAgentSessions(data);
});
if (response.ok) {
const data = (await response.json()) as AgentSession[];
setAgentSessions(data);
}
} catch (error) { } catch (error) {
console.error("Failed to fetch agent sessions:", error); console.error("Failed to fetch agent sessions:", error);
} finally { } finally {

View File

@@ -3,26 +3,48 @@
* Following TDD principles * 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 { render, screen, waitFor } from "@testing-library/react";
import { ActiveProjectsWidget } from "../ActiveProjectsWidget"; import { ActiveProjectsWidget } from "../ActiveProjectsWidget";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { clearCsrfToken } from "@/lib/api/client";
// Mock fetch for API calls // Mock fetch for API calls
global.fetch = vi.fn() as typeof global.fetch; 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 => { describe("ActiveProjectsWidget", (): void => {
beforeEach((): void => { beforeEach((): void => {
vi.clearAllMocks(); vi.clearAllMocks();
clearCsrfToken(); // Clear cached CSRF token between tests
});
afterEach((): void => {
vi.resetAllMocks();
}); });
it("should render loading state initially", (): void => { it("should render loading state initially", (): void => {
vi.mocked(global.fetch).mockImplementation( // First call returns CSRF token, subsequent calls never resolve (loading state)
() => let csrfReturned = false;
new Promise(() => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
// Intentionally empty - creates a never-resolving promise for loading state 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(<ActiveProjectsWidget id="active-projects-1" />); render(<ActiveProjectsWidget id="active-projects-1" />);
@@ -57,6 +79,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; 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")) { if (urlString.includes("active-projects")) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
@@ -103,6 +129,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; 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")) { if (urlString.includes("active-projects")) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
@@ -127,12 +157,18 @@ describe("ActiveProjectsWidget", (): void => {
}); });
it("should handle empty states", async (): Promise<void> => { it("should handle empty states", async (): Promise<void> => {
vi.mocked(global.fetch).mockImplementation(() => vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
Promise.resolve({ 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, ok: true,
json: () => Promise.resolve([]), json: () => Promise.resolve([]),
} as Response) } as Response);
); });
render(<ActiveProjectsWidget id="active-projects-1" />); render(<ActiveProjectsWidget id="active-projects-1" />);
@@ -161,6 +197,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; 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")) { if (urlString.includes("agent-chains")) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
@@ -207,6 +247,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; 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")) { if (urlString.includes("agent-chains")) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
@@ -251,6 +295,10 @@ describe("ActiveProjectsWidget", (): void => {
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => { vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : ""; 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")) { if (urlString.includes("active-projects")) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,

View File

@@ -3,16 +3,24 @@
* Following TDD principles * 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 { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
// We'll implement this hook // We'll implement this hook
import { useLayouts, useCreateLayout, useUpdateLayout, useDeleteLayout } from "../useLayouts"; import { useLayouts, useCreateLayout, useUpdateLayout, useDeleteLayout } from "../useLayouts";
import { clearCsrfToken } from "@/lib/api/client";
global.fetch = vi.fn(); 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 createWrapper = () => {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -29,6 +37,11 @@ const createWrapper = () => {
describe("useLayouts", (): void => { describe("useLayouts", (): void => {
beforeEach((): void => { beforeEach((): void => {
vi.clearAllMocks(); vi.clearAllMocks();
clearCsrfToken(); // Clear cached CSRF token between tests
});
afterEach((): void => {
vi.resetAllMocks();
}); });
it("should fetch layouts on mount", async (): Promise<void> => { it("should fetch layouts on mount", async (): Promise<void> => {
@@ -82,6 +95,11 @@ describe("useLayouts", (): void => {
describe("useCreateLayout", (): void => { describe("useCreateLayout", (): void => {
beforeEach((): void => { beforeEach((): void => {
vi.clearAllMocks(); vi.clearAllMocks();
clearCsrfToken(); // Clear cached CSRF token between tests
});
afterEach((): void => {
vi.resetAllMocks();
}); });
it("should create a new layout", async (): Promise<void> => { it("should create a new layout", async (): Promise<void> => {
@@ -92,10 +110,13 @@ describe("useCreateLayout", (): void => {
layout: [], layout: [],
}; };
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ // Mock CSRF token fetch first, then the actual POST request
ok: true, (global.fetch as ReturnType<typeof vi.fn>)
json: () => mockLayout, .mockResolvedValueOnce(mockCsrfResponse())
}); .mockResolvedValueOnce({
ok: true,
json: () => mockLayout,
});
const { result } = renderHook(() => useCreateLayout(), { const { result } = renderHook(() => useCreateLayout(), {
wrapper: createWrapper(), wrapper: createWrapper(),
@@ -113,7 +134,10 @@ describe("useCreateLayout", (): void => {
}); });
it("should handle creation errors", async (): Promise<void> => { it("should handle creation errors", async (): Promise<void> => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error")); // Mock CSRF token fetch succeeds but the actual POST fails
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockCsrfResponse())
.mockRejectedValueOnce(new Error("API Error"));
const { result } = renderHook(() => useCreateLayout(), { const { result } = renderHook(() => useCreateLayout(), {
wrapper: createWrapper(), wrapper: createWrapper(),
@@ -133,6 +157,11 @@ describe("useCreateLayout", (): void => {
describe("useUpdateLayout", (): void => { describe("useUpdateLayout", (): void => {
beforeEach((): void => { beforeEach((): void => {
vi.clearAllMocks(); vi.clearAllMocks();
clearCsrfToken(); // Clear cached CSRF token between tests
});
afterEach((): void => {
vi.resetAllMocks();
}); });
it("should update an existing layout", async (): Promise<void> => { it("should update an existing layout", async (): Promise<void> => {
@@ -143,10 +172,13 @@ describe("useUpdateLayout", (): void => {
layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }], layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }],
}; };
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ // Mock CSRF token fetch first, then the actual PATCH request
ok: true, (global.fetch as ReturnType<typeof vi.fn>)
json: () => mockLayout, .mockResolvedValueOnce(mockCsrfResponse())
}); .mockResolvedValueOnce({
ok: true,
json: () => mockLayout,
});
const { result } = renderHook(() => useUpdateLayout(), { const { result } = renderHook(() => useUpdateLayout(), {
wrapper: createWrapper(), wrapper: createWrapper(),
@@ -165,7 +197,10 @@ describe("useUpdateLayout", (): void => {
}); });
it("should handle update errors", async (): Promise<void> => { it("should handle update errors", async (): Promise<void> => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error")); // Mock CSRF token fetch succeeds but the actual PATCH fails
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockCsrfResponse())
.mockRejectedValueOnce(new Error("API Error"));
const { result } = renderHook(() => useUpdateLayout(), { const { result } = renderHook(() => useUpdateLayout(), {
wrapper: createWrapper(), wrapper: createWrapper(),
@@ -185,13 +220,21 @@ describe("useUpdateLayout", (): void => {
describe("useDeleteLayout", (): void => { describe("useDeleteLayout", (): void => {
beforeEach((): void => { beforeEach((): void => {
vi.clearAllMocks(); vi.clearAllMocks();
clearCsrfToken(); // Clear cached CSRF token between tests
});
afterEach((): void => {
vi.resetAllMocks();
}); });
it("should delete a layout", async (): Promise<void> => { it("should delete a layout", async (): Promise<void> => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ // Mock CSRF token fetch first, then the actual DELETE request
ok: true, (global.fetch as ReturnType<typeof vi.fn>)
json: () => ({ success: true }), .mockResolvedValueOnce(mockCsrfResponse())
}); .mockResolvedValueOnce({
ok: true,
json: () => ({ success: true }),
});
const { result } = renderHook(() => useDeleteLayout(), { const { result } = renderHook(() => useDeleteLayout(), {
wrapper: createWrapper(), wrapper: createWrapper(),
@@ -205,7 +248,10 @@ describe("useDeleteLayout", (): void => {
}); });
it("should handle deletion errors", async (): Promise<void> => { it("should handle deletion errors", async (): Promise<void> => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error")); // Mock CSRF token fetch succeeds but the actual DELETE fails
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mockCsrfResponse())
.mockRejectedValueOnce(new Error("API Error"));
const { result } = renderHook(() => useDeleteLayout(), { const { result } = renderHook(() => useDeleteLayout(), {
wrapper: createWrapper(), wrapper: createWrapper(),

View File

@@ -5,6 +5,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query"; import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query";
import type { UserLayout, WidgetPlacement } from "@mosaic/shared"; import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client";
const LAYOUTS_KEY = ["layouts"]; const LAYOUTS_KEY = ["layouts"];
@@ -30,11 +31,7 @@ export function useLayouts(): UseQueryResult<UserLayout[]> {
return useQuery<UserLayout[]>({ return useQuery<UserLayout[]>({
queryKey: LAYOUTS_KEY, queryKey: LAYOUTS_KEY,
queryFn: async (): Promise<UserLayout[]> => { queryFn: async (): Promise<UserLayout[]> => {
const response = await fetch("/api/layouts"); return apiGet<UserLayout[]>("/api/layouts");
if (!response.ok) {
throw new Error("Failed to fetch layouts");
}
return response.json() as Promise<UserLayout[]>;
}, },
}); });
} }
@@ -46,11 +43,7 @@ export function useLayout(id: string): UseQueryResult<UserLayout> {
return useQuery<UserLayout>({ return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, id], queryKey: [...LAYOUTS_KEY, id],
queryFn: async (): Promise<UserLayout> => { queryFn: async (): Promise<UserLayout> => {
const response = await fetch(`/api/layouts/${id}`); return apiGet<UserLayout>(`/api/layouts/${id}`);
if (!response.ok) {
throw new Error("Failed to fetch layout");
}
return response.json() as Promise<UserLayout>;
}, },
enabled: !!id, enabled: !!id,
}); });
@@ -63,36 +56,20 @@ export function useDefaultLayout(): UseQueryResult<UserLayout> {
return useQuery<UserLayout>({ return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, "default"], queryKey: [...LAYOUTS_KEY, "default"],
queryFn: async (): Promise<UserLayout> => { queryFn: async (): Promise<UserLayout> => {
const response = await fetch("/api/layouts/default"); return apiGet<UserLayout>("/api/layouts/default");
if (!response.ok) {
throw new Error("Failed to fetch default layout");
}
return response.json() as Promise<UserLayout>;
}, },
}); });
} }
/** /**
* Create a new layout * Create a new layout (uses API client for CSRF protection)
*/ */
export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLayoutData> { export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLayoutData> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (data: CreateLayoutData): Promise<UserLayout> => { mutationFn: async (data: CreateLayoutData): Promise<UserLayout> => {
const response = await fetch("/api/layouts", { return apiPost<UserLayout>("/api/layouts", data);
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<UserLayout>;
}, },
onSuccess: (): void => { onSuccess: (): void => {
// Invalidate layouts cache to refetch // Invalidate layouts cache to refetch
@@ -102,26 +79,14 @@ export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLa
} }
/** /**
* Update an existing layout * Update an existing layout (uses API client for CSRF protection)
*/ */
export function useUpdateLayout(): UseMutationResult<UserLayout, Error, UpdateLayoutData> { export function useUpdateLayout(): UseMutationResult<UserLayout, Error, UpdateLayoutData> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ id, ...data }: UpdateLayoutData): Promise<UserLayout> => { mutationFn: async ({ id, ...data }: UpdateLayoutData): Promise<UserLayout> => {
const response = await fetch(`/api/layouts/${id}`, { return apiPatch<UserLayout>(`/api/layouts/${id}`, data);
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<UserLayout>;
}, },
onSuccess: (_data, variables): void => { onSuccess: (_data, variables): void => {
// Invalidate affected queries // Invalidate affected queries
@@ -132,22 +97,14 @@ export function useUpdateLayout(): UseMutationResult<UserLayout, Error, UpdateLa
} }
/** /**
* Delete a layout * Delete a layout (uses API client for CSRF protection)
*/ */
export function useDeleteLayout(): UseMutationResult<void, Error, string> { export function useDeleteLayout(): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (id: string): Promise<void> => { mutationFn: async (id: string): Promise<void> => {
const response = await fetch(`/api/layouts/${id}`, { await apiDelete(`/api/layouts/${id}`);
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete layout");
}
await response.json();
}, },
onSuccess: (): void => { onSuccess: (): void => {
// Invalidate layouts cache to refetch // Invalidate layouts cache to refetch

View File

@@ -214,3 +214,57 @@ export async function apiDelete<T>(endpoint: string, workspaceId?: string): Prom
} }
return apiRequest<T>(endpoint, options); return apiRequest<T>(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<T>(
endpoint: string,
formData: FormData,
workspaceId?: string
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const headers: Record<string, string> = {};
// 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<T>(endpoint, formData, workspaceId);
}
throw new Error(error.message);
}
return response.json() as Promise<T>;
}