From 1c6f520ceecbbca6d673ba5040c96b9add4d3c45 Mon Sep 17 00:00:00 2001 From: Vandan Panwala Date: Sat, 14 Mar 2026 12:46:10 +0530 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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",