From 95de7e0e156f91e0196080ccdf264d3a3b4bd03f Mon Sep 17 00:00:00 2001 From: OG T Date: Tue, 31 Mar 2026 22:45:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=B4=BB=E8=BA=8D=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=20Y/n=20=E6=8C=89=E9=88=95=E8=A3=9C=E4=B8=8A=20CSRF?= =?UTF-8?q?=20Token=20(P0=20=E6=A0=B9=E6=9C=AC=E5=8E=9F=E5=9B=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題: DualStateIncidentCard 的 Y/n 按鈕呼叫 apiClient.signApproval/rejectApproval 時,沒有帶 X-CSRF-Token header 也沒有 credentials: 'include' 後端返回 403 CSRF token cookie missing 修復: - api-client.ts: signApproval/rejectApproval 加入 csrfToken 參數 + X-CSRF-Token header + credentials: 'include' - dual-state-incident-card.tsx: 加入 useCSRF() hook, 將 csrfToken 傳入 API 呼叫,更新 useCallback deps Co-Authored-By: Claude Opus 4.5 --- .../incident/dual-state-incident-card.tsx | 12 +++++++----- apps/web/src/lib/api-client.ts | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/incident/dual-state-incident-card.tsx b/apps/web/src/components/incident/dual-state-incident-card.tsx index 17928fd20..19042c947 100644 --- a/apps/web/src/components/incident/dual-state-incident-card.tsx +++ b/apps/web/src/components/incident/dual-state-incident-card.tsx @@ -31,6 +31,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react' import { useTranslations } from 'next-intl' import type { DecisionInfo } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client' +import { useCSRF } from '@/hooks/useCSRF' type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error' | 'timeout' @@ -63,6 +64,7 @@ export const DualStateIncidentCard: React.FC = ({ onApprovalChange, }) => { const t = useTranslations('incident.card') + const { csrfToken } = useCSRF() const isAlert = status === 'alert' const [buttonState, setButtonState] = useState('idle') const [errorMessage, setErrorMessage] = useState(null) @@ -132,8 +134,8 @@ export const DualStateIncidentCard: React.FC = ({ throw new Error('No approval ID available') } - // Step 2: 簽核 - const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via WarRoom') + // Step 2: 簽核 (Phase 22 P0: 帶入 CSRF token) + const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via WarRoom', csrfToken) console.log('✅ 簽核回應:', result) // 清除超時計時器 @@ -170,7 +172,7 @@ export const DualStateIncidentCard: React.FC = ({ // 不自動恢復,讓用戶看到錯誤並主動點擊重試 } // eslint-disable-next-line react-hooks/exhaustive-deps -- t is stable from next-intl - }, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange]) + }, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange, csrfToken]) /** * 處理拒絕 (n 按鈕) @@ -210,7 +212,7 @@ export const DualStateIncidentCard: React.FC = ({ throw new Error('No approval ID available') } - await apiClient.rejectApproval(approvalId, 'Rejected via WarRoom') + await apiClient.rejectApproval(approvalId, 'Rejected via WarRoom', csrfToken) // 清除超時計時器 if (timeoutRef.current) { @@ -234,7 +236,7 @@ export const DualStateIncidentCard: React.FC = ({ setErrorMessage(errMsg) } // eslint-disable-next-line react-hooks/exhaustive-deps -- t is stable from next-intl - }, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange]) + }, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange, csrfToken]) /** * 渲染決策按鈕區塊 diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index acffd09ea..e3048b620 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -114,10 +114,14 @@ export const apiClient = { }>(res) }, - async signApproval(approvalId: string, signer: string = 'commander', comment?: string) { + async signApproval(approvalId: string, signer: string = 'commander', comment?: string, csrfToken?: string | null) { + // Phase 22 P0: 加入 CSRF token + credentials (2026-03-31 Claude Code) + const headers: Record = { 'Content-Type': 'application/json' } + if (csrfToken) headers['X-CSRF-Token'] = csrfToken const res = await fetch(`${API_BASE_URL}/approvals/${approvalId}/sign`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, + credentials: 'include', body: JSON.stringify({ signer_id: signer, signer_name: signer, @@ -138,10 +142,14 @@ export const apiClient = { }>(res) }, - async rejectApproval(approvalId: string, reason?: string) { + async rejectApproval(approvalId: string, reason?: string, csrfToken?: string | null) { + // Phase 22 P0: 加入 CSRF token + credentials (2026-03-31 Claude Code) + const headers: Record = { 'Content-Type': 'application/json' } + if (csrfToken) headers['X-CSRF-Token'] = csrfToken const res = await fetch(`${API_BASE_URL}/approvals/${approvalId}/reject`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, + credentials: 'include', body: JSON.stringify({ rejector_id: 'commander', rejector_name: 'Commander',