Compare commits

..

10 Commits

Author SHA1 Message Date
Yunxiao Xu
2c44df3a5c fix(frontend): update theme context usage
- Add theme-context.tsx
- Update ThemeToggle.tsx to use ThemeContext
2026-02-21 08:32:35 -08:00
Yunxiao Xu
7be24d8884 fix(frontend): resolve build errors
- Fix undefined loadHistory in App.tsx
- Use type-only import for Theme in theme-provider.tsx
- Update auth.ts to import Theme from theme-context
2026-02-21 08:30:27 -08:00
Yunxiao Xu
5d8ecdc8e9 chore(release): bump frontend version to 0.1.0 2026-02-20 17:22:05 -08:00
Yunxiao Xu
b4f79ee052 docs: update project documentation and verification strategies
- Update GEMINI.md with verification steps and remove ignored docs reference
- Update README.md to remove reference to local langchain-docs
- Update backend/GEMINI.md with correct database schema (users table) and architecture details
- Update frontend/GEMINI.md with latest project structure
2026-02-20 17:14:16 -08:00
Yunxiao Xu
cc927e2a90 fix(auth): Resolve lint regressions and add security regression test 2026-02-18 14:56:17 -08:00
Yunxiao Xu
f5aeb9d956 fix(auth): Address high and medium priority security and build findings 2026-02-18 14:50:09 -08:00
Yunxiao Xu
6131f27142 refactor: Address technical debt in auth refresh implementation 2026-02-18 14:36:10 -08:00
Yunxiao Xu
341bd08176 docs: Update .env.example with new JWT refresh settings 2026-02-18 14:11:28 -08:00
Yunxiao Xu
7cc34ceb0b feat(frontend): Implement reactive refresh interceptor 2026-02-18 13:52:02 -08:00
Yunxiao Xu
5adc826cfb feat(frontend): Implement silent refresh logic 2026-02-18 13:43:37 -08:00
19 changed files with 371 additions and 200 deletions

View File

@@ -43,9 +43,22 @@ The frontend is a modern SPA (Single Page Application) designed for data-heavy i
- **Real-time Visualization**: Supports streaming text responses and immediate rendering of base64-encoded or binary-retrieved analysis plots. - **Real-time Visualization**: Supports streaming text responses and immediate rendering of base64-encoded or binary-retrieved analysis plots.
## Documentation ## Documentation
- **[README](./README.md)**: Main project documentation and setup guide.
- **[Backend Guide](./backend/GEMINI.md)**: Detailed information about the backend architecture, migration goals, and implementation steps. - **[Backend Guide](./backend/GEMINI.md)**: Detailed information about the backend architecture, migration goals, and implementation steps.
- **[Frontend Guide](./frontend/GEMINI.md)**: Frontend development guide and technology stack. - **[Frontend Guide](./frontend/GEMINI.md)**: Frontend development guide and technology stack.
- **LangChain Docs**: See the `langchain-docs/` folder for local LangChain and LangGraph documentation.
## Verification Strategy
When making changes, always verify using the following commands:
### Backend
- **Test**: `cd backend && uv run pytest`
- **Lint/Format**: `cd backend && uv run ruff check .`
- **Type Check**: `cd backend && uv run mypy .` (if configured)
### Frontend
- **Test**: `cd frontend && npm run test`
- **Lint**: `cd frontend && npm run lint`
- **Build**: `cd frontend && npm run build` (to ensure no compilation errors)
## Git Operations ## Git Operations
- All new feature and bug-fix branches must be created from the `develop` branch except hot-fix. - All new feature and bug-fix branches must be created from the `develop` branch except hot-fix.

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# Election Analytics Chatbot
A stateful, graph-based chatbot for election data analysis, built with LangGraph, FastAPI, and React.
## 🚀 Features
- **Intelligent Query Analysis**: Automatically determines if a query needs data analysis, web research, or clarification.
- **Automated Data Analysis**: Generates and executes Python code to analyze election datasets and produce visualizations.
- **Web Research**: Integrates web search capabilities for general election-related questions.
- **Stateful Conversations**: Maintains context across multiple turns using LangGraph's persistent checkpointing.
- **Real-time Streaming**: Streams reasoning steps, code execution outputs, and plots to the UI.
- **Secure Authentication**: Traditional login and OIDC/SSO support with HttpOnly cookies.
- **History Management**: Persistent storage and management of chat history and generated artifacts.
## 🏗️ Project Structure
- `backend/`: Python FastAPI application using LangGraph.
- `frontend/`: React SPA built with TypeScript, Vite, and Tailwind CSS.
## 🛠️ Prerequisites
- Python 3.11+
- Node.js 18+
- PostgreSQL
- Docker (optional, for Postgres/PgAdmin)
- API Keys: OpenAI/Google Gemini, Google Search (if using research tools).
## 📥 Getting Started
### Backend Setup
1. Navigate to the backend directory:
```bash
cd backend
```
2. Install dependencies:
```bash
uv sync
```
3. Set up environment variables:
```bash
cp .env.example .env
# Edit .env with your configuration and API keys
```
4. Run database migrations:
```bash
uv run alembic upgrade head
```
5. Start the server:
```bash
uv run python -m ea_chatbot.api.main
```
### Frontend Setup
1. Navigate to the frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
## 📖 Documentation
- **[Top-level GEMINI.md](./GEMINI.md)**: General project overview.
- **[Backend Guide](./backend/GEMINI.md)**: Detailed backend architecture and implementation details.
- **[Frontend Guide](./frontend/GEMINI.md)**: Frontend development guide and technology stack.
## 📜 License
This project is licensed under the MIT License - see the LICENSE file for details.

