From 1c6f520ceecbbca6d673ba5040c96b9add4d3c45 Mon Sep 17 00:00:00 2001 From: Vandan Panwala Date: Sat, 14 Mar 2026 12:46:10 +0530 Subject: [PATCH 01/22] FEAT: Add DeepSeek LLM provider integration Adds DeepSeek-V3/R1 as a third LLM provider option alongside Anthropic and Gemini. DeepSeek uses an OpenAI-compatible API so no new dependencies are required. - backend/app/config.py: added deepseek_api_key, deepseek_model, deepseek_base_url settings; updated llm_provider comment - backend/app/services/llm.py: added DeepSeekService class implementing all five LLM methods (analyze_document, analyze_document_with_prompt, generate_finding_details, answer_query, _generate); updated create_llm_service() factory to handle LLM_PROVIDER=deepseek --- backend/app/config.py | 7 +- backend/app/services/llm.py | 224 +++++++++++++++++++++++++++++++++++- 2 files changed, 229 insertions(+), 2 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 01b9219..ed69882 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,7 +41,7 @@ class Settings(BaseSettings): minio_secure: bool = False # AI/ML - Provider Selection - llm_provider: str = "anthropic" # "anthropic" or "gemini" + llm_provider: str = "anthropic" # "anthropic", "gemini", or "deepseek" embedding_provider: str = "openai" # "openai" or "gemini" # Anthropic (Claude) - Using Claude Opus 4.5 (Latest as of Jan 2026) @@ -58,6 +58,11 @@ class Settings(BaseSettings): gemini_model: str = "gemini-3.0-flash" gemini_embedding_model: str = "text-embedding-005" + # DeepSeek - OpenAI-compatible API + deepseek_api_key: str | None = None + deepseek_model: str = "deepseek-chat" + deepseek_base_url: str = "https://api.deepseek.com" + # Document Processing azure_doc_intel_endpoint: str | None = None azure_doc_intel_key: str | None = None diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 3b31652..4c9eb36 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -725,11 +725,224 @@ async def answer_query( return self._parse_json_response(raw_text) -LLMService = ClaudeService | GeminiService +class DeepSeekService(BaseLLMService): + """Service for interacting with DeepSeek API for document analysis. + + DeepSeek exposes an OpenAI-compatible API, so this service uses the + openai Python package pointed at DeepSeek's base URL. + + Supported models: + - deepseek-chat (DeepSeek-V3, general purpose, default) + - deepseek-reasoner (DeepSeek-R1, chain-of-thought reasoning) + """ + + def __init__( + self, + api_key: str | None = None, + model: str | None = None, + base_url: str | None = None, + ): + """Initialize the DeepSeek service. + + Args: + api_key: DeepSeek API key (uses settings if not provided) + model: Model name (uses settings if not provided) + base_url: API base URL (uses settings if not provided) + """ + settings = get_settings() + self.api_key = api_key or settings.deepseek_api_key + self._model = model or settings.deepseek_model + self._base_url = base_url or settings.deepseek_base_url + self._client = None + + if self.api_key: + try: + from openai import AsyncOpenAI + + self._client = AsyncOpenAI( + api_key=self.api_key, + base_url=self._base_url, + ) + except ImportError: + pass + + @property + def is_configured(self) -> bool: + """Check if the service has valid API credentials.""" + return self._client is not None + + @property + def model(self) -> str: + """Return the model name being used.""" + return self._model + + async def _generate( + self, + prompt: str, + system: str | None = None, + max_tokens: int = 4096, + ) -> tuple[str, int, int]: + """Generate content using the DeepSeek API. + + Args: + prompt: The user prompt to send + system: Optional system prompt + max_tokens: Maximum tokens in response + + Returns: + Tuple of (response_text, input_tokens, output_tokens) + """ + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + response = await self._client.chat.completions.create( + model=self._model, + messages=messages, + max_tokens=max_tokens, + temperature=0.1, + ) + + content = response.choices[0].message.content or "" + input_tokens = response.usage.prompt_tokens if response.usage else 0 + output_tokens = response.usage.completion_tokens if response.usage else 0 + return content, input_tokens, output_tokens + + async def analyze_document( + self, + chunks: list[dict], + framework: str, + document_type: str, + max_tokens: int = 4096, + ) -> AnalysisResult: + """Analyze document chunks against a compliance framework.""" + if not self.is_configured: + raise ValueError("DeepSeek API key not configured") + + context = self._build_context(chunks) + prompt = self._build_analysis_prompt(context, framework, document_type) + raw_text, input_tokens, output_tokens = await self._generate( + prompt, self.ANALYSIS_SYSTEM_PROMPT, max_tokens + ) + findings = self._parse_findings(raw_text) + + return AnalysisResult( + findings=findings, + summary=self._extract_summary(findings), + raw_response=raw_text, + model=self._model, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + + async def analyze_document_with_prompt( + self, + prompt: str, + framework: str, + document_type: str | None = None, + max_tokens: int = 8192, + ) -> AnalysisResult: + """Analyze document using a pre-built prompt.""" + if not self.is_configured: + raise ValueError("DeepSeek API key not configured") + + from app.prompts.compliance_analysis import COMPLIANCE_ANALYSIS_SYSTEM_PROMPT + + raw_text, input_tokens, output_tokens = await self._generate( + prompt, COMPLIANCE_ANALYSIS_SYSTEM_PROMPT, max_tokens + ) + parsed = self._parse_enhanced_response(raw_text) + findings = parsed.get("findings", []) + summary = parsed.get("overall_assessment", {}).get( + "summary", self._extract_summary(findings) + ) + + return AnalysisResult( + findings=findings, + summary=summary, + raw_response=raw_text, + model=self._model, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + + async def generate_finding_details( + self, + chunk_content: str, + framework_control: str, + initial_concern: str, + max_tokens: int = 2048, + ) -> dict: + """Generate detailed finding information for a specific concern.""" + if not self.is_configured: + raise ValueError("DeepSeek API key not configured") + + prompt = f"""Analyze this document excerpt and provide a detailed finding assessment. + +Document Excerpt: +{chunk_content} + +Framework Control: {framework_control} +Initial Concern: {initial_concern} + +Provide a detailed assessment in JSON format: +{{ + "title": "Brief finding title", + "severity": "critical|high|medium|low|info", + "description": "Detailed description of the finding", + "evidence": "Specific quote from the document", + "impact": "Business impact of this finding", + "remediation": "Recommended remediation steps", + "confidence": 0.0-1.0 +}}""" + + raw_text, _, _ = await self._generate(prompt, max_tokens=max_tokens) + return self._parse_json_response(raw_text) + + async def answer_query( + self, + query: str, + context_chunks: list[dict], + max_tokens: int = 2048, + ) -> dict: + """Answer a natural language query about the documents.""" + if not self.is_configured: + raise ValueError("DeepSeek API key not configured") + + context = self._build_context(context_chunks) + + prompt = f"""Based on the following document excerpts, answer the user's question. + +Document Context: +{context} + +User Question: {query} + +Provide your answer in JSON format: +{{ + "answer": "Your detailed answer", + "confidence": 0.0-1.0, + "citations": [ + {{ + "chunk_index": 0, + "excerpt": "relevant quote", + "relevance": "why this is relevant" + }} + ], + "limitations": "Any limitations or caveats" +}}""" + + raw_text, _, _ = await self._generate(prompt, max_tokens=max_tokens) + return self._parse_json_response(raw_text) + + +LLMService = ClaudeService | GeminiService | DeepSeekService _claude_service: ClaudeService | None = None _gemini_service: GeminiService | None = None +_deepseek_service: DeepSeekService | None = None _llm_service: LLMService | None = None @@ -747,6 +960,13 @@ def get_gemini_service() -> GeminiService: return _gemini_service +def get_deepseek_service() -> DeepSeekService: + global _deepseek_service + if _deepseek_service is None: + _deepseek_service = DeepSeekService() + return _deepseek_service + + def create_llm_service(provider: str | None = None) -> LLMService: settings = get_settings() provider = provider or settings.llm_provider @@ -754,6 +974,8 @@ def create_llm_service(provider: str | None = None) -> LLMService: return ClaudeService() elif provider == "gemini": return GeminiService() + elif provider == "deepseek": + return DeepSeekService() raise ValueError(f"Unsupported LLM provider: {provider}") From 93af7d3ee4d25764a5a64daf4ccb498bc8196fe3 Mon Sep 17 00:00:00 2001 From: Vandan Panwala Date: Sat, 14 Mar 2026 12:46:37 +0530 Subject: [PATCH 02/22] FIX: Resolve TypeError in risk scoring when comparing datetimes SQLite stores datetimes without timezone info (naive), but the code used datetime.now(timezone.utc) (aware). Subtracting them raised: TypeError: can't subtract offset-naive and offset-aware datetimes Fix: treat naive datetimes from SQLite as UTC before comparison in _calculate_document_freshness_score(). --- backend/app/services/risk_scoring.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/app/services/risk_scoring.py b/backend/app/services/risk_scoring.py index f8fe23d..45f1534 100644 --- a/backend/app/services/risk_scoring.py +++ b/backend/app/services/risk_scoring.py @@ -239,11 +239,12 @@ def _calculate_document_freshness_score(documents: list[Document]) -> tuple[floa oldest_age_days = 0 for doc in processed_docs: - if doc.processed_at: - age = (now - doc.processed_at).days - oldest_age_days = max(oldest_age_days, age) - elif doc.created_at: - age = (now - doc.created_at).days + ts = doc.processed_at or doc.created_at + if ts: + # SQLite stores naive datetimes - treat as UTC for safe comparison + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + age = (now - ts).days oldest_age_days = max(oldest_age_days, age) # Score based on age thresholds From 8375bb77a75a4a5978beab54c56caa9da387605a Mon Sep 17 00:00:00 2001 From: Vandan Panwala Date: Sat, 14 Mar 2026 12:47:03 +0530 Subject: [PATCH 03/22] FIX: Normalize backend snake_case findings to camelCase in Analysis page The backend FindingResponse uses snake_case (confidence_score, framework_control, finding_type, remediation, created_at) while the frontend Finding type and all its consuming components (FindingCard, FindingsList) expected camelCase. This caused a crash on render because finding.findingType was undefined and .charAt(0) threw a TypeError, resulting in a completely blank Analysis page. Changes: - frontend/src/lib/findings.ts: new normalizeFinding() utility that maps every backend field to its camelCase counterpart; splits framework_control into controlId and controlName; defaults missing fields safely - frontend/src/pages/Analysis.tsx: apply normalizeFinding() to API response; add FindingsErrorBoundary to catch any future render crashes; read ?document_id= URL param to allow vendor/document pages to pre-select a document; increase analysis timeout to 180s for LLM calls --- frontend/src/lib/findings.ts | 58 +++++++++++++++++++++++++++++++++ frontend/src/pages/Analysis.tsx | 51 ++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/findings.ts diff --git a/frontend/src/lib/findings.ts b/frontend/src/lib/findings.ts new file mode 100644 index 0000000..e93d724 --- /dev/null +++ b/frontend/src/lib/findings.ts @@ -0,0 +1,58 @@ +/** + * Utility for normalizing backend finding responses. + * + * The backend returns snake_case fields (confidence_score, framework_control, etc.) + * while the frontend Finding type uses camelCase. This module provides a single + * normalization function used wherever findings are fetched from the API. + */ + +import type { Finding, FindingType, Severity } from '@/types/api'; + +/** + * Map a raw backend FindingResponse (snake_case) to the camelCase Finding shape + * expected by FindingCard, FindingsList, and FindingsSummary. + * + * Backend fields that differ from the frontend type: + * analysis_run_id -> runId + * document_id -> documentId + * framework_control -> controlId (first segment) + controlName (remainder) + * finding_type -> findingType (may be absent; defaults to 'gap') + * confidence_score -> confidenceScore + * remediation -> recommendation + * page_number -> pageReferences (single number -> single-element array) + * created_at -> createdAt + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function normalizeFinding(raw: any): Finding { + // framework_control may look like "CC6.1 - Logical Access Controls" + // Split on first " - " to get controlId and controlName separately. + const frameworkControl: string = raw.framework_control ?? raw.controlId ?? ''; + const separatorIdx = frameworkControl.indexOf(' - '); + const controlId = + separatorIdx !== -1 ? frameworkControl.slice(0, separatorIdx) : frameworkControl; + const controlName = + separatorIdx !== -1 ? frameworkControl.slice(separatorIdx + 3) : (raw.controlName ?? ''); + + return { + id: raw.id ?? '', + runId: raw.analysis_run_id ?? raw.runId ?? '', + documentId: raw.document_id ?? raw.documentId ?? '', + controlId, + controlName, + framework: raw.framework ?? '', + findingType: (raw.finding_type ?? raw.findingType ?? 'gap') as FindingType, + severity: (raw.severity ?? 'info') as Severity, + title: raw.title ?? '', + description: raw.description ?? '', + evidence: raw.evidence ?? undefined, + recommendation: raw.remediation ?? raw.recommendation ?? undefined, + pageReferences: + raw.page_number != null + ? [raw.page_number as number] + : (raw.pageReferences ?? []), + citations: raw.citations ?? [], + confidenceScore: raw.confidence_score ?? raw.confidenceScore ?? 0, + status: raw.status ?? 'open', + createdAt: raw.created_at ?? raw.createdAt ?? new Date().toISOString(), + }; +} diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 5fbd3cd..f506bb2 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useState, useEffect, Component, type ReactNode } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Play, FileText, AlertTriangle, Loader2, RefreshCw, Download, FileSpreadsheet, Shield } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; @@ -6,6 +7,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/u import { CardSkeleton, SkeletonLoader } from '@/components/ui/TypingIndicator'; import { FindingsSummary, FindingsList } from '@/components/findings'; import apiClient, { getApiErrorMessage } from '@/lib/api'; +import { normalizeFinding } from '@/lib/findings'; import type { Document, Finding, AnalysisRun, Severity } from '@/types/api'; // Animation variants @@ -51,14 +53,51 @@ const FRAMEWORKS = [ type FrameworkType = typeof FRAMEWORKS[number]['value']; +/** + * Error boundary to catch render crashes in findings components + * and show a readable error instead of a blank screen. + */ +class FindingsErrorBoundary extends Component< + { children: ReactNode }, + { hasError: boolean; message: string } +> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { hasError: false, message: '' }; + } + static getDerivedStateFromError(error: Error) { + return { hasError: true, message: error.message }; + } + render() { + if (this.state.hasError) { + return ( +
+

