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

@@ -214,3 +214,57 @@ export async function apiDelete<T>(endpoint: string, workspaceId?: string): Prom
}
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>;
}