View File

@@ -8,11 +8,13 @@ DATA_STATE=new_jersey
LOG_LEVEL=INFO LOG_LEVEL=INFO
DEV_MODE=true DEV_MODE=true
FRONTEND_URL=http://localhost:5173 FRONTEND_URL=http://localhost:5173
API_V1_STR=/api/v1
# Security & JWT Configuration # Security & JWT Configuration
SECRET_KEY=change-me-in-production SECRET_KEY=change-me-in-production
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30 ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# Voter Database Configuration # Voter Database Configuration
DB_HOST=localhost DB_HOST=localhost

View File

@@ -1,162 +1,63 @@
# Election Analytics Chatbot - Backend Guide # Election Analytics Chatbot - Backend Guide
## Overview ## Overview
This document serves as a guide for the backend implementation of the Election Analytics Chatbot, specifically focusing on the transition from the "BambooAI" based system to a modern, stateful, and graph-based architecture using **LangGraph**. The backend is a Python-based FastAPI application that leverages **LangGraph** to provide a stateful, agentic workflow for election data analysis. It handles complex queries by decomposing them into tasks such as data analysis, web research, or user clarification.
## 1. Migration Goals ## 1. Architecture Overview
- **Framework Switch**: Move from the custom linear `ChatBot` class (in `src/ea_chatbot/bambooai/core/chatbot.py`) to `LangGraph`. - **Framework**: LangGraph for workflow orchestration and state management.
- **State Management**: explicit state management using LangGraph's `StateGraph`. - **API**: FastAPI for providing REST and streaming (SSE) endpoints.
- **Modularity**: Break down monolithic methods (`pd_agent_converse`, `execute_code`) into distinct Nodes. - **State Management**: Persistent state using LangGraph's `StateGraph` with a PostgreSQL checkpointer.
- **Observability**: Easier debugging of the decision process (Routing -> Planning -> Coding -> Executing). - **Database**: PostgreSQL.
- Application data: Uses `users` table for local and OIDC users (String IDs).
- History: Persists chat history and artifacts.
- Election Data: Structured datasets for analysis.
## 2. Architecture Proposal ## 2. Core Components
### 2.1. The Graph State ### 2.1. The Graph State (`src/ea_chatbot/graph/state.py`)
The state will track the conversation and execution context. The state tracks the conversation context, plan, generated code, execution results, and artifacts.
```python
from typing import TypedDict, Annotated, List, Dict, Any, Optional
from langchain_core.messages import BaseMessage
import operator
class AgentState(TypedDict):
# Conversation history
messages: Annotated[List[BaseMessage], operator.add]
# Task context
question: str
# Query Analysis (Decomposition results)
analysis: Optional[Dict[str, Any]]
# Expected keys: "requires_dataset", "expert", "data", "unknown", "condition"
# Step-by-step reasoning
plan: Optional[str]
# Code execution context
code: Optional[str]
code_output: Optional[str]
error: Optional[str]
# Artifacts (for UI display)
plots: List[Figure] # Matplotlib figures
dfs: Dict[str, DataFrame] # Pandas DataFrames
# Control flow
iterations: int
next_action: str # Routing hint: "clarify", "plan", "research", "end"
```
### 2.2. Nodes (The Actors) ### 2.2. Nodes (The Actors)
We will map existing logic to these nodes: Located in `src/ea_chatbot/graph/nodes/`:
1. **`query_analyzer_node`** (Router & Refiner): - **`query_analyzer`**: Analyzes the user query to determine the intent and required data.
* **Logic**: Replaces `Expert Selector` and `Analyst Selector`. - **`planner`**: Creates a step-by-step plan for data analysis.
* **Function**: - **`coder`**: Generates Python code based on the plan and dataset metadata.
1. Decomposes the user's query into key elements (Data, Unknowns, Conditions). - **`executor`**: Safely executes the generated code and captures outputs (dataframes, plots).
2. Determines if the query is ambiguous or missing critical information. - **`error_corrector`**: Fixes code if execution fails.
* **Output**: Updates `messages`. Returns routing decision: - **`researcher`**: Performs web searches for general election information.
* `clarification_node` (if ambiguous). - **`summarizer`**: Generates a natural language response based on the analysis results.
* `planner_node` (if clear data task). - **`clarification`**: Asks the user for more information if the query is ambiguous.
* `researcher_node` (if general/web task).
2. **`clarification_node`** (Human-in-the-loop):
* **Logic**: Replaces `Theorist-Clarification`.
* **Function**: Formulates a specific question to ask the user for missing details.
* **Output**: Returns a message to the user and **interrupts** the graph execution to await user input.
3. **`researcher_node`** (Theorist):
* **Logic**: Handles general queries or web searches.
* **Function**: Uses `GoogleSearch` tool if necessary.
* **Output**: Final answer.
4. **`planner_node`**:
* **Logic**: Replaces `Planner`.
* **Function**: Generates a step-by-step plan based on the decomposed query elements and dataframe ontology.
* **Output**: Updates `plan`.
5. **`coder_node`**:
* **Logic**: Replaces `Code Generator` & `Error Corrector`.
* **Function**: Generates Python code. If `error` exists in state, it attempts to fix it.
* **Output**: Updates `code`.
6. **`executor_node`**:
* **Logic**: Replaces `Code Executor`.
* **Function**: Executes the Python code in a safe(r) environment. It needs access to the `DBClient`.
* **Output**: Updates `code_output`, `plots`, `dfs`. If exception, updates `error`.
7. **`summarizer_node`**:
* **Logic**: Replaces `Solution Summarizer`.
* **Function**: Interprets the code output and generates a natural language response.
* **Output**: Final response message.
### 2.3. The Workflow (Graph) ### 2.3. The Workflow (Graph)
The graph connects these nodes with conditional edges, allowing for iterative refinement and error correction.
```mermaid ## 3. Key Modules
graph TD
Start --> QueryAnalyzer - **`src/ea_chatbot/api/`**: Contains FastAPI routers for authentication, conversation management, and the agent streaming endpoint.
QueryAnalyzer -->|Ambiguous| Clarification - **`src/ea_chatbot/graph/`**: Core LangGraph logic, including state definitions, node implementations, and the workflow graph.
Clarification -->|User Input| QueryAnalyzer - **`src/ea_chatbot/history/`**: Manages persistent chat history and message mapping between application models and LangGraph state.
QueryAnalyzer -->|General/Web| Researcher - **`src/ea_chatbot/utils/`**: Utility functions for database inspection, LLM factory, and logging.
QueryAnalyzer -->|Data Analysis| Planner
Planner --> Coder ## 4. Development & Execution
Coder --> Executor
Executor -->|Success| Summarizer ### Entry Point
Executor -->|Error| Coder The main entry point for the API is `src/ea_chatbot/api/main.py`.
Researcher --> End
Summarizer --> End ### Running the API
```bash
cd backend
uv run python -m ea_chatbot.api.main
``` ```
## 3. Implementation Steps ### Database Migrations
Handled by Alembic.
### Step 1: Dependencies ```bash
Add the following packages to `pyproject.toml`: uv run alembic upgrade head
* `langgraph`
* `langchain`
* `langchain-openai`
* `langchain-google-genai`
* `langchain-community`
### Step 2: Directory Structure
Create a new package for the graph logic to keep it separate from the old one during migration.
```
src/ea_chatbot/
├── graph/
│ ├── __init__.py
│ ├── state.py # State definition
│ ├── nodes/ # Individual node implementations
│ │ ├── __init__.py
│ │ ├── router.py
│ │ ├── planner.py
│ │ ├── coder.py
│ │ ├── executor.py
│ │ └── ...
│ ├── workflow.py # Graph construction
│ └── tools/ # DB and Search tools wrapped for LangChain
└── ...
``` ```
### Step 3: Tool Wrapping ### Testing
Wrap the existing `DBClient` (from `src/ea_chatbot/bambooai/utils/db_client.py`) into a structure accessible by the `executor_node`. The `executor_node` will likely keep the existing `exec()` based approach initially for compatibility with the generated code, but structured as a graph node. Tests are located in the `tests/` directory and use `pytest`.
```bash
### Step 4: Prompt Migration uv run pytest
Port the prompts from `data/PROMPT_TEMPLATES.json` or `src/ea_chatbot/bambooai/prompts/strings.py` into the respective nodes. Use LangChain's `ChatPromptTemplate` for better management. ```
### Step 5: Integration
Update `src/ea_chatbot/app.py` to use the new `workflow.compile()` runnable.
* Instead of `chatbot.pd_agent_converse(...)`, use `app.stream(...)` (LangGraph app).
* Handle the streaming output to update the UI progressively.
## 4. Key Considerations for Refactoring
* **Database Connection**: Ensure `DBClient` is initialized once and passed to the `Executor` node efficiently (e.g., via `configurable` parameters or closure).
* **Prompt Templating**: The current system uses simple `format` strings. Switching to LangChain templates allows for easier model switching and partial formatting.
* **Token Management**: LangGraph provides built-in tracing (if LangSmith is enabled), but we should ensure the `OutputManager` logic (printing costs/tokens) is preserved or adapted if still needed for the CLI/Logs.
* **Vector DB**: The current system has `PineconeWrapper` for RAG. This should be integrated into the `Planner` or `Coder` node to fetch few-shot examples or context.
## 5. Next Actions
1. **Initialize**: Create the folder structure.
2. **Define State**: Create `src/ea_chatbot/graph/state.py`.
3. **Implement Router**: Create the first node to replicate `Expert Selector` logic.
4. **Implement Executor**: Port the `exec()` logic to a node.