Failed to render findings

+

{this.state.message}

+
+ ); + } + return this.props.children; + } +} + /** * Analysis Page - Document analysis dashboard with findings display. * Allows users to select a document, run analysis, and view findings. */ export function Analysis() { const queryClient = useQueryClient(); - const [selectedDocumentId, setSelectedDocumentId] = useState(''); + const [searchParams] = useSearchParams(); + + // Allow vendor/document pages to pre-select a document via ?document_id=... + const documentIdParam = searchParams.get('document_id') ?? ''; + const [selectedDocumentId, setSelectedDocumentId] = useState(documentIdParam); const [selectedFramework, setSelectedFramework] = useState('soc2_tsc'); + + // Keep the selection in sync if the URL param changes (e.g. browser back/forward) + useEffect(() => { + if (documentIdParam) setSelectedDocumentId(documentIdParam); + }, [documentIdParam]); const [analysisError, setAnalysisError] = useState(null); const [isExporting, setIsExporting] = useState<'csv' | 'pdf' | null>(null); @@ -140,7 +179,8 @@ export function Analysis() { enabled: !!selectedDocumentId, }); - const findings: Finding[] = findingsResponse?.data || []; + // Normalize snake_case backend response to camelCase Finding shape + const findings: Finding[] = (findingsResponse?.data || []).map(normalizeFinding); const analysisRuns: AnalysisRun[] = runsResponse?.data || []; const latestRun = analysisRuns[0]; @@ -148,10 +188,11 @@ export function Analysis() { const runAnalysisMutation = useMutation({ mutationFn: async ({ documentId, framework }: { documentId: string; framework: FrameworkType }) => { // POST /analysis/documents/{document_id}/analyze with { framework, chunk_limit } + // LLM analysis can take 60-180s depending on document size and provider, so use a long timeout const response = await apiClient.post(`/analysis/documents/${documentId}/analyze`, { framework, chunk_limit: 50, - }); + }, { timeout: 180000 }); return response.data; }, onSuccess: () => { @@ -452,6 +493,7 @@ export function Analysis() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} > + {findings.length > 0 ? (
{/* Summary Statistics */} @@ -528,6 +570,7 @@ export function Analysis() { )} + )} From 4c97f8c6ddd47e947c6672cefd9f12f7c4383db8 Mon Sep 17 00:00:00 2001 From: Vandan Panwala Date: Sat, 14 Mar 2026 12:47:17 +0530 Subject: [PATCH 04/22] FIX: Associate uploaded documents with vendor when uploading from vendor page When navigating to /documents?vendor_id= (e.g. from the vendor detail page), the upload form now passes vendor_id as a query parameter to the POST /api/v1/documents endpoint. Previously the parameter was ignored and documents appeared under Others with no vendor link. Also increases the upload request timeout to 120s to accommodate large files and slow document processing. --- frontend/src/pages/Documents.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Documents.tsx b/frontend/src/pages/Documents.tsx index 205a5a9..50b75f2 100644 --- a/frontend/src/pages/Documents.tsx +++ b/frontend/src/pages/Documents.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Upload, Search, FileText, MoreVertical, CheckCircle, Clock, AlertCircle, Loader2, Download, Trash2 } from 'lucide-react'; import { @@ -51,6 +52,8 @@ interface BackendDocument { export function Documents() { const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); + const vendorIdFromUrl = searchParams.get('vendor_id'); const [searchQuery, setSearchQuery] = useState(''); const [isDragging, setIsDragging] = useState(false); const [uploadError, setUploadError] = useState(null); @@ -87,11 +90,21 @@ export function Documents() { const formData = new FormData(); formData.append('file', file); - const response = await apiClient.post('/documents', formData); + // If arriving from a vendor detail page, associate the document with that vendor + const url = vendorIdFromUrl + ? `/documents?vendor_id=${encodeURIComponent(vendorIdFromUrl)}` + : '/documents'; + + // Document upload triggers text extraction and chunking which can be slow for large files + const response = await apiClient.post(url, formData, { timeout: 120000 }); return response.data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['documents'] }); + // Also invalidate vendor-documents so the vendor detail page refreshes + if (vendorIdFromUrl) { + queryClient.invalidateQueries({ queryKey: ['vendor-documents', vendorIdFromUrl] }); + } setUploadError(null); }, onError: (error) => { @@ -233,6 +246,12 @@ export function Documents() {

DOCUMENTVAULT

+ {vendorIdFromUrl && ( +
+ + UPLOADING FOR VENDOR — documents will be linked automatically +
+ )}
Date: Sat, 14 Mar 2026 12:47:36 +0530 Subject: [PATCH 05/22] FIX: Pre-select document in Analysis when clicking View All Findings The View All Findings button previously navigated to /analysis with no context, leaving the page empty. It now navigates to /analysis?document_id= so the document is pre-selected and existing findings are loaded immediately. Also applies normalizeFinding() to vendor-level findings so the severity breakdown sidebar renders correctly. --- frontend/src/pages/VendorDetail.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/VendorDetail.tsx b/frontend/src/pages/VendorDetail.tsx index 69fc796..ded1cd0 100644 --- a/frontend/src/pages/VendorDetail.tsx +++ b/frontend/src/pages/VendorDetail.tsx @@ -34,6 +34,7 @@ import { DialogFooter, } from '@/components/ui'; import apiClient, { getApiErrorMessage } from '@/lib/api'; +import { normalizeFinding } from '@/lib/findings'; import { AIClassificationPanel } from '@/components/vendors/AIClassificationPanel'; import type { Vendor, VendorTier, VendorStatus, Document, Finding, UpdateVendorRequest } from '@/types/api'; @@ -101,7 +102,8 @@ export function VendorDetail() { }); const documents: Document[] = documentsResponse?.data || []; - const findings: Finding[] = findingsResponse?.data || []; + // Normalize snake_case backend response to camelCase Finding shape + const findings: Finding[] = (findingsResponse?.data || []).map(normalizeFinding); // Update mutation const updateMutation = useMutation({ @@ -479,7 +481,7 @@ export function VendorDetail() { Documents ({documents.length}) - @@ -568,7 +570,17 @@ export function VendorDetail() { From 6e02ea6d6d8551c39e296fe0ff59f11ad235a957 Mon Sep 17 00:00:00 2001 From: Vandan Panwala Date: Sat, 14 Mar 2026 12:48:05 +0530 Subject: [PATCH 06/22] FIX: Prevent infinite login/dashboard redirect loop on stale auth state When the backend was restarted or tokens expired, Zustand's persisted store could retain isAuthenticated: true while localStorage had no tokens. On the next page load, ProtectedRoute saw isAuthenticated: true and redirected to /dashboard, which immediately redirected back to /login, creating a loop. Three-part fix: - authStore.ts: init() now explicitly resets user/tokens/isAuthenticated to null/false when no tokens are found in localStorage, clearing any stale persisted state - api.ts: 401 response handler also clears the auth-storage Zustand persist key so the store resets on the next load after token expiry - App.tsx: defers route rendering until after init() completes via a ready flag, preventing ProtectedRoute/PublicRoute from reading stale Zustand state before the reset has run --- frontend/src/App.tsx | 13 ++++++++++++- frontend/src/lib/api.ts | 3 +++ frontend/src/stores/authStore.ts | 11 ++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d28b327..1369c42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { MainLayout, AuthLayout } from '@/components/layout'; import { Landing, Login, Register, Dashboard, Vendors, VendorDetail, Documents, Query, Analysis, Remediation, Monitoring, Agents, Risk, Analytics, Competition, Playbooks, ApprovedVendors, BPO, Integrations } from '@/pages'; @@ -33,11 +33,22 @@ function PublicRoute({ children }: { children: React.ReactNode }) { function App() { const { init } = useAuthStore(); + const [ready, setReady] = useState(false); useEffect(() => { + // Run init synchronously then allow routes to render. + // This prevents a race where Zustand's persisted isAuthenticated: true + // is visible to ProtectedRoute/PublicRoute before init() has a chance + // to reset it when tokens are missing (which causes the redirect loop). init(); + setReady(true); }, [init]); + if (!ready) { + // Blank frame while auth state is being resolved - avoids redirect flicker + return null; + } + return (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d98e5f6..07c16d8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -48,6 +48,9 @@ apiClient.interceptors.response.use( if (error.response?.status === 401) { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); + // Clear the Zustand persist store so isAuthenticated resets to false, + // preventing an infinite redirect loop between /login and /dashboard + localStorage.removeItem('auth-storage'); // Only redirect if not already on login page if (window.location.pathname !== '/login') { diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 5a1482d..7541466 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -177,12 +177,21 @@ export const useAuthStore = create()( const refreshToken = localStorage.getItem('refresh_token'); if (accessToken && refreshToken) { - // Tokens exist, consider authenticated (will be validated on first API call) + // Tokens exist - consider authenticated (validated on first API call) set({ accessToken, refreshToken, isAuthenticated: true, }); + } else { + // No tokens - explicitly reset auth state to prevent stale persisted + // isAuthenticated: true from causing an infinite /login <-> /dashboard loop + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + }); } }, }), From ad953b1244b887034e0034e792daed1ba1066a8e Mon Sep 17 00:00:00 2001 From: Vandan Panwala Date: Sat, 14 Mar 2026 12:48:12 +0530 Subject: [PATCH 07/22] CHORE: Update frontend package-lock.json --- frontend/package-lock.json | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f1ee905..aca1790 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -174,7 +174,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -543,7 +542,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -587,7 +585,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2483,6 +2480,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2496,6 +2494,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2510,7 +2509,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2586,7 +2586,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2722,7 +2723,6 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2740,7 +2740,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2752,7 +2751,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2806,7 +2804,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3100,7 +3097,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3557,7 +3553,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4283,7 +4278,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -4571,7 +4567,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6146,7 +6141,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6176,7 +6170,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -6409,6 +6402,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7072,7 +7066,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7306,7 +7299,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7319,7 +7311,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7333,7 +7324,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8311,7 +8301,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8432,7 +8421,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9109,7 +9097,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9278,7 +9265,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", From cc1c6d41cf5dc5f811c7932217c073cbc3ff145c Mon Sep 17 00:00:00 2001 From: Vandan Panwala Date: Sat, 21 Mar 2026 19:02:01 +0530 Subject: [PATCH 08/22] FEAT: Custom Framework Builder (v1.2 Feature 1) Add full custom compliance framework CRUD and wire it into the analysis pipeline so users can define their own controls and run AI analysis against them exactly like built-in frameworks. Backend: - New CustomFramework + CustomControl SQLAlchemy models - New Pydantic schemas (create/update/response/list) - New /custom-frameworks REST endpoints (framework + control CRUD) - Register models in __init__.py, router under /custom-frameworks - Extend AnalysisRequest with optional custom_framework_id field - analysis.py service: fetch custom framework + inject controls into LLM prompt; store framework as "custom:" on run - analysis.py endpoint: validate exactly one of framework / custom_framework_id is supplied Frontend: - New Frameworks page: list view, edit view, controls table, add/edit control inline form, clone-from-built-in modal, Run Analysis shortcut button - Export Frameworks from pages/index.ts - Add /frameworks route in App.tsx - Add Frameworks nav link (Layers icon) in Sidebar.tsx - Analysis page: fetch /custom-frameworks, show as second optgroup in framework dropdown, pass custom_framework_id in POST body when a custom framework is selected, support ?custom_framework_id= URL param for deep-linking --- backend/app/api/v1/endpoints/analysis.py | 8 +- .../app/api/v1/endpoints/custom_frameworks.py | 269 +++++++ backend/app/api/v1/router.py | 6 + backend/app/models/__init__.py | 3 + backend/app/models/custom_framework.py | 61 ++ backend/app/schemas/custom_framework.py | 102 +++ backend/app/schemas/finding.py | 15 +- backend/app/services/analysis.py | 75 +- frontend/src/App.tsx | 3 +- frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/pages/Analysis.tsx | 83 +- frontend/src/pages/Frameworks.tsx | 735 ++++++++++++++++++ frontend/src/pages/index.ts | 1 + 13 files changed, 1325 insertions(+), 38 deletions(-) create mode 100644 backend/app/api/v1/endpoints/custom_frameworks.py create mode 100644 backend/app/models/custom_framework.py create mode 100644 backend/app/schemas/custom_framework.py create mode 100644 frontend/src/pages/Frameworks.tsx diff --git a/backend/app/api/v1/endpoints/analysis.py b/backend/app/api/v1/endpoints/analysis.py index 564b77d..e4b6b70 100644 --- a/backend/app/api/v1/endpoints/analysis.py +++ b/backend/app/api/v1/endpoints/analysis.py @@ -38,13 +38,19 @@ async def analyze_document( The document must be processed before analysis. Analysis uses Claude to identify gaps against the specified framework. """ + if not analysis_request.framework and not analysis_request.custom_framework_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide either 'framework' (built-in) or 'custom_framework_id'.", + ) try: analysis_run = await analysis_service.run_analysis( db=db, document_id=document_id, org_id=current_user.organization_id, - framework=analysis_request.framework, + framework=analysis_request.framework or "custom", chunk_limit=analysis_request.chunk_limit, + custom_framework_id=analysis_request.custom_framework_id, ) await db.commit() await db.refresh(analysis_run) diff --git a/backend/app/api/v1/endpoints/custom_frameworks.py b/backend/app/api/v1/endpoints/custom_frameworks.py new file mode 100644 index 0000000..1277f9f --- /dev/null +++ b/backend/app/api/v1/endpoints/custom_frameworks.py @@ -0,0 +1,269 @@ +"""Custom compliance framework CRUD endpoints. + +Provides endpoints for: +- POST /custom-frameworks - create a framework +- GET /custom-frameworks - list frameworks for the org +- GET /custom-frameworks/{id} - get a framework with all controls +- PATCH /custom-frameworks/{id} - update framework metadata +- DELETE /custom-frameworks/{id} - delete a framework +- POST /custom-frameworks/{id}/controls - add a control +- PATCH /custom-frameworks/{id}/controls/{control_id} - update a control +- DELETE /custom-frameworks/{id}/controls/{control_id} - remove a control +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.api.deps import get_current_active_user +from app.db import get_db +from app.models import User +from app.models.custom_framework import CustomControl, CustomFramework +from app.schemas.custom_framework import ( + CustomControlCreate, + CustomControlResponse, + CustomControlUpdate, + CustomFrameworkCreate, + CustomFrameworkListResponse, + CustomFrameworkResponse, + CustomFrameworkSummary, + CustomFrameworkUpdate, +) + +router = APIRouter(tags=["Custom Frameworks"]) + + +async def _get_framework_or_404( + framework_id: str, + org_id: str, + db: AsyncSession, + load_controls: bool = False, +) -> CustomFramework: + """Fetch a framework by ID scoped to the org, or raise 404.""" + query = select(CustomFramework).where( + CustomFramework.id == framework_id, + CustomFramework.organization_id == org_id, + ) + if load_controls: + query = query.options(selectinload(CustomFramework.controls)) + result = await db.execute(query) + fw = result.scalar_one_or_none() + if not fw: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Framework not found") + return fw + + +# ── Framework CRUD ─────────────────────────────────────────────────────────── + +@router.post("", response_model=CustomFrameworkResponse, status_code=status.HTTP_201_CREATED) +async def create_framework( + payload: CustomFrameworkCreate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> CustomFrameworkResponse: + """Create a new custom compliance framework.""" + fw = CustomFramework( + organization_id=current_user.organization_id, + created_by=current_user.id, + name=payload.name, + version=payload.version, + description=payload.description, + ) + db.add(fw) + await db.commit() + await db.refresh(fw) + # Reload with controls relationship (empty at creation) + result = await db.execute( + select(CustomFramework) + .where(CustomFramework.id == fw.id) + .options(selectinload(CustomFramework.controls)) + ) + return CustomFrameworkResponse.model_validate(result.scalar_one()) + + +@router.get("", response_model=CustomFrameworkListResponse) +async def list_frameworks( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), +) -> CustomFrameworkListResponse: + """List all custom frameworks for the current organisation.""" + skip = (page - 1) * limit + + total_result = await db.execute( + select(func.count(CustomFramework.id)).where( + CustomFramework.organization_id == current_user.organization_id, + CustomFramework.is_active == True, # noqa: E712 + ) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(CustomFramework) + .where( + CustomFramework.organization_id == current_user.organization_id, + CustomFramework.is_active == True, # noqa: E712 + ) + .options(selectinload(CustomFramework.controls)) + .order_by(CustomFramework.created_at.desc()) + .offset(skip) + .limit(limit) + ) + frameworks = result.scalars().all() + + summaries = [ + CustomFrameworkSummary( + id=fw.id, + name=fw.name, + version=fw.version, + description=fw.description, + is_active=fw.is_active, + control_count=len(fw.controls), + created_at=fw.created_at, + updated_at=fw.updated_at, + ) + for fw in frameworks + ] + + return CustomFrameworkListResponse(data=summaries, total=total, page=page, limit=limit) + + +@router.get("/{framework_id}", response_model=CustomFrameworkResponse) +async def get_framework( + framework_id: str, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> CustomFrameworkResponse: + """Get a framework with all its controls.""" + fw = await _get_framework_or_404( + framework_id, current_user.organization_id, db, load_controls=True + ) + return CustomFrameworkResponse.model_validate(fw) + + +@router.patch("/{framework_id}", response_model=CustomFrameworkResponse) +async def update_framework( + framework_id: str, + payload: CustomFrameworkUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> CustomFrameworkResponse: + """Update framework metadata (name, version, description, is_active).""" + fw = await _get_framework_or_404( + framework_id, current_user.organization_id, db, load_controls=True + ) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(fw, field, value) + await db.commit() + await db.refresh(fw) + result = await db.execute( + select(CustomFramework) + .where(CustomFramework.id == fw.id) + .options(selectinload(CustomFramework.controls)) + ) + return CustomFrameworkResponse.model_validate(result.scalar_one()) + + +@router.delete("/{framework_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_framework( + framework_id: str, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + """Delete a custom framework and all its controls (cascade).""" + fw = await _get_framework_or_404(framework_id, current_user.organization_id, db) + await db.delete(fw) + await db.commit() + + +# ── Control CRUD ───────────────────────────────────────────────────────────── + +@router.post( + "/{framework_id}/controls", + response_model=CustomControlResponse, + status_code=status.HTTP_201_CREATED, +) +async def add_control( + framework_id: str, + payload: CustomControlCreate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> CustomControlResponse: + """Add a control to a framework.""" + await _get_framework_or_404(framework_id, current_user.organization_id, db) + + control = CustomControl( + framework_id=framework_id, + control_id=payload.control_id, + name=payload.name, + description=payload.description, + category=payload.category, + guidance=payload.guidance, + order_index=payload.order_index, + ) + db.add(control) + await db.commit() + await db.refresh(control) + return CustomControlResponse.model_validate(control) + + +@router.patch( + "/{framework_id}/controls/{control_id}", + response_model=CustomControlResponse, +) +async def update_control( + framework_id: str, + control_id: str, + payload: CustomControlUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> CustomControlResponse: + """Update a control.""" + await _get_framework_or_404(framework_id, current_user.organization_id, db) + + result = await db.execute( + select(CustomControl).where( + CustomControl.id == control_id, + CustomControl.framework_id == framework_id, + ) + ) + control = result.scalar_one_or_none() + if not control: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Control not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(control, field, value) + await db.commit() + await db.refresh(control) + return CustomControlResponse.model_validate(control) + + +@router.delete( + "/{framework_id}/controls/{control_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_control( + framework_id: str, + control_id: str, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> None: + """Remove a control from a framework.""" + await _get_framework_or_404(framework_id, current_user.organization_id, db) + + result = await db.execute( + select(CustomControl).where( + CustomControl.id == control_id, + CustomControl.framework_id == framework_id, + ) + ) + control = result.scalar_one_or_none() + if not control: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Control not found") + + await db.delete(control) + await db.commit() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 163d88f..8e4bb80 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -8,6 +8,7 @@ ai_classification, analysis, analytics, + custom_frameworks, approved_vendors, audit, auth, @@ -107,6 +108,11 @@ approved_vendors.router, prefix="/approved-vendors", tags=["Approved AI Vendors"] ) +# Include custom frameworks router +api_router.include_router( + custom_frameworks.router, prefix="/custom-frameworks", tags=["Custom Frameworks"] +) + # Include BPO router api_router.include_router( bpo.router, prefix="/bpo", tags=["BPO Risk Management"] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 830cb30..a4d0234 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -46,6 +46,7 @@ TestResult, ) from app.models.base import Base, SoftDeleteMixin, TimestampMixin, UUIDMixin +from app.models.custom_framework import CustomControl, CustomFramework from app.models.chunk import DocumentChunk from app.models.document import Document, DocumentStatus, DocumentType, ProcessingStage from app.models.finding import AnalysisRun, Finding, FindingSeverity, FindingStatus @@ -138,6 +139,8 @@ "AuditAction", "AuditLog", "Base", + "CustomControl", + "CustomFramework", "ConversationThread", "Department", "Document", diff --git a/backend/app/models/custom_framework.py b/backend/app/models/custom_framework.py new file mode 100644 index 0000000..200813e --- /dev/null +++ b/backend/app/models/custom_framework.py @@ -0,0 +1,61 @@ +"""Custom compliance framework models.""" + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class CustomFramework(Base, UUIDMixin, TimestampMixin): + """User-defined compliance framework. + + Organisations can create their own frameworks with custom controls + and run AI analysis against them exactly like built-in frameworks. + """ + + __tablename__ = "custom_frameworks" + + organization_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + created_by: Mapped[str] = mapped_column(String(36), nullable=False) + + name: Mapped[str] = mapped_column(String(255), nullable=False) + version: Mapped[str] = mapped_column(String(50), nullable=False, default="1.0") + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + controls: Mapped[list["CustomControl"]] = relationship( + "CustomControl", + back_populates="framework", + cascade="all, delete-orphan", + order_by="CustomControl.order_index", + ) + + def __repr__(self) -> str: + return f"" + + +class CustomControl(Base, UUIDMixin, TimestampMixin): + """A single control within a custom framework.""" + + __tablename__ = "custom_controls" + + framework_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("custom_frameworks.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + control_id: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + guidance: Mapped[str | None] = mapped_column(Text, nullable=True) + order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + framework: Mapped["CustomFramework"] = relationship( + "CustomFramework", back_populates="controls" + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/schemas/custom_framework.py b/backend/app/schemas/custom_framework.py new file mode 100644 index 0000000..4e6a1f5 --- /dev/null +++ b/backend/app/schemas/custom_framework.py @@ -0,0 +1,102 @@ +"""Pydantic schemas for custom compliance frameworks.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class CustomControlCreate(BaseModel): + """Schema for creating a control.""" + + control_id: str = Field(..., min_length=1, max_length=50, description="Control identifier, e.g. CC-1") + name: str = Field(..., min_length=1, max_length=255, description="Control name") + description: str = Field(..., min_length=1, description="What must be true for this control to pass") + category: str | None = Field(None, max_length=100, description="Control category") + guidance: str | None = Field(None, description="Optional hints for the AI evaluator") + order_index: int = Field(0, ge=0, description="Display order within the framework") + + +class CustomControlUpdate(BaseModel): + """Schema for updating a control (all fields optional).""" + + control_id: str | None = Field(None, min_length=1, max_length=50) + name: str | None = Field(None, min_length=1, max_length=255) + description: str | None = Field(None, min_length=1) + category: str | None = None + guidance: str | None = None + order_index: int | None = Field(None, ge=0) + + +class CustomControlResponse(BaseModel): + """Schema for a control response.""" + + model_config = ConfigDict(from_attributes=True) + + id: str + framework_id: str + control_id: str + name: str + description: str + category: str | None + guidance: str | None + order_index: int + created_at: datetime + updated_at: datetime + + +class CustomFrameworkCreate(BaseModel): + """Schema for creating a custom framework.""" + + name: str = Field(..., min_length=1, max_length=255, description="Framework name") + version: str = Field("1.0", max_length=50, description="Framework version") + description: str | None = Field(None, description="Framework description") + + +class CustomFrameworkUpdate(BaseModel): + """Schema for updating a framework (all fields optional).""" + + name: str | None = Field(None, min_length=1, max_length=255) + version: str | None = Field(None, max_length=50) + description: str | None = None + is_active: bool | None = None + + +class CustomFrameworkResponse(BaseModel): + """Schema for a full framework response including controls.""" + + model_config = ConfigDict(from_attributes=True) + + id: str + organization_id: str + created_by: str + name: str + version: str + description: str | None + is_active: bool + controls: list[CustomControlResponse] = [] + created_at: datetime + updated_at: datetime + + +class CustomFrameworkSummary(BaseModel): + """Lightweight framework summary for list views.""" + + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + version: str + description: str | None + is_active: bool + control_count: int = 0 + created_at: datetime + updated_at: datetime + + +class CustomFrameworkListResponse(BaseModel): + """Paginated list of custom frameworks.""" + + data: list[CustomFrameworkSummary] + total: int + page: int + limit: int diff --git a/backend/app/schemas/finding.py b/backend/app/schemas/finding.py index f8191bf..a85b741 100644 --- a/backend/app/schemas/finding.py +++ b/backend/app/schemas/finding.py @@ -12,9 +12,18 @@ class AnalysisRequest(BaseModel): - """Request to trigger document analysis.""" - - framework: FrameworkType = Field(..., description="Compliance framework to analyze against") + """Request to trigger document analysis. + + Supply either `framework` (a built-in framework key) OR `custom_framework_id` + (the UUID of a custom framework). Exactly one must be provided. + """ + + framework: FrameworkType | None = Field( + None, description="Built-in compliance framework key" + ) + custom_framework_id: str | None = Field( + None, description="UUID of a custom framework to analyze against" + ) chunk_limit: int = Field(50, ge=1, le=200, description="Maximum chunks to analyze") diff --git a/backend/app/services/analysis.py b/backend/app/services/analysis.py index 99a6d49..3a27f90 100644 --- a/backend/app/services/analysis.py +++ b/backend/app/services/analysis.py @@ -24,7 +24,9 @@ get_framework_controls, get_framework_summary, ) +from app.models.custom_framework import CustomFramework from app.services.llm import SUPPORTED_FRAMEWORKS, get_llm_service +from sqlalchemy.orm import selectinload async def get_analysis_run_by_id( @@ -303,6 +305,7 @@ async def run_analysis( framework: str, chunk_limit: int = 50, focus_controls: list[str] | None = None, + custom_framework_id: str | None = None, ) -> AnalysisRun: """Run enhanced compliance analysis on a document. @@ -311,13 +314,17 @@ async def run_analysis( - Enhanced prompts for detailed finding generation - Page-specific citations from document chunks + When custom_framework_id is provided the analysis runs against the user-defined + controls from that framework instead of a built-in one. + Args: db: Database session document_id: Document to analyze org_id: Organization ID - framework: Compliance framework (e.g., 'soc2', 'iso27001') + framework: Compliance framework key or 'custom' chunk_limit: Maximum chunks to analyze focus_controls: Optional list of specific control IDs to focus on + custom_framework_id: UUID of a CustomFramework to use instead of built-in Returns: AnalysisRun with detailed findings @@ -325,8 +332,26 @@ async def run_analysis( Raises: ValueError: If document not found or analysis fails """ - # Validate framework - if framework not in SUPPORTED_FRAMEWORKS: + # Resolve custom framework or validate built-in + custom_fw = None + if custom_framework_id: + fw_result = await db.execute( + select(CustomFramework) + .where( + CustomFramework.id == custom_framework_id, + CustomFramework.organization_id == org_id, + CustomFramework.is_active == True, # noqa: E712 + ) + .options(selectinload(CustomFramework.controls)) + ) + custom_fw = fw_result.scalar_one_or_none() + if not custom_fw: + raise ValueError("Custom framework not found") + if not custom_fw.controls: + raise ValueError("Custom framework has no controls add at least one control before running analysis") + # Use a unique framework label so findings are traceable + framework = f"custom:{custom_fw.name}" + elif framework not in SUPPORTED_FRAMEWORKS: raise ValueError(f"Unsupported framework: {framework}") # Get document @@ -348,24 +373,36 @@ async def run_analysis( if not llm_service.is_configured: raise ValueError("LLM service not configured - check API keys") - # Load framework controls for validation and context - framework_summary = get_framework_summary(framework) - control_mapping = _get_framework_control_mapping(framework) - - # Get specific controls if focus_controls specified - controls_for_prompt = None - if focus_controls: + # Load framework controls for prompt context + if custom_fw: + framework_summary = None + control_mapping = {} + # Serialise custom controls into the same shape the prompt builder expects controls_for_prompt = [ - {"id": ctrl_id, **control_mapping[ctrl_id]} - for ctrl_id in focus_controls - if ctrl_id in control_mapping - ] - elif control_mapping: - # Include a sample of controls in the prompt for context - sample_controls = list(control_mapping.items())[:15] - controls_for_prompt = [ - {"id": ctrl_id, **details} for ctrl_id, details in sample_controls + { + "id": ctrl.control_id, + "name": ctrl.name, + "description": ctrl.description, + "category": ctrl.category or "", + "guidance": ctrl.guidance or "", + } + for ctrl in custom_fw.controls ] + else: + framework_summary = get_framework_summary(framework) + control_mapping = _get_framework_control_mapping(framework) + controls_for_prompt = None + if focus_controls: + controls_for_prompt = [ + {"id": ctrl_id, **control_mapping[ctrl_id]} + for ctrl_id in focus_controls + if ctrl_id in control_mapping + ] + elif control_mapping: + sample_controls = list(control_mapping.items())[:15] + controls_for_prompt = [ + {"id": ctrl_id, **details} for ctrl_id, details in sample_controls + ] # Create analysis run analysis_run = AnalysisRun( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1369c42..2a027df 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { MainLayout, AuthLayout } from '@/components/layout'; -import { Landing, Login, Register, Dashboard, Vendors, VendorDetail, Documents, Query, Analysis, Remediation, Monitoring, Agents, Risk, Analytics, Competition, Playbooks, ApprovedVendors, BPO, Integrations } from '@/pages'; +import { Landing, Login, Register, Dashboard, Vendors, VendorDetail, Documents, Query, Analysis, Remediation, Monitoring, Agents, Risk, Analytics, Competition, Playbooks, ApprovedVendors, BPO, Integrations, Frameworks } from '@/pages'; import { useAuthStore } from '@/stores/authStore'; import { ToastProvider } from '@/components/ui/toast'; @@ -88,6 +88,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ce884b5..c57099e 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -18,6 +18,7 @@ import { CheckCircle, Users, Link2, + Layers, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuthStore } from '@/stores/authStore'; @@ -41,6 +42,7 @@ const navigation: NavItem[] = [ { name: 'Vendors', href: '/vendors', icon: Building2 }, { name: 'Documents', href: '/documents', icon: FileText }, { name: 'Analysis', href: '/analysis', icon: Shield }, + { name: 'Frameworks', href: '/frameworks', icon: Layers }, { name: 'Remediation', href: '/remediation', icon: ClipboardList }, { name: 'Monitoring', href: '/monitoring', icon: Bell }, { name: 'Query', href: '/query', icon: MessageSquare }, diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index f506bb2..b488070 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -51,7 +51,18 @@ const FRAMEWORKS = [ { value: 'ai_risk', label: 'AI Risk Assessment' }, ] as const; -type FrameworkType = typeof FRAMEWORKS[number]['value']; +type BuiltinFrameworkType = typeof FRAMEWORKS[number]['value']; +// Allow built-in keys or a custom: prefixed key for display purposes +type FrameworkType = BuiltinFrameworkType | string; + +// Custom framework summary shape returned by /custom-frameworks +interface CustomFrameworkSummary { + id: string; + name: string; + version: string; + control_count: number; + is_active: boolean; +} /** * Error boundary to catch render crashes in findings components @@ -91,13 +102,23 @@ export function Analysis() { // Allow vendor/document pages to pre-select a document via ?document_id=... const documentIdParam = searchParams.get('document_id') ?? ''; + // Allow Frameworks page to pre-select a custom framework via ?custom_framework_id=... + const customFrameworkIdParam = searchParams.get('custom_framework_id') ?? ''; + const [selectedDocumentId, setSelectedDocumentId] = useState(documentIdParam); - const [selectedFramework, setSelectedFramework] = useState('soc2_tsc'); + // Selected value is either a built-in key (e.g. "soc2_tsc") or "custom:" for custom frameworks + const [selectedFramework, setSelectedFramework] = useState( + customFrameworkIdParam ? `custom:${customFrameworkIdParam}` : 'soc2_tsc' + ); - // Keep the selection in sync if the URL param changes (e.g. browser back/forward) + // Keep the selection in sync if the URL params change (e.g. browser back/forward) useEffect(() => { if (documentIdParam) setSelectedDocumentId(documentIdParam); }, [documentIdParam]); + + useEffect(() => { + if (customFrameworkIdParam) setSelectedFramework(`custom:${customFrameworkIdParam}`); + }, [customFrameworkIdParam]); const [analysisError, setAnalysisError] = useState(null); const [isExporting, setIsExporting] = useState<'csv' | 'pdf' | null>(null); @@ -151,6 +172,19 @@ export function Analysis() { (doc: Document) => doc.status === 'processed' || doc.status === 'analyzed' ); + // Fetch custom frameworks for the org + const { data: customFrameworksResponse } = useQuery({ + queryKey: ['custom-frameworks-for-analysis'], + queryFn: async () => { + const response = await apiClient.get('/custom-frameworks?limit=100'); + return response.data; + }, + }); + + const customFrameworks: CustomFrameworkSummary[] = (customFrameworksResponse?.data || []).filter( + (fw: CustomFrameworkSummary) => fw.is_active + ); + // Fetch findings for selected document using the correct endpoint const { data: findingsResponse, @@ -187,12 +221,18 @@ export function Analysis() { // Run analysis mutation - uses correct backend endpoint const runAnalysisMutation = useMutation({ mutationFn: async ({ documentId, framework }: { documentId: string; framework: FrameworkType }) => { - // POST /analysis/documents/{document_id}/analyze with { framework, chunk_limit } + // Determine if this is a custom framework (stored as "custom:") + const isCustom = framework.startsWith('custom:'); + const body = isCustom + ? { custom_framework_id: framework.replace('custom:', ''), chunk_limit: 50 } + : { framework, chunk_limit: 50 }; + // POST /analysis/documents/{document_id}/analyze // LLM analysis can take 60-180s depending on document size and provider, so use a long timeout - const response = await apiClient.post(`/analysis/documents/${documentId}/analyze`, { - framework, - chunk_limit: 50, - }, { timeout: 180000 }); + const response = await apiClient.post( + `/analysis/documents/${documentId}/analyze`, + body, + { timeout: 180000 } + ); return response.data; }, onSuccess: () => { @@ -315,11 +355,22 @@ export function Analysis() { onChange={(e) => setSelectedFramework(e.target.value as FrameworkType)} className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" > - {FRAMEWORKS.map((fw) => ( - - ))} + + {FRAMEWORKS.map((fw) => ( + + ))} + + {customFrameworks.length > 0 && ( + + {customFrameworks.map((fw) => ( + + ))} + + )}
@@ -431,7 +482,11 @@ export function Analysis() {

Analysis in Progress

- Analyzing document against {FRAMEWORKS.find(f => f.value === selectedFramework)?.label || selectedFramework}... + Analyzing document against { + selectedFramework.startsWith('custom:') + ? customFrameworks.find(f => `custom:${f.id}` === selectedFramework)?.name ?? 'Custom Framework' + : FRAMEWORKS.find(f => f.value === selectedFramework)?.label ?? selectedFramework + }...

This may take 1-2 minutes depending on document size. diff --git a/frontend/src/pages/Frameworks.tsx b/frontend/src/pages/Frameworks.tsx new file mode 100644 index 0000000..f12790f --- /dev/null +++ b/frontend/src/pages/Frameworks.tsx @@ -0,0 +1,735 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Layers, Trash2, Pencil, Play, X, ChevronDown, ChevronUp, Copy } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui'; +import apiClient, { getApiErrorMessage } from '@/lib/api'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface CustomControl { + id: string; + framework_id: string; + control_id: string; + name: string; + description: string; + category: string | null; + guidance: string | null; + order_index: number; + created_at: string; + updated_at: string; +} + +interface CustomFramework { + id: string; + organization_id: string; + created_by: string; + name: string; + version: string; + description: string | null; + is_active: boolean; + controls: CustomControl[]; + created_at: string; + updated_at: string; +} + +interface FrameworkSummary { + id: string; + name: string; + version: string; + description: string | null; + is_active: boolean; + control_count: number; + created_at: string; + updated_at: string; +} + +// Starter controls to pre-populate when cloning a built-in +const BUILTIN_STARTER_CONTROLS: Record> = { + soc2_tsc: [ + { control_id: 'CC1.1', name: 'Control Environment', description: 'The entity demonstrates a commitment to integrity and ethical values.', category: 'Common Criteria' }, + { control_id: 'CC2.1', name: 'Communication of Objectives', description: 'Management communicates information relevant to meeting objectives.', category: 'Common Criteria' }, + { control_id: 'CC6.1', name: 'Logical Access Controls', description: 'Logical access security software and infrastructure protect against threats.', category: 'Security' }, + ], + nist_800_53: [ + { control_id: 'AC-1', name: 'Access Control Policy', description: 'Develop, document, and disseminate an access control policy.', category: 'Access Control' }, + { control_id: 'AU-2', name: 'Audit Events', description: 'Identify the types of events that the system is capable of logging.', category: 'Audit & Accountability' }, + { control_id: 'IR-4', name: 'Incident Handling', description: 'Implement an incident handling capability for security incidents.', category: 'Incident Response' }, + ], + iso_27001: [ + { control_id: 'A.5.1', name: 'Information Security Policies', description: 'Policies for information security shall be defined and approved by management.', category: 'Policies' }, + { control_id: 'A.9.1', name: 'Access Control Policy', description: 'An access control policy shall be established, documented and reviewed.', category: 'Access Control' }, + { control_id: 'A.12.1', name: 'Operational Procedures', description: 'Operating procedures shall be documented and made available to users.', category: 'Operations' }, + ], +}; + +const BUILTIN_LABELS: Record = { + soc2_tsc: 'SOC 2 TSC', + nist_800_53: 'NIST 800-53', + iso_27001: 'ISO 27001', + cis_controls: 'CIS Controls', + hipaa: 'HIPAA', + pci_dss: 'PCI-DSS', +}; + +// ── Empty blank control for the add-control form ───────────────────────────── +const BLANK_CONTROL = { control_id: '', name: '', description: '', category: '', guidance: '', order_index: 0 }; + +// ── Animation variants ─────────────────────────────────────────────────────── +const container = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.08 } } }; +const item = { hidden: { y: 16, opacity: 0 }, visible: { y: 0, opacity: 1, transition: { type: 'spring' as const, stiffness: 100 } } }; + +// ── Component ──────────────────────────────────────────────────────────────── + +export function Frameworks() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + // Which framework is open for editing (null = list view) + const [editingId, setEditingId] = useState(null); + // Show create form + const [showCreate, setShowCreate] = useState(false); + // Create form state + const [createForm, setCreateForm] = useState({ name: '', version: '1.0', description: '' }); + const [createError, setCreateError] = useState(null); + // Clone modal + const [showClone, setShowClone] = useState(false); + const [cloneTarget, setCloneTarget] = useState('soc2_tsc'); + // Control form + const [controlForm, setControlForm] = useState({ ...BLANK_CONTROL }); + const [editingControlId, setEditingControlId] = useState(null); + const [controlError, setControlError] = useState(null); + const [expandedControls, setExpandedControls] = useState(true); + + // ── Queries ──────────────────────────────────────────────────────────────── + + const { data: listData, isLoading } = useQuery({ + queryKey: ['custom-frameworks'], + queryFn: async () => { + const res = await apiClient.get('/custom-frameworks'); + return res.data; + }, + }); + + const { data: detailData } = useQuery({ + queryKey: ['custom-framework', editingId], + queryFn: async () => { + const res = await apiClient.get(`/custom-frameworks/${editingId}`); + return res.data as CustomFramework; + }, + enabled: !!editingId, + }); + + const frameworks: FrameworkSummary[] = listData?.data || []; + const detail: CustomFramework | undefined = detailData; + + // ── Mutations ────────────────────────────────────────────────────────────── + + const createMutation = useMutation({ + mutationFn: async (payload: typeof createForm) => { + const res = await apiClient.post('/custom-frameworks', payload); + return res.data as CustomFramework; + }, + onSuccess: (fw) => { + queryClient.invalidateQueries({ queryKey: ['custom-frameworks'] }); + setShowCreate(false); + setCreateForm({ name: '', version: '1.0', description: '' }); + setEditingId(fw.id); + }, + onError: (e) => setCreateError(getApiErrorMessage(e)), + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/custom-frameworks/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['custom-frameworks'] }); + if (editingId) setEditingId(null); + }, + }); + + const updateFrameworkMutation = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: Partial }) => { + const res = await apiClient.patch(`/custom-frameworks/${id}`, payload); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['custom-frameworks'] }); + queryClient.invalidateQueries({ queryKey: ['custom-framework', editingId] }); + }, + }); + + const addControlMutation = useMutation({ + mutationFn: async (payload: typeof controlForm) => { + const res = await apiClient.post(`/custom-frameworks/${editingId}/controls`, payload); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['custom-framework', editingId] }); + setControlForm({ ...BLANK_CONTROL }); + setControlError(null); + }, + onError: (e) => setControlError(getApiErrorMessage(e)), + }); + + const updateControlMutation = useMutation({ + mutationFn: async ({ controlId, payload }: { controlId: string; payload: Partial }) => { + const res = await apiClient.patch(`/custom-frameworks/${editingId}/controls/${controlId}`, payload); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['custom-framework', editingId] }); + setEditingControlId(null); + setControlForm({ ...BLANK_CONTROL }); + setControlError(null); + }, + onError: (e) => setControlError(getApiErrorMessage(e)), + }); + + const deleteControlMutation = useMutation({ + mutationFn: async (controlId: string) => { + await apiClient.delete(`/custom-frameworks/${editingId}/controls/${controlId}`); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['custom-framework', editingId] }), + }); + + // ── Handlers ─────────────────────────────────────────────────────────────── + + const handleCloneApply = () => { + const starters = BUILTIN_STARTER_CONTROLS[cloneTarget] || []; + starters.forEach((ctrl, i) => { + addControlMutation.mutate({ ...BLANK_CONTROL, ...ctrl, order_index: i }); + }); + setShowClone(false); + }; + + const handleControlSubmit = () => { + if (!controlForm.control_id.trim() || !controlForm.name.trim() || !controlForm.description.trim()) { + setControlError('Control ID, Name, and Description are required.'); + return; + } + if (editingControlId) { + updateControlMutation.mutate({ controlId: editingControlId, payload: controlForm }); + } else { + addControlMutation.mutate(controlForm); + } + }; + + const startEditControl = (ctrl: CustomControl) => { + setEditingControlId(ctrl.id); + setControlForm({ + control_id: ctrl.control_id, + name: ctrl.name, + description: ctrl.description, + category: ctrl.category || '', + guidance: ctrl.guidance || '', + order_index: ctrl.order_index, + }); + setControlError(null); + }; + + const cancelControlEdit = () => { + setEditingControlId(null); + setControlForm({ ...BLANK_CONTROL }); + setControlError(null); + }; + + // ── Render ───────────────────────────────────────────────────────────────── + + // Detail / edit view + if (editingId && detail) { + return ( + + {/* Header */} + + +

+

+ EDIT FRAMEWORK +

+

{detail.name} · v{detail.version} · {detail.controls.length} controls

+
+
+ + +
+ + + {/* Framework details */} + + + + + Framework Details + + + + updateFrameworkMutation.mutate({ id: detail.id, payload })} + isSaving={updateFrameworkMutation.isPending} + /> + + + + + {/* Controls */} + + + +
+ + + Controls ({detail.controls.length}) + + + +
+
+ + + {expandedControls && ( + + + {/* Controls table */} + {detail.controls.length > 0 && ( +
+ + + + + + + + + + + + {detail.controls.map((ctrl) => ( + + + + + + + + ))} + +
IDNameCategoryDescriptionActions
{ctrl.control_id}{ctrl.name} + {ctrl.category && ( + + {ctrl.category} + + )} + {ctrl.description} +
+ + +
+
+
+ )} + + {/* Add / edit control form */} +
+

+ {editingControlId ? 'Edit Control' : 'Add Control'} +

+
+
+ + setControlForm({ ...controlForm, control_id: e.target.value })} + /> +
+
+ + setControlForm({ ...controlForm, category: e.target.value })} + /> +
+
+
+ + setControlForm({ ...controlForm, name: e.target.value })} + /> +
+
+ +