/** * Approval Store - HITL 授權狀態管理 * =================================== * CISO-101: Multi-Sig 信任鏈前端整合 * * Features: * - 輪詢 GET /api/v1/approvals/pending * - 簽核 POST /api/v1/approvals/{id}/sign * - 拒絕 POST /api/v1/approvals/{id}/reject * - Multi-Sig UX 狀態管理 */ import { create } from 'zustand' import { subscribeWithSelector } from 'zustand/middleware' import type { ApprovalRequest as FrontendApprovalRequest } from '@/components/approval/approval-card' // ============================================================================= // Types (與後端 Pydantic 模型對應) // ============================================================================= export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired' export type RiskLevel = 'low' | 'medium' | 'high' | 'critical' export type DataImpact = 'none' | 'read_only' | 'write' | 'destructive' export interface BlastRadius { affected_pods: number estimated_downtime: string related_services: string[] data_impact: DataImpact } export interface DryRunCheck { name: string passed: boolean message?: string | null } export interface Signature { id: string signer_id: string signer_name: string signed_at: string comment?: string | null } export interface ApprovalRequest { id: string action: string description: string status: ApprovalStatus risk_level: RiskLevel blast_radius: BlastRadius dry_run_checks: DryRunCheck[] required_signatures: number current_signatures: number signatures: Signature[] requested_by: string created_at: string expires_at: string | null resolved_at: string | null // 戰略 B: 告警風暴收斂 fingerprint?: string | null hit_count?: number last_seen_at?: string | null incident_id?: string | null matched_playbook_id?: string | null telegram_message_id?: number | null telegram_chat_id?: number | null metadata?: Record | null } export interface SignResponse { success: boolean message: string approval: ApprovalRequest execution_triggered: boolean } // ============================================================================= // Store State // ============================================================================= type SSEConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error' interface ApprovalState { // Data pendingApprovals: ApprovalRequest[] isLoading: boolean error: string | null lastFetched: Date | null // Signing state signingId: string | null rejectingId: string | null // Recently resolved (for animation) recentlyApproved: Set recentlyRejected: Set // SSE Connection (Phase 15: Polling → SSE) sseStatus: SSEConnectionStatus sseReconnectAttempts: number // Polling (Legacy, kept for fallback) pollingInterval: number | null // Actions fetchPending: () => Promise // Phase 20: CSRF Token 參數 (可選,向後相容) signApproval: (id: string, signerId: string, signerName: string, comment?: string, csrfToken?: string) => Promise rejectApproval: (id: string, rejectorId: string, rejectorName: string, reason: string, csrfToken?: string) => Promise // SSE Actions (Phase 15) connectSSE: () => void disconnectSSE: () => void // Polling (Legacy) startPolling: (intervalMs?: number) => void stopPolling: () => void clearRecentlyResolved: (id: string) => void setError: (error: string | null) => void } // ============================================================================= // Constants // ============================================================================= const DEFAULT_POLLING_INTERVAL = 5000 // 5 seconds const MAX_SSE_RECONNECT_ATTEMPTS = 10 const BASE_RECONNECT_DELAY = 1000 // 1 second const MAX_RECONNECT_DELAY = 30000 // 30 seconds // 專案鐵律: 禁止任何 Fallback IP const getApiBaseUrl = (): string => { if (typeof window === 'undefined') return '' const url = process.env.NEXT_PUBLIC_API_URL if (!url) { console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL') return '' } return url } const API_BASE_URL = getApiBaseUrl() // ============================================================================= // Store Implementation // ============================================================================= let pollingTimer: NodeJS.Timeout | null = null let eventSource: EventSource | null = null let sseReconnectTimeout: NodeJS.Timeout | null = null export const useApprovalStore = create()( subscribeWithSelector((set, get) => ({ // Initial state pendingApprovals: [], isLoading: false, error: null, lastFetched: null, signingId: null, rejectingId: null, recentlyApproved: new Set(), recentlyRejected: new Set(), // SSE state (Phase 15) sseStatus: 'disconnected', sseReconnectAttempts: 0, // Polling (Legacy) pollingInterval: null, // ========================================================================== // Fetch Pending Approvals // ========================================================================== fetchPending: async () => { set({ isLoading: true, error: null }) try { const response = await fetch(`${API_BASE_URL}/api/v1/approvals/pending`) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } const data = await response.json() const approvals: ApprovalRequest[] = data.approvals || [] console.log('[Approval] Fetched pending:', approvals.length) set({ pendingApprovals: approvals, isLoading: false, lastFetched: new Date(), }) } catch (err) { console.error('[Approval] Fetch failed:', err) set({ isLoading: false, error: `Failed to fetch approvals: ${err}`, }) } }, // ========================================================================== // Sign Approval (Phase 15: Optimistic Updates) // ========================================================================== signApproval: async (id, signerId, signerName, comment, csrfToken) => { // 🔧 Race Condition 修復: 簽核期間暫停 Polling const wasPolling = pollingTimer !== null if (wasPolling) { clearInterval(pollingTimer!) pollingTimer = null console.log('[Approval] Polling paused during sign') } // 🎯 Phase 15: 樂觀更新 - 立即更新 UI (Optimistic Update) const state = get() const originalApprovals = [...state.pendingApprovals] const targetApproval = originalApprovals.find((a) => a.id === id) if (targetApproval) { // 樂觀更新: 假設簽核成功,立即增加簽章數 const optimisticApproval = { ...targetApproval, current_signatures: targetApproval.current_signatures + 1, // 如果達到要求簽章數,預設為 approved status: (targetApproval.current_signatures + 1 >= targetApproval.required_signatures ? 'approved' : targetApproval.status) as ApprovalStatus, } set({ pendingApprovals: state.pendingApprovals.map((a) => a.id === id ? optimisticApproval : a ), signingId: id, error: null, }) console.log('[Approval] Optimistic update applied:', id, { signatures: `${optimisticApproval.current_signatures}/${optimisticApproval.required_signatures}`, status: optimisticApproval.status, }) } else { set({ signingId: id, error: null }) } try { // Phase 20: CSRF Protection const headers: Record = { 'Content-Type': 'application/json' } if (csrfToken) { headers['X-CSRF-Token'] = csrfToken } const response = await fetch(`${API_BASE_URL}/api/v1/approvals/${id}/sign`, { method: 'POST', headers, credentials: 'include', // Phase 20: 確保 cookie 被發送 body: JSON.stringify({ signer_id: signerId, signer_name: signerName, comment: comment || null, }), }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.detail || `HTTP ${response.status}`) } const result: SignResponse = await response.json() console.log('[Approval] Signed:', id, result.message) // Update local state // 🔴 2026-03-26 修復: 簽核後保留內容顯示,不立即移除 set((state) => { const updatedApprovals = state.pendingApprovals.map((a) => a.id === id ? result.approval : a ) // If approved, mark for removal animation (但不立即移除) const newRecentlyApproved = new Set(state.recentlyApproved) if (result.approval.status === 'approved') { newRecentlyApproved.add(id) } return { // 🔴 修復: 保留在列表中,讓 UI 顯示已簽核狀態 pendingApprovals: updatedApprovals, signingId: null, recentlyApproved: newRecentlyApproved, } }) // 🔴 延遲 5 秒後才從列表移除 (讓用戶看到完整簽核結果) if (result.approval.status === 'approved') { setTimeout(() => { set((state) => ({ pendingApprovals: state.pendingApprovals.filter((a) => a.id !== id), })) console.log('[Approval] Removed approved item after delay:', id) }, 5000) } // 🔧 Race Condition 修復: 延遲 1 秒後恢復 Polling,讓後端有時間更新 if (wasPolling) { setTimeout(() => { get().startPolling(get().pollingInterval || DEFAULT_POLLING_INTERVAL) console.log('[Approval] Polling resumed after sign') }, 1000) } return result } catch (err) { console.error('[Approval] Sign failed:', err) // 🎯 Phase 15: 樂觀更新失敗 - 回滾到原始狀態 (Rollback) set({ pendingApprovals: originalApprovals, signingId: null, error: `Sign failed: ${err}`, }) console.log('[Approval] Rollback applied due to error') // 🔧 失敗也要恢復 Polling if (wasPolling) { get().startPolling(get().pollingInterval || DEFAULT_POLLING_INTERVAL) console.log('[Approval] Polling resumed after sign error') } return null } }, // ========================================================================== // Reject Approval (Phase 15: Optimistic Updates) // ========================================================================== rejectApproval: async (id, rejectorId, rejectorName, reason, csrfToken) => { // 🔧 Race Condition 修復: 拒絕期間暫停 Polling const wasPolling = pollingTimer !== null if (wasPolling) { clearInterval(pollingTimer!) pollingTimer = null console.log('[Approval] Polling paused during reject') } // 🎯 Phase 15: 樂觀更新 - 立即更新 UI (Optimistic Update) const state = get() const originalApprovals = [...state.pendingApprovals] // 樂觀更新: 假設拒絕成功,立即更新狀態 set({ pendingApprovals: state.pendingApprovals.map((a) => a.id === id ? { ...a, status: 'rejected' as ApprovalStatus } : a ), rejectingId: id, error: null, }) console.log('[Approval] Optimistic reject applied:', id) try { // Phase 20: CSRF Protection const headers: Record = { 'Content-Type': 'application/json' } if (csrfToken) { headers['X-CSRF-Token'] = csrfToken } const response = await fetch(`${API_BASE_URL}/api/v1/approvals/${id}/reject`, { method: 'POST', headers, credentials: 'include', // Phase 20: 確保 cookie 被發送 body: JSON.stringify({ rejector_id: rejectorId, rejector_name: rejectorName, reason, }), }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.detail || `HTTP ${response.status}`) } console.log('[Approval] Rejected:', id) // Update local state // 🔴 2026-03-26 修復: 拒絕後保留內容顯示,不立即移除 set((state) => { const newRecentlyRejected = new Set(state.recentlyRejected) newRecentlyRejected.add(id) // 更新狀態但保留在列表中 const updatedApprovals = state.pendingApprovals.map((a) => a.id === id ? { ...a, status: 'rejected' as const } : a ) return { pendingApprovals: updatedApprovals, rejectingId: null, recentlyRejected: newRecentlyRejected, } }) // 🔴 延遲 5 秒後才從列表移除 setTimeout(() => { set((state) => ({ pendingApprovals: state.pendingApprovals.filter((a) => a.id !== id), })) console.log('[Approval] Removed rejected item after delay:', id) }, 5000) // 🔧 Race Condition 修復: 延遲 1 秒後恢復 Polling if (wasPolling) { setTimeout(() => { get().startPolling(get().pollingInterval || DEFAULT_POLLING_INTERVAL) console.log('[Approval] Polling resumed after reject') }, 1000) } return true } catch (err) { console.error('[Approval] Reject failed:', err) // 🎯 Phase 15: 樂觀更新失敗 - 回滾到原始狀態 (Rollback) set({ pendingApprovals: originalApprovals, rejectingId: null, error: `Reject failed: ${err}`, }) console.log('[Approval] Rollback applied due to reject error') // 🔧 失敗也要恢復 Polling if (wasPolling) { get().startPolling(get().pollingInterval || DEFAULT_POLLING_INTERVAL) console.log('[Approval] Polling resumed after reject error') } return false } }, // ========================================================================== // SSE Connection (Phase 15: Polling → SSE) // ========================================================================== connectSSE: () => { const state = get() // Already connected or connecting if (eventSource && state.sseStatus === 'connected') { console.log('[Approval SSE] Already connected') return } // Clean up existing connection if (eventSource) { eventSource.close() eventSource = null } if (!API_BASE_URL) { console.error('[Approval SSE] Missing API URL') set({ sseStatus: 'error', error: 'Missing API URL' }) return } set({ sseStatus: 'connecting', error: null }) console.log('[Approval SSE] Connecting to', `${API_BASE_URL}/api/v1/approvals/stream`) // Initial fetch state.fetchPending() // Create EventSource eventSource = new EventSource(`${API_BASE_URL}/api/v1/approvals/stream`) eventSource.onopen = () => { console.log('[Approval SSE] Connected') set({ sseStatus: 'connected', sseReconnectAttempts: 0, error: null, }) } // Handle approval events (Phase 15: Incremental Updates) eventSource.addEventListener('approval', (e: MessageEvent) => { try { const data = JSON.parse(e.data) as { action: 'created' | 'signed' | 'rejected' | 'expired' | 'executed' approval_id: string approval?: ApprovalRequest } console.log('[Approval SSE] Event:', data.action, data.approval_id) // 🎯 Phase 15: 增量更新取代全量 re-fetch switch (data.action) { case 'created': // 新增: 如果有完整資料,直接加入列表 if (data.approval) { set((state) => ({ pendingApprovals: [data.approval!, ...state.pendingApprovals], })) } else { // 無完整資料時才 re-fetch get().fetchPending() } break case 'signed': // 簽核: 更新或確認樂觀更新 if (data.approval) { set((state) => ({ pendingApprovals: state.pendingApprovals.map((a) => a.id === data.approval_id ? data.approval! : a ), })) } break case 'rejected': case 'expired': case 'executed': // 移除: 直接從列表移除 (延遲 2 秒讓 UI 顯示最終狀態) setTimeout(() => { set((state) => ({ pendingApprovals: state.pendingApprovals.filter( (a) => a.id !== data.approval_id ), })) }, 2000) break default: // 未知事件類型: re-fetch 作為 fallback get().fetchPending() } } catch (err) { console.error('[Approval SSE] Failed to parse event:', err) } }) eventSource.addEventListener('heartbeat', () => { console.log('[Approval SSE] Heartbeat') }) eventSource.addEventListener('connected', () => { console.log('[Approval SSE] Server confirmed connection') }) // Error handling with exponential backoff eventSource.onerror = () => { const attempts = get().sseReconnectAttempts if (attempts >= MAX_SSE_RECONNECT_ATTEMPTS) { console.error('[Approval SSE] Max reconnect attempts reached, falling back to polling') set({ sseStatus: 'error', error: 'SSE connection failed' }) eventSource?.close() eventSource = null // Fallback to polling get().startPolling() return } set({ sseStatus: 'reconnecting', sseReconnectAttempts: attempts + 1, }) const delay = Math.min( BASE_RECONNECT_DELAY * Math.pow(2, attempts), MAX_RECONNECT_DELAY ) console.log(`[Approval SSE] Reconnecting in ${delay}ms (attempt ${attempts + 1})`) if (sseReconnectTimeout) clearTimeout(sseReconnectTimeout) sseReconnectTimeout = setTimeout(() => { eventSource?.close() eventSource = null get().connectSSE() }, delay) } }, disconnectSSE: () => { console.log('[Approval SSE] Disconnecting') if (eventSource) { eventSource.close() eventSource = null } if (sseReconnectTimeout) { clearTimeout(sseReconnectTimeout) sseReconnectTimeout = null } set({ sseStatus: 'disconnected', sseReconnectAttempts: 0 }) }, // ========================================================================== // Polling (Legacy Fallback) // ========================================================================== startPolling: (intervalMs = DEFAULT_POLLING_INTERVAL) => { const state = get() // Clear existing timer if (pollingTimer) { clearInterval(pollingTimer) } // Initial fetch state.fetchPending() // Start polling pollingTimer = setInterval(() => { get().fetchPending() }, intervalMs) set({ pollingInterval: intervalMs }) console.log('[Approval] Polling started:', intervalMs, 'ms') }, stopPolling: () => { if (pollingTimer) { clearInterval(pollingTimer) pollingTimer = null } set({ pollingInterval: null }) console.log('[Approval] Polling stopped') }, clearRecentlyResolved: (id) => { set((state) => { const newApproved = new Set(state.recentlyApproved) const newRejected = new Set(state.recentlyRejected) newApproved.delete(id) newRejected.delete(id) return { recentlyApproved: newApproved, recentlyRejected: newRejected, } }) }, setError: (error) => { set({ error }) }, })) ) // ============================================================================= // Selector Hooks // ============================================================================= export const usePendingApprovals = () => useApprovalStore((state) => state.pendingApprovals) export const useApprovalCount = () => useApprovalStore((state) => state.pendingApprovals.length) export const useApprovalById = (id: string) => useApprovalStore((state) => state.pendingApprovals.find((a) => a.id === id)) export const useIsSigningApproval = (id: string) => useApprovalStore((state) => state.signingId === id) export const useIsRejectingApproval = (id: string) => useApprovalStore((state) => state.rejectingId === id) export const useApprovalError = () => useApprovalStore((state) => state.error) // SSE Status Hooks (Phase 15) export const useApprovalSSEStatus = () => useApprovalStore((state) => state.sseStatus) export const useApprovalSSEConnected = () => useApprovalStore((state) => state.sseStatus === 'connected') // ============================================================================= // Type Converters (Backend → Frontend) // ============================================================================= // P1 修復: 使用 mapping object 取代 toUpperCase() as Type (2026-03-26) const DATA_IMPACT_MAP: Record = { 'none': 'NONE', 'read_only': 'READ_ONLY', 'read-only': 'READ_ONLY', 'readonly': 'READ_ONLY', 'write': 'WRITE', 'destructive': 'DESTRUCTIVE', // 大寫版本 (防禦性) 'NONE': 'NONE', 'READ_ONLY': 'READ_ONLY', 'WRITE': 'WRITE', 'DESTRUCTIVE': 'DESTRUCTIVE', } export function toFrontendApproval(backend: ApprovalRequest): FrontendApprovalRequest { const rawDataImpact = backend.blast_radius.data_impact || 'none' const dataImpact = DATA_IMPACT_MAP[rawDataImpact] || DATA_IMPACT_MAP[rawDataImpact.toLowerCase()] || 'NONE' return { id: backend.id, action: backend.action, description: backend.description, riskLevel: backend.risk_level === 'critical' ? 'critical' : backend.risk_level === 'high' ? 'high' : backend.risk_level === 'medium' ? 'medium' : 'low', blastRadius: { affectedPods: backend.blast_radius.affected_pods, estimatedDowntime: backend.blast_radius.estimated_downtime, relatedServices: backend.blast_radius.related_services, dataImpact, }, dryRunChecks: backend.dry_run_checks.map((c) => ({ name: c.name, passed: c.passed, message: c.message || undefined, })), requiredSignatures: backend.required_signatures, currentSignatures: backend.current_signatures, requestedBy: backend.requested_by, requestedAt: new Date(backend.created_at).toLocaleString('zh-TW'), // 戰略 B: 告警風暴收斂 hitCount: backend.hit_count ?? 1, lastSeenAt: backend.last_seen_at || undefined, fingerprint: backend.fingerprint || undefined, } }