/** * CSRF Token Hook (Phase 20) * ========================== * Phase 19 首席架構師審查: 核鑰 UX 缺 CSRF 防護 (9/10) * * 使用方式: * 1. 在需要 CSRF 保護的組件中使用 useCSRF() * 2. 在敏感請求中帶上 X-CSRF-Token header * * @example * ```tsx * const { csrfToken, isLoading, getHeaders } = useCSRF(); * * const handleApprove = async () => { * await fetch('/api/v1/approvals/123/sign', { * method: 'POST', * headers: { * 'Content-Type': 'application/json', * ...getHeaders(), // 自動帶上 X-CSRF-Token * }, * body: JSON.stringify({ ... }), * }); * }; * ``` * * 建立日期: 2026-03-28 (台北時間) * 建立者: Claude Code (首席架構師) */ import { useCallback, useEffect, useState } from "react"; import { getApiUrl } from "@/lib/config"; interface CSRFTokenResponse { token: string; cookie_name: string; header_name: string; } interface UseCSRFReturn { /** CSRF Token (null 表示尚未載入) */ csrfToken: string | null; /** 是否正在載入 */ isLoading: boolean; /** 載入錯誤 */ error: Error | null; /** 取得包含 CSRF header 的物件,可直接展開到 fetch headers */ getHeaders: () => Record; /** 手動重新獲取 Token */ refresh: () => Promise; } let cachedCSRFToken: string | null = null; let csrfInFlight: Promise | null = null; async function requestCSRFToken(force = false): Promise { if (cachedCSRFToken && !force) return cachedCSRFToken; if (csrfInFlight) return csrfInFlight; csrfInFlight = (async () => { const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/api/v1/csrf/token`, { method: "GET", credentials: "include", // 確保 cookie 被設定 }); if (!response.ok) { throw new Error(`Failed to fetch CSRF token: ${response.status}`); } const data: CSRFTokenResponse = await response.json(); cachedCSRFToken = data.token; return data.token; })(); try { return await csrfInFlight; } finally { csrfInFlight = null; } } /** * CSRF Token Hook * * 自動在 mount 時獲取 CSRF Token,並提供 getHeaders() 方法 * 供敏感請求使用。 */ export function useCSRF(): UseCSRFReturn { const [csrfToken, setCSRFToken] = useState(cachedCSRFToken); const [isLoading, setIsLoading] = useState(!cachedCSRFToken); const [error, setError] = useState(null); const fetchToken = useCallback(async (force = false) => { if (cachedCSRFToken && !force) { setCSRFToken(cachedCSRFToken); setIsLoading(false); setError(null); return; } setIsLoading(true); setError(null); try { const token = await requestCSRFToken(force); setCSRFToken(token); } catch (err) { setError(err instanceof Error ? err : new Error(String(err))); console.error("[useCSRF] Failed to fetch CSRF token:", err); } finally { setIsLoading(false); } }, []); // 自動在 mount 時獲取 Token useEffect(() => { fetchToken(); }, [fetchToken]); // 提供 headers 物件,可直接展開到 fetch const getHeaders = useCallback((): Record => { if (!csrfToken) { console.warn("[useCSRF] CSRF token not available yet"); return {}; } return { "X-CSRF-Token": csrfToken, }; }, [csrfToken]); return { csrfToken, isLoading, error, getHeaders, refresh: () => fetchToken(true), }; } /** * CSRF Context Provider (可選,用於全局共享 Token) * * 如果多個組件需要共享同一個 CSRF Token, * 可以使用 CSRFProvider 包裝應用根組件。 */ export { useCSRF as default };