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 17928fd2..19042c94 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 acffd09e..e3048b62 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',