diff --git a/ccw/frontend/src/hooks/useSkillHub.ts b/ccw/frontend/src/hooks/useSkillHub.ts index 26714d71..531dca74 100644 --- a/ccw/frontend/src/hooks/useSkillHub.ts +++ b/ccw/frontend/src/hooks/useSkillHub.ts @@ -4,72 +4,7 @@ // React Query hooks for Skill Hub API import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; - -// ============================================================================ -// API Helper -// ============================================================================ - -/** - * Get CSRF token from cookie - */ -function getCsrfToken(): string | null { - const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); - return match ? decodeURIComponent(match[1]) : null; -} - -/** - * Typed fetch wrapper with CSRF token handling - */ -async function fetchApi(url: string, options: RequestInit = {}): Promise { - const headers = new Headers(options.headers); - - // Add CSRF token for mutating requests - if (options.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) { - const csrfToken = getCsrfToken(); - if (csrfToken) { - headers.set('X-CSRF-Token', csrfToken); - } - } - - // Set content type for JSON requests - if (options.body && typeof options.body === 'string') { - headers.set('Content-Type', 'application/json'); - } - - const response = await fetch(url, { - ...options, - headers, - credentials: 'same-origin', - }); - - if (!response.ok) { - const error: { message: string; status: number; code?: string } = { - message: response.statusText || 'Request failed', - status: response.status, - }; - - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - try { - const body = await response.json(); - if (body.message) error.message = body.message; - else if (body.error) error.message = body.error; - if (body.code) error.code = body.code; - } catch { - // Silently ignore JSON parse errors - } - } - - throw error; - } - - // Handle no-content responses - if (response.status === 204) { - return undefined as T; - } - - return response.json(); -} +import { fetchApi } from '@/lib/api'; // ============================================================================ // Types diff --git a/ccw/frontend/src/lib/unsplash.ts b/ccw/frontend/src/lib/unsplash.ts index 79305759..8448a925 100644 --- a/ccw/frontend/src/lib/unsplash.ts +++ b/ccw/frontend/src/lib/unsplash.ts @@ -21,10 +21,7 @@ export interface UnsplashSearchResult { totalPages: number; } -function getCsrfToken(): string | null { - const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); - return match ? decodeURIComponent(match[1]) : null; -} +import { fetchApi } from './api'; /** * Search Unsplash photos via backend proxy. @@ -40,16 +37,7 @@ export async function searchUnsplash( per_page: String(perPage), }); - const response = await fetch(`/api/unsplash/search?${params}`, { - credentials: 'same-origin', - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body.error || `Unsplash search failed: ${response.status}`); - } - - return response.json(); + return fetchApi(`/api/unsplash/search?${params}`); } /** @@ -57,46 +45,22 @@ export async function searchUnsplash( * Sends raw binary to avoid base64 overhead. */ export async function uploadBackgroundImage(file: File): Promise<{ url: string; filename: string }> { - const headers: Record = { - 'Content-Type': file.type, - 'X-Filename': encodeURIComponent(file.name), - }; - const csrfToken = getCsrfToken(); - if (csrfToken) { - headers['X-CSRF-Token'] = csrfToken; - } - - const response = await fetch('/api/background/upload', { + return fetchApi<{ url: string; filename: string }>('/api/background/upload', { method: 'POST', - headers, - credentials: 'same-origin', + headers: { + 'Content-Type': file.type, + 'X-Filename': encodeURIComponent(file.name), + }, body: file, }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body.error || `Upload failed: ${response.status}`); - } - - return response.json(); } /** * Trigger Unsplash download event (required by API guidelines). */ export async function triggerUnsplashDownload(downloadLocation: string): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - }; - const csrfToken = getCsrfToken(); - if (csrfToken) { - headers['X-CSRF-Token'] = csrfToken; - } - - await fetch('/api/unsplash/download', { + await fetchApi('/api/unsplash/download', { method: 'POST', - headers, - credentials: 'same-origin', body: JSON.stringify({ downloadLocation }), }); } diff --git a/ccw/frontend/src/pages/SettingsPage.tsx b/ccw/frontend/src/pages/SettingsPage.tsx index 97db6a0f..a4044cfd 100644 --- a/ccw/frontend/src/pages/SettingsPage.tsx +++ b/ccw/frontend/src/pages/SettingsPage.tsx @@ -59,16 +59,13 @@ import { useExportSettings, useImportSettings, } from '@/hooks/useSystemSettings'; +import { fetchApi } from '@/lib/api'; import type { ExportedSettings } from '@/lib/api'; import { RemoteNotificationSection } from '@/components/settings/RemoteNotificationSection'; import { A2UIPreferencesSection } from '@/components/settings/A2UIPreferencesSection'; import { AgentDefinitionsSection } from '@/components/settings/AgentDefinitionsSection'; -// ========== CSRF Token Helper ========== -function getCsrfToken(): string | null { - const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); - return match ? decodeURIComponent(match[1]) : null; -} +// CSRF tokens are managed by fetchApi from lib/api.ts (token pool pattern) // ========== File Path Input with Native File Picker ========== @@ -1310,29 +1307,19 @@ export function SettingsPage() { // Auto-parse models from settings file if (settingsFile && SETTINGS_FILE_TOOLS.has(toolId)) { try { - const csrfToken = getCsrfToken(); - const headers: Record = { 'Content-Type': 'application/json' }; - if (csrfToken) headers['X-CSRF-Token'] = csrfToken; - - const res = await fetch('/api/cli/parse-settings', { - method: 'POST', - headers, - body: JSON.stringify({ path: settingsFile }), - credentials: 'same-origin', - }); - - if (res.ok) { - const data = await res.json(); - if (data.primaryModel || data.secondaryModel || data.availableModels?.length) { - const updates: Partial<{ primaryModel: string; secondaryModel: string; availableModels: string[] }> = {}; - if (data.primaryModel) updates.primaryModel = data.primaryModel; - if (data.secondaryModel) updates.secondaryModel = data.secondaryModel; - if (data.availableModels?.length) updates.availableModels = data.availableModels; - updateCliTool(toolId, updates); - toast.success(`Models loaded from settings: ${data.primaryModel || 'default'}`, { - duration: 3000, - }); - } + const data = await fetchApi<{ primaryModel?: string; secondaryModel?: string; availableModels?: string[] }>( + '/api/cli/parse-settings', + { method: 'POST', body: JSON.stringify({ path: settingsFile }) } + ); + if (data.primaryModel || data.secondaryModel || data.availableModels?.length) { + const updates: Partial<{ primaryModel: string; secondaryModel: string; availableModels: string[] }> = {}; + if (data.primaryModel) updates.primaryModel = data.primaryModel; + if (data.secondaryModel) updates.secondaryModel = data.secondaryModel; + if (data.availableModels?.length) updates.availableModels = data.availableModels; + updateCliTool(toolId, updates); + toast.success(`Models loaded from settings: ${data.primaryModel || 'default'}`, { + duration: 3000, + }); } } catch { // Silently fail — file parsing is best-effort @@ -1368,23 +1355,11 @@ export function SettingsPage() { body.effort = config.effort || null; } - const csrfToken = getCsrfToken(); - const headers: Record = { 'Content-Type': 'application/json' }; - if (csrfToken) { - headers['X-CSRF-Token'] = csrfToken; - } - - const res = await fetch(`/api/cli/config/${toolId}`, { + await fetchApi(`/api/cli/config/${toolId}`, { method: 'PUT', - headers, body: JSON.stringify(body), - credentials: 'same-origin', }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - toast.success(formatMessage({ id: 'settings.cliTools.configSaved' }), { description: toolId, });