fix(web): 活躍事件 Y/n 按鈕補上 CSRF Token (P0 根本原因)
All checks were successful
E2E Health Check / e2e-health (push) Successful in 19s

問題: 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 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-31 22:45:27 +08:00
parent 2ba61acf72
commit 95de7e0e15
2 changed files with 19 additions and 9 deletions

View File

@@ -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<DualStateIncidentCardProps> = ({
onApprovalChange,
}) => {
const t = useTranslations('incident.card')
const { csrfToken } = useCSRF()
const isAlert = status === 'alert'
const [buttonState, setButtonState] = useState<ButtonState>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
@@ -132,8 +134,8 @@ export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
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<DualStateIncidentCardProps> = ({
// 不自動恢復,讓用戶看到錯誤並主動點擊重試
}
// 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<DualStateIncidentCardProps> = ({
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<DualStateIncidentCardProps> = ({
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])
/**
* 渲染決策按鈕區塊

View File

@@ -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<string, string> = { '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<string, string> = { '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',