View File

@@ -40,6 +40,14 @@ async def get_current_user(request: Request, token: str = Depends(oauth2_scheme)
if payload is None: if payload is None:
raise credentials_exception raise credentials_exception
# Security Fix: Reject refresh tokens for standard API access
if payload.get("type") == "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Cannot use refresh token for this endpoint",
headers={"WWW-Authenticate": "Bearer"},
)
user_id: str | None = payload.get("sub") user_id: str | None = payload.get("sub")
if user_id is None: if user_id is None:
raise credentials_exception raise credentials_exception

View File

@@ -2,7 +2,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Response, Request from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from ea_chatbot.api.utils import create_access_token, create_refresh_token, settings from ea_chatbot.api.utils import create_access_token, create_refresh_token, decode_access_token, settings
from ea_chatbot.api.dependencies import history_manager, oidc_client, get_current_user from ea_chatbot.api.dependencies import history_manager, oidc_client, get_current_user
from ea_chatbot.history.models import User as UserDB from ea_chatbot.history.models import User as UserDB
from ea_chatbot.api.schemas import Token, UserCreate, UserResponse, ThemeUpdate from ea_chatbot.api.schemas import Token, UserCreate, UserResponse, ThemeUpdate
@@ -101,7 +101,6 @@ async def refresh(request: Request, response: Response):
detail="Refresh token missing" detail="Refresh token missing"
) )
from ea_chatbot.api.utils import decode_access_token # Using decode_access_token for both
payload = decode_access_token(refresh_token) payload = decode_access_token(refresh_token)
if not payload: if not payload:

