- useCSRF: 修正 import 路徑 @/lib/env → @/lib/config - terminal-telemetry: 新增 UNKNOWN_COMPONENT 錯誤碼 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
121 lines
3.0 KiB
TypeScript
121 lines
3.0 KiB
TypeScript
/**
|
||
* 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<string, string>;
|
||
/** 手動重新獲取 Token */
|
||
refresh: () => Promise<void>;
|
||
}
|
||
|
||
/**
|
||
* CSRF Token Hook
|
||
*
|
||
* 自動在 mount 時獲取 CSRF Token,並提供 getHeaders() 方法
|
||
* 供敏感請求使用。
|
||
*/
|
||
export function useCSRF(): UseCSRFReturn {
|
||
const [csrfToken, setCSRFToken] = useState<string | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState<Error | null>(null);
|
||
|
||
const fetchToken = useCallback(async () => {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
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();
|
||
setCSRFToken(data.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<string, string> => {
|
||
if (!csrfToken) {
|
||
console.warn("[useCSRF] CSRF token not available yet");
|
||
return {};
|
||
}
|
||
return {
|
||
"X-CSRF-Token": csrfToken,
|
||
};
|
||
}, [csrfToken]);
|
||
|
||
return {
|
||
csrfToken,
|
||
isLoading,
|
||
error,
|
||
getHeaders,
|
||
refresh: fetchToken,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* CSRF Context Provider (可選,用於全局共享 Token)
|
||
*
|
||
* 如果多個組件需要共享同一個 CSRF Token,
|
||
* 可以使用 CSRFProvider 包裝應用根組件。
|
||
*/
|
||
export { useCSRF as default };
|