View File

@@ -1,3 +1,4 @@
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, Any, List from typing import Optional, Any, List
from jose import JWTError, jwt from jose import JWTError, jwt
@@ -56,7 +57,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
to_encode.update({ to_encode.update({
"exp": expire, "exp": expire,
"iat": now, "iat": now,
"iss": "ea-chatbot-api" "iss": "ea-chatbot-api",
"type": "access",
"jti": str(uuid.uuid4())
}) })
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt return encoded_jwt
@@ -84,7 +87,8 @@ def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None)
"exp": expire, "exp": expire,
"iat": now, "iat": now,
"iss": "ea-chatbot-api", "iss": "ea-chatbot-api",
"type": "refresh" "type": "refresh",
"jti": str(uuid.uuid4())
}) })
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt return encoded_jwt

View File

@@ -18,11 +18,17 @@ def test_refresh_token_success(client):
# 2. Set the cookie manually in the client # 2. Set the cookie manually in the client
client.cookies.set("refresh_token", refresh_token) client.cookies.set("refresh_token", refresh_token)
import time
time.sleep(1.1) # Wait to ensure iat is different
# 3. Call the refresh endpoint with mock # 3. Call the refresh endpoint with mock
with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm: with patch("ea_chatbot.api.routers.auth.history_manager") as mock_hm, \
patch("ea_chatbot.api.utils.datetime") as mock_datetime:
# Mock datetime to ensure the second token has a different timestamp
from datetime import datetime, timezone, timedelta
base_now = datetime.now(timezone.utc)
# First call (inside refresh) gets base_now + 1 second
mock_datetime.now.return_value = base_now + timedelta(seconds=1)
mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs)
mock_hm.get_user_by_id.return_value = User(id=user_id, username="test@test.com") mock_hm.get_user_by_id.return_value = User(id=user_id, username="test@test.com")
response = client.post("/api/v1/auth/refresh") response = client.post("/api/v1/auth/refresh")
@@ -54,3 +60,17 @@ def test_refresh_token_wrong_type(client):
response = client.post("/api/v1/auth/refresh") response = client.post("/api/v1/auth/refresh")
assert response.status_code == 401 assert response.status_code == 401
assert response.json()["detail"] == "Invalid token type" assert response.json()["detail"] == "Invalid token type"
def test_protected_endpoint_rejects_refresh_token(client):
"""Regression test: Ensure refresh tokens cannot be used to access protected endpoints."""
user_id = "test-user-id"
refresh_token = create_refresh_token({"sub": user_id})
# Attempt to access /auth/me with a refresh token in the cookie
client.cookies.set("access_token", refresh_token)
response = client.get("/api/v1/auth/me")
# Should be rejected with 401
assert response.status_code == 401
assert "Cannot use refresh token for this endpoint" in response.json()["detail"]

View File

@@ -15,11 +15,13 @@ This document serves as a guide for the frontend implementation of the Election
## Project Structure ## Project Structure
- `src/components/`: - `src/components/`:
- `auth/`: Login, Registration, and OIDC callback forms/pages. - `auth/`: Login, Registration, and OIDC callback forms/pages.
- `chat/`: Core chat interface components, including message list and plot rendering.
- `layout/`: Main application layout including the sidebar navigation. - `layout/`: Main application layout including the sidebar navigation.
- `ui/`: Reusable primitive components (buttons, cards, inputs, etc.) via Shadcn. - `ui/`: Reusable primitive components (buttons, cards, inputs, etc.) via Shadcn.
- `src/services/`: - `src/services/`:
- `api.ts`: Axios instance configuration with `/api/v1` base URL and interceptors. - `api.ts`: Axios instance configuration with `/api/v1` base URL and interceptors.
- `auth.ts`: Authentication logic (Login, Logout, OIDC, User Profile). - `auth.ts`: Authentication logic (Login, Logout, OIDC, User Profile).
- `chat.ts`: Service for interacting with the agent streaming endpoint.
- `src/lib/`: - `src/lib/`:
- `validations/`: Zod schemas for form validation. - `validations/`: Zod schemas for form validation.
- `utils.ts`: Core utility functions. - `utils.ts`: Core utility functions.
@@ -42,3 +44,7 @@ The frontend communicates with the backend's `/api/v1` endpoints:
- `npm run dev`: Start development server. - `npm run dev`: Start development server.
- `npm run build`: Build for production. - `npm run build`: Build for production.
- `npm run test`: Run Vitest unit tests. - `npm run test`: Run Vitest unit tests.
## Documentation
- **[README](../README.md)**: Main project documentation and setup guide.
- **[Backend Guide](../backend/GEMINI.md)**: Backend implementation details.

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -9,7 +9,9 @@ import { ChatService, type MessageResponse } from "./services/chat"
import { type Conversation } from "./components/layout/HistorySidebar" import { type Conversation } from "./components/layout/HistorySidebar"
import { registerUnauthorizedCallback } from "./services/api" import { registerUnauthorizedCallback } from "./services/api"
import { Button } from "./components/ui/button" import { Button } from "./components/ui/button"
import { ThemeProvider, useTheme } from "./components/theme-provider" import { ThemeProvider } from "./components/theme-provider"
import { useTheme } from "./components/theme-context"
import { useSilentRefresh } from "./hooks/use-silent-refresh"
// --- Auth Context --- // --- Auth Context ---
interface AuthContextType { interface AuthContextType {
@@ -52,12 +54,23 @@ function AppContent() {
const { setThemeLocal } = useTheme() const { setThemeLocal } = useTheme()
const { isAuthenticated, setIsAuthenticated, user, setUser } = useAuth() const { isAuthenticated, setIsAuthenticated, user, setUser } = useAuth()
useSilentRefresh(isAuthenticated)
const [authMode, setAuthMode] = useState<"login" | "register">("login") const [authMode, setAuthMode] = useState<"login" | "register">("login")
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null) const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
const [conversations, setConversations] = useState<Conversation[]>([]) const [conversations, setConversations] = useState<Conversation[]>([])
const [threadMessages, setThreadMessages] = useState<Record<string, MessageResponse[]>>({}) const [threadMessages, setThreadMessages] = useState<Record<string, MessageResponse[]>>({})
const loadHistory = useCallback(async () => {
try {
const history = await ChatService.listConversations()
setConversations(history)
} catch (err) {
console.error("Failed to load conversation history:", err)
}
}, [])
useEffect(() => { useEffect(() => {
registerUnauthorizedCallback(() => { registerUnauthorizedCallback(() => {
setIsAuthenticated(false) setIsAuthenticated(false)
@@ -86,16 +99,7 @@ function AppContent() {
} }
initAuth() initAuth()
}, [setIsAuthenticated, setThemeLocal, setUser]) }, [setIsAuthenticated, setThemeLocal, setUser, loadHistory])
const loadHistory = useCallback(async () => {
try {
const history = await ChatService.listConversations()
setConversations(history)
} catch (err) {
console.error("Failed to load conversation history:", err)
}
}, [])
const handleAuthSuccess = useCallback(async () => { const handleAuthSuccess = useCallback(async () => {
try { try {

View File

@@ -1,6 +1,6 @@
import { Moon, Sun } from "lucide-react" import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useTheme } from "@/components/theme-provider" import { useTheme } from "@/components/theme-context"
export function ThemeToggle() { export function ThemeToggle() {
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()

View File

@@ -0,0 +1,20 @@
import { createContext, useContext } from "react"
export type Theme = "light" | "dark"
export interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
setThemeLocal: (theme: Theme) => void
toggleTheme: () => void
}
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export const useTheme = () => {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}

View File

@@ -1,16 +1,6 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from "react" import { useEffect, useState, useCallback, useMemo } from "react"
import { AuthService } from "@/services/auth" import { AuthService } from "@/services/auth"
import { type Theme, ThemeContext } from "./theme-context"
export type Theme = "light" | "dark"
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
setThemeLocal: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ export function ThemeProvider({
children, children,
@@ -61,11 +51,3 @@ export function ThemeProvider({
</ThemeContext.Provider> </ThemeContext.Provider>
) )
} }
export const useTheme = () => {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useCallback, useRef } from 'react'
import { AuthService } from '@/services/auth'
/**
* Hook to handle silent token refresh in the background.
* It proactively refreshes the session to prevent expiration while the user is active.
*/
export function useSilentRefresh(isAuthenticated: boolean) {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const refresh = useCallback(async () => {
try {
console.debug('[Auth] Proactively refreshing session...')
await AuthService.refreshSession()
console.debug('[Auth] Silent refresh successful.')
} catch (error) {
console.warn('[Auth] Silent refresh failed:', error)
// We don't force logout here; the reactive interceptor in api.ts
// will handle it if a subsequent data request returns a 401.
}
}, [])
useEffect(() => {
if (!isAuthenticated) {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
return
}
// Refresh every 25 minutes (access token defaults to 30 mins)
const REFRESH_INTERVAL = 25 * 60 * 1000
// Clear existing timer if any
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = setInterval(refresh, REFRESH_INTERVAL)
return () => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
}
}, [isAuthenticated, refresh])
}

View File

@@ -4,7 +4,6 @@ import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { TooltipProvider } from "@/components/ui/tooltip" import { TooltipProvider } from "@/components/ui/tooltip"
import { BrowserRouter } from "react-router-dom" import { BrowserRouter } from "react-router-dom"
import { ThemeProvider } from "./components/theme-provider"
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@@ -1,4 +1,4 @@
import axios from "axios" import axios, { type InternalAxiosRequestConfig } from "axios"
const API_URL = import.meta.env.VITE_API_URL || "" const API_URL = import.meta.env.VITE_API_URL || ""
@@ -7,29 +7,98 @@ const api = axios.create({
withCredentials: true, // Crucial for HttpOnly cookies withCredentials: true, // Crucial for HttpOnly cookies
}) })
// Optional callback for unauthorized errors // Optional callback for unauthorized errors (fallback if refresh fails)
let onUnauthorized: (() => void) | null = null let onUnauthorized: (() => void) | null = null
export const registerUnauthorizedCallback = (callback: () => void) => { export const registerUnauthorizedCallback = (callback: () => void) => {
onUnauthorized = callback onUnauthorized = callback
} }
// State to manage multiple concurrent refreshes
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []
let refreshErrorSubscribers: ((error: unknown) => void)[] = []
const subscribeTokenRefresh = (onSuccess: (token: string) => void, onError: (error: unknown) => void) => {
refreshSubscribers.push(onSuccess)
refreshErrorSubscribers.push(onError)
}
const onRefreshed = (token: string) => {
refreshSubscribers.forEach((callback) => callback(token))
refreshSubscribers = []
refreshErrorSubscribers = []
}
const onRefreshFailed = (error: unknown) => {
refreshErrorSubscribers.forEach((callback) => callback(error))
refreshSubscribers = []
refreshErrorSubscribers = []
}
// Add a response interceptor to handle 401s // Add a response interceptor to handle 401s
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
// Only handle if it's not an auth endpoint const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// This prevents loops during bootstrap and allows login form to show errors
const isAuthEndpoint = /^\/auth\//.test(error.config?.url)
if (error.response?.status === 401 && !isAuthEndpoint) { // Only handle 401 if it's not already a retry and not an auth endpoint
// Unauthorized - session likely expired on a protected data route // We allow /auth/login and /auth/register to fail normally to show errors
if (onUnauthorized) { const isAuthEndpoint = /^\/auth\/(login|register|oidc\/callback)/.test(originalRequest.url || "")
onUnauthorized() const isRefreshEndpoint = /^\/auth\/refresh/.test(originalRequest.url || "")
if (error.response?.status === 401 && !isAuthEndpoint && !isRefreshEndpoint && !originalRequest._retry) {
if (isRefreshing) {
// Wait for the current refresh to complete
return new Promise((resolve, reject) => {
subscribeTokenRefresh(
() => resolve(api(originalRequest)),
(err) => reject(err)
)
})
}
originalRequest._retry = true
isRefreshing = true
try {
console.log("Reactive refresh: Access token expired, attempting to refresh...")
// Call refresh endpoint
const response = await api.post("/auth/refresh")
const { access_token } = response.data
isRefreshing = false
onRefreshed(access_token)
// Retry the original request
return api(originalRequest)
} catch (refreshError: unknown) {
isRefreshing = false
onRefreshFailed(refreshError)
console.error("Reactive refresh failed:", refreshError)
// Final failure - session is dead
handleUnauthorized()
return Promise.reject(refreshError)
} }
} }
// If it's a 401 on an endpoint we don't/can't refresh (like refresh itself or login)
if (error.response?.status === 401) {
handleUnauthorized()
}
return Promise.reject(error) return Promise.reject(error)
} }
) )
/**
* Shared helper to trigger logout/unauthorized cleanup.
*/
function handleUnauthorized() {
if (onUnauthorized) {
onUnauthorized()
}
}
export default api export default api

View File

@@ -83,4 +83,19 @@ describe("AuthService", () => {
await AuthService.logout() await AuthService.logout()
expect(mockedApi.post).toHaveBeenCalledWith("/auth/logout") expect(mockedApi.post).toHaveBeenCalledWith("/auth/logout")
}) })
it("successfully refreshes session", async () => {
const mockResponse = {
data: {
access_token: "new-fake-token",
token_type: "bearer",
},
}
mockedApi.post.mockResolvedValueOnce(mockResponse)
const result = await AuthService.refreshSession()
expect(mockedApi.post).toHaveBeenCalledWith("/auth/refresh")
expect(result.access_token).toBe("new-fake-token")
})
}) })

View File

@@ -1,5 +1,5 @@
import api from "./api" import api from "./api"
import type { Theme } from "@/components/theme-provider" import type { Theme } from "@/components/theme-context"
export interface AuthResponse { export interface AuthResponse {
access_token: string access_token: string
@@ -52,6 +52,11 @@ export const AuthService = {
await api.post("/auth/logout") await api.post("/auth/logout")
}, },
async refreshSession(): Promise<AuthResponse> {
const response = await api.post<AuthResponse>("/auth/refresh")
return response.data
},
async updateTheme(theme: Theme): Promise<UserResponse> { async updateTheme(theme: Theme): Promise<UserResponse> {
const response = await api.patch<UserResponse>("/auth/theme", { theme }) const response = await api.patch<UserResponse>("/auth/theme", { theme })
return response.data return response.data