From 1b36a940345d1d9efeecc2715caade7a4a527ba0 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Tue, 9 Sep 2025 06:38:37 +0200 Subject: [PATCH] simplifying the auth and creating strict separation --- backend/app/api/internal_v1/__init__.py | 6 +- backend/app/api/v1/llm.py | 63 +----- backend/app/api/v1/llm_internal.py | 111 ++++++++++ backend/app/api/v1/modules.py | 29 +-- backend/app/api/v1/openai_compat.py | 11 +- backend/app/api/v1/platform.py | 10 +- backend/app/db/database.py | 4 +- backend/app/middleware/security.py | 2 + backend/app/services/plugin_gateway.py | 2 +- backend/app/services/plugin_security.py | 2 +- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/app/test-auth/page.tsx | 24 +- frontend/src/contexts/AuthContext.tsx | 265 ++++++++--------------- frontend/src/contexts/ModulesContext.tsx | 66 +++++- frontend/src/contexts/PluginContext.tsx | 16 +- 16 files changed, 335 insertions(+), 287 deletions(-) create mode 100644 backend/app/api/v1/llm_internal.py diff --git a/backend/app/api/internal_v1/__init__.py b/backend/app/api/internal_v1/__init__.py index 463f961..97e8510 100644 --- a/backend/app/api/internal_v1/__init__.py +++ b/backend/app/api/internal_v1/__init__.py @@ -16,7 +16,7 @@ from ..v1.prompt_templates import router as prompt_templates_router from ..v1.security import router as security_router from ..v1.plugin_registry import router as plugin_registry_router from ..v1.platform import router as platform_router -from ..v1.llm import router as llm_router +from ..v1.llm_internal import router as llm_internal_router from ..v1.chatbot import router as chatbot_router # Create internal API router @@ -61,8 +61,8 @@ internal_api_router.include_router(security_router, prefix="/security", tags=["i # Include plugin registry routes (frontend plugin management) internal_api_router.include_router(plugin_registry_router, prefix="/plugins", tags=["internal-plugins"]) -# Include LLM routes (frontend LLM service access) -internal_api_router.include_router(llm_router, prefix="/llm", tags=["internal-llm"]) +# Include internal LLM routes (frontend LLM service access with JWT auth) +internal_api_router.include_router(llm_internal_router, prefix="/llm", tags=["internal-llm"]) # Include chatbot routes (frontend chatbot management) internal_api_router.include_router(chatbot_router, prefix="/chatbot", tags=["internal-chatbot"]) \ No newline at end of file diff --git a/backend/app/api/v1/llm.py b/backend/app/api/v1/llm.py index 69a2eae..799b165 100644 --- a/backend/app/api/v1/llm.py +++ b/backend/app/api/v1/llm.py @@ -126,62 +126,13 @@ class ModelsResponse(BaseModel): data: List[ModelInfo] -# Hybrid authentication function -async def get_auth_context( - request: Request, - db: AsyncSession = Depends(get_db) -) -> Dict[str, Any]: - """Get authentication context from either API key or JWT token""" - # Try API key authentication first - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header[7:] - - # Check if it's an API key (starts with ce_ prefix) - if token.startswith(settings.API_KEY_PREFIX): - try: - context = await get_api_key_context(request, db) - if context: - return context - except Exception as e: - logger.warning(f"API key authentication failed: {e}") - else: - # Try JWT token authentication - try: - from app.core.security import get_current_user - # Create a fake credentials object for JWT validation - from fastapi.security import HTTPAuthorizationCredentials - credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) - user = await get_current_user(credentials, db) - if user: - return { - "user": user, - "auth_type": "jwt", - "api_key": None - } - except Exception as e: - logger.warning(f"JWT authentication failed: {e}") - - # Try X-API-Key header - api_key = request.headers.get("X-API-Key") - if api_key: - try: - context = await get_api_key_context(request, db) - if context: - return context - except Exception as e: - logger.warning(f"X-API-Key authentication failed: {e}") - - # No valid authentication found - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Valid API key or authentication token required" - ) +# Authentication: Public API endpoints should use require_api_key +# Internal API endpoints should use get_current_user from core.security # Endpoints @router.get("/models", response_model=ModelsResponse) async def list_models( - context: Dict[str, Any] = Depends(get_auth_context), + context: Dict[str, Any] = Depends(require_api_key), db: AsyncSession = Depends(get_db) ): """List available models""" @@ -220,7 +171,7 @@ async def list_models( @router.post("/models/invalidate-cache") async def invalidate_models_cache_endpoint( - context: Dict[str, Any] = Depends(get_auth_context), + context: Dict[str, Any] = Depends(require_api_key), db: AsyncSession = Depends(get_db) ): """Invalidate models cache (admin only)""" @@ -249,7 +200,7 @@ async def invalidate_models_cache_endpoint( async def create_chat_completion( request_body: Request, chat_request: ChatCompletionRequest, - context: Dict[str, Any] = Depends(get_auth_context), + context: Dict[str, Any] = Depends(require_api_key), db: AsyncSession = Depends(get_db) ): """Create chat completion with budget enforcement""" @@ -604,7 +555,7 @@ async def create_embedding( @router.get("/health") async def llm_health_check( - context: Dict[str, Any] = Depends(get_auth_context) + context: Dict[str, Any] = Depends(require_api_key) ): """Health check for LLM service""" try: @@ -686,7 +637,7 @@ async def get_usage_stats( @router.get("/budget/status") async def get_budget_status( request: Request, - context: Dict[str, Any] = Depends(get_auth_context), + context: Dict[str, Any] = Depends(require_api_key), db: AsyncSession = Depends(get_db) ): """Get current budget status and usage analytics""" diff --git a/backend/app/api/v1/llm_internal.py b/backend/app/api/v1/llm_internal.py new file mode 100644 index 0000000..4f023ca --- /dev/null +++ b/backend/app/api/v1/llm_internal.py @@ -0,0 +1,111 @@ +""" +Internal LLM API endpoints - for frontend use with JWT authentication +""" + +import logging +from typing import Dict, Any, List + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.database import get_db +from app.core.security import get_current_user +from app.services.llm.service import llm_service +from app.api.v1.llm import get_cached_models # Reuse the caching logic + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/models") +async def list_models( + current_user: Dict[str, Any] = Depends(get_current_user) +) -> Dict[str, List[Dict[str, Any]]]: + """ + List available LLM models for authenticated users + """ + try: + models = await get_cached_models() + return {"data": models} + except Exception as e: + logger.error(f"Failed to list models: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve models" + ) + + +@router.get("/providers/status") +async def get_provider_status( + current_user: Dict[str, Any] = Depends(get_current_user) +): + """ + Get status of all LLM providers for authenticated users + """ + try: + provider_status = await llm_service.get_provider_status() + return { + "object": "provider_status", + "data": { + name: { + "provider": status.provider, + "status": status.status, + "latency_ms": status.latency_ms, + "success_rate": status.success_rate, + "last_check": status.last_check.isoformat(), + "error_message": status.error_message, + "models_available": status.models_available + } + for name, status in provider_status.items() + } + } + except Exception as e: + logger.error(f"Failed to get provider status: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve provider status" + ) + + +@router.get("/health") +async def health_check( + current_user: Dict[str, Any] = Depends(get_current_user) +): + """ + Get LLM service health status for authenticated users + """ + try: + health = await llm_service.health_check() + return { + "status": health["status"], + "providers": health.get("providers", {}), + "timestamp": health.get("timestamp") + } + except Exception as e: + logger.error(f"Health check failed: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Health check failed" + ) + + +@router.get("/metrics") +async def get_metrics( + current_user: Dict[str, Any] = Depends(get_current_user) +): + """ + Get LLM service metrics for authenticated users + """ + try: + metrics = await llm_service.get_metrics() + return { + "object": "metrics", + "data": metrics + } + except Exception as e: + logger.error(f"Failed to get metrics: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve metrics" + ) \ No newline at end of file diff --git a/backend/app/api/v1/modules.py b/backend/app/api/v1/modules.py index b2814e5..d582e5e 100644 --- a/backend/app/api/v1/modules.py +++ b/backend/app/api/v1/modules.py @@ -6,12 +6,13 @@ from typing import Dict, Any, List from fastapi import APIRouter, Depends, HTTPException from app.services.module_manager import module_manager, ModuleConfig from app.core.logging import log_api_request +from app.core.security import get_current_user router = APIRouter() @router.get("/") -async def list_modules(): +async def list_modules(current_user: Dict[str, Any] = Depends(get_current_user)): """Get list of all discovered modules with their status (enabled and disabled)""" log_api_request("list_modules", {}) @@ -70,7 +71,7 @@ async def list_modules(): @router.get("/status") -async def get_modules_status(): +async def get_modules_status(current_user: Dict[str, Any] = Depends(get_current_user)): """Get comprehensive module status - CONSOLIDATED endpoint""" log_api_request("get_modules_status", {}) @@ -134,7 +135,7 @@ async def get_modules_status(): @router.get("/{module_name}") -async def get_module_info(module_name: str): +async def get_module_info(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Get detailed information about a specific module""" log_api_request("get_module_info", {"module_name": module_name}) @@ -187,7 +188,7 @@ async def get_module_info(module_name: str): @router.post("/{module_name}/enable") -async def enable_module(module_name: str): +async def enable_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Enable a module""" log_api_request("enable_module", {"module_name": module_name}) @@ -212,7 +213,7 @@ async def enable_module(module_name: str): @router.post("/{module_name}/disable") -async def disable_module(module_name: str): +async def disable_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Disable a module""" log_api_request("disable_module", {"module_name": module_name}) @@ -237,7 +238,7 @@ async def disable_module(module_name: str): @router.post("/all/reload") -async def reload_all_modules(): +async def reload_all_modules(current_user: Dict[str, Any] = Depends(get_current_user)): """Reload all modules""" log_api_request("reload_all_modules", {}) @@ -271,7 +272,7 @@ async def reload_all_modules(): @router.post("/{module_name}/reload") -async def reload_module(module_name: str): +async def reload_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Reload a specific module""" log_api_request("reload_module", {"module_name": module_name}) @@ -290,7 +291,7 @@ async def reload_module(module_name: str): @router.post("/{module_name}/restart") -async def restart_module(module_name: str): +async def restart_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Restart a specific module (alias for reload)""" log_api_request("restart_module", {"module_name": module_name}) @@ -309,7 +310,7 @@ async def restart_module(module_name: str): @router.post("/{module_name}/start") -async def start_module(module_name: str): +async def start_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Start a specific module (enable and load)""" log_api_request("start_module", {"module_name": module_name}) @@ -331,7 +332,7 @@ async def start_module(module_name: str): @router.post("/{module_name}/stop") -async def stop_module(module_name: str): +async def stop_module(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Stop a specific module (disable and unload)""" log_api_request("stop_module", {"module_name": module_name}) @@ -353,7 +354,7 @@ async def stop_module(module_name: str): @router.get("/{module_name}/stats") -async def get_module_stats(module_name: str): +async def get_module_stats(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Get module statistics""" log_api_request("get_module_stats", {"module_name": module_name}) @@ -380,7 +381,7 @@ async def get_module_stats(module_name: str): @router.post("/{module_name}/execute") -async def execute_module_action(module_name: str, request_data: Dict[str, Any]): +async def execute_module_action(module_name: str, request_data: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)): """Execute a module action through the interceptor pattern""" log_api_request("execute_module_action", {"module_name": module_name, "action": request_data.get("action")}) @@ -442,7 +443,7 @@ async def execute_module_action(module_name: str, request_data: Dict[str, Any]): @router.get("/{module_name}/config") -async def get_module_config(module_name: str): +async def get_module_config(module_name: str, current_user: Dict[str, Any] = Depends(get_current_user)): """Get module configuration schema and current values""" log_api_request("get_module_config", {"module_name": module_name}) @@ -493,7 +494,7 @@ async def get_module_config(module_name: str): @router.post("/{module_name}/config") -async def update_module_config(module_name: str, config: dict): +async def update_module_config(module_name: str, config: dict, current_user: Dict[str, Any] = Depends(get_current_user)): """Update module configuration""" log_api_request("update_module_config", {"module_name": module_name}) diff --git a/backend/app/api/v1/openai_compat.py b/backend/app/api/v1/openai_compat.py index 59a1e9d..5833f3f 100644 --- a/backend/app/api/v1/openai_compat.py +++ b/backend/app/api/v1/openai_compat.py @@ -10,8 +10,9 @@ from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession from app.db.database import get_db +from app.services.api_key_auth import require_api_key from app.api.v1.llm import ( - get_auth_context, get_cached_models, ModelsResponse, ModelInfo, + get_cached_models, ModelsResponse, ModelInfo, ChatCompletionRequest, EmbeddingRequest, create_chat_completion as llm_chat_completion, create_embedding as llm_create_embedding ) @@ -41,7 +42,7 @@ def openai_error_response(message: str, error_type: str = "invalid_request_error @router.get("/models", response_model=ModelsResponse) async def list_models( - context: Dict[str, Any] = Depends(get_auth_context), + context: Dict[str, Any] = Depends(require_api_key), db: AsyncSession = Depends(get_db) ): """ @@ -84,7 +85,7 @@ async def list_models( async def create_chat_completion( request_body: Request, chat_request: ChatCompletionRequest, - context: Dict[str, Any] = Depends(get_auth_context), + context: Dict[str, Any] = Depends(require_api_key), db: AsyncSession = Depends(get_db) ): """ @@ -100,7 +101,7 @@ async def create_chat_completion( @router.post("/embeddings") async def create_embedding( request: EmbeddingRequest, - context: Dict[str, Any] = Depends(get_auth_context), + context: Dict[str, Any] = Depends(require_api_key), db: AsyncSession = Depends(get_db) ): """ @@ -116,7 +117,7 @@ async def create_embedding( @router.get("/models/{model_id}") async def retrieve_model( model_id: str, - context: Dict[str, Any] = Depends(get_auth_context), + context: Dict[str, Any] = Depends(require_api_key), db: AsyncSession = Depends(get_db) ): """ diff --git a/backend/app/api/v1/platform.py b/backend/app/api/v1/platform.py index ab28323..d79d86f 100644 --- a/backend/app/api/v1/platform.py +++ b/backend/app/api/v1/platform.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from app.services.permission_manager import permission_registry, Permission, PermissionScope from app.core.logging import get_logger +from app.core.security import get_current_user logger = get_logger(__name__) @@ -85,7 +86,7 @@ async def get_available_permissions(namespace: Optional[str] = None): resource=perm.resource, action=perm.action, description=perm.description, - conditions=perm.conditions + conditions=getattr(perm, 'conditions', None) ) for perm in perms ] @@ -131,7 +132,10 @@ async def validate_permissions(request: PermissionValidationRequest): @router.post("/permissions/check", response_model=PermissionCheckResponse) -async def check_permission(request: PermissionCheckRequest): +async def check_permission( + request: PermissionCheckRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): """Check if user has a specific permission""" try: has_permission = permission_registry.check_permission( @@ -168,7 +172,7 @@ async def get_module_permissions(module_id: str): resource=perm.resource, action=perm.action, description=perm.description, - conditions=perm.conditions + conditions=getattr(perm, 'conditions', None) ) for perm in permissions ] diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 4ce8c97..9b05c5e 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -72,7 +72,9 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: try: yield session except Exception as e: - logger.error(f"Database session error: {e}") + # Only log if there's an actual error, not normal operation + if str(e).strip(): # Only log if error message exists + logger.error(f"Database session error: {str(e)}", exc_info=True) await session.rollback() raise finally: diff --git a/backend/app/middleware/security.py b/backend/app/middleware/security.py index ce86feb..6efc1f4 100644 --- a/backend/app/middleware/security.py +++ b/backend/app/middleware/security.py @@ -104,8 +104,10 @@ class SecurityMiddleware(BaseHTTPMiddleware): "/favicon.ico", "/api/v1/auth/register", "/api/v1/auth/login", + "/api/v1/auth/refresh", # Allow refresh endpoint "/api-internal/v1/auth/register", "/api-internal/v1/auth/login", + "/api-internal/v1/auth/refresh", # Allow refresh endpoint for internal API "/", # Root endpoint ] diff --git a/backend/app/services/plugin_gateway.py b/backend/app/services/plugin_gateway.py index 39bdb7c..fff1d80 100644 --- a/backend/app/services/plugin_gateway.py +++ b/backend/app/services/plugin_gateway.py @@ -4,7 +4,7 @@ Handles authentication, routing, and security for plugin APIs """ import asyncio import time -import jwt +from jose import jwt from typing import Dict, Any, List, Optional, Tuple from fastapi import FastAPI, Request, Response, HTTPException, Depends from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials diff --git a/backend/app/services/plugin_security.py b/backend/app/services/plugin_security.py index 6a6b6dc..fc93a2e 100644 --- a/backend/app/services/plugin_security.py +++ b/backend/app/services/plugin_security.py @@ -2,7 +2,7 @@ Plugin Security and Authentication Service Handles plugin tokens, permissions, and security policies """ -import jwt +from jose import jwt import hashlib import secrets import time diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c660b6b..5f8f91b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "date-fns": "^2.30.0", + "events": "^3.3.0", "js-cookie": "^3.0.5", "lucide-react": "^0.294.0", "next": "^14.2.32", @@ -3973,6 +3974,15 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a60ee40..1c8ec8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "date-fns": "^2.30.0", + "events": "^3.3.0", "js-cookie": "^3.0.5", "lucide-react": "^0.294.0", "next": "^14.2.32", diff --git a/frontend/src/app/test-auth/page.tsx b/frontend/src/app/test-auth/page.tsx index 2441791..003f34c 100644 --- a/frontend/src/app/test-auth/page.tsx +++ b/frontend/src/app/test-auth/page.tsx @@ -5,10 +5,10 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { useState } from "react" import { apiClient } from "@/lib/api-client" -import { decodeToken } from "@/lib/auth-utils" +import { tokenManager } from "@/lib/token-manager" export default function TestAuthPage() { - const { user, token, refreshToken, logout } = useAuth() + const { user, logout, isAuthenticated } = useAuth() const [testResult, setTestResult] = useState("") const [isLoading, setIsLoading] = useState(false) @@ -24,19 +24,19 @@ export default function TestAuthPage() { } const getTokenInfo = () => { - if (!token) return "No token" + const expiry = tokenManager.getTokenExpiry() + const refreshExpiry = tokenManager.getRefreshTokenExpiry() - const payload = decodeToken(token) - if (!payload) return "Invalid token" + if (!expiry) return "No token" - const now = Math.floor(Date.now() / 1000) - const timeUntilExpiry = payload.exp - now - const expiryDate = new Date(payload.exp * 1000) + const now = new Date() + const timeUntilExpiry = Math.floor((expiry.getTime() - now.getTime()) / 1000) return ` Token expires in: ${Math.floor(timeUntilExpiry / 60)} minutes ${timeUntilExpiry % 60} seconds -Expiry time: ${expiryDate.toLocaleString()} -Token payload: ${JSON.stringify(payload, null, 2)} +Access token expiry: ${expiry.toLocaleString()} +Refresh token expiry: ${refreshExpiry?.toLocaleString() || 'N/A'} +Authenticated: ${tokenManager.isAuthenticated()} ` } @@ -70,9 +70,9 @@ Token payload: ${JSON.stringify(payload, null, 2)}
               {getTokenInfo()}
             
- {refreshToken && ( + {isAuthenticated && (

- Refresh token available - auto-refresh enabled + Auto-refresh enabled - tokens will refresh automatically

)} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 497b3ff..c5be4be 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,16 +1,8 @@ "use client" -import { createContext, useContext, useState, useEffect, ReactNode, useRef } from "react" +import { createContext, useContext, useState, useEffect, ReactNode } from "react" import { useRouter } from "next/navigation" -import { - isTokenExpired, - refreshAccessToken, - storeTokens, - getStoredTokens, - clearTokens, - setupTokenRefreshTimer, - decodeToken -} from "@/lib/auth-utils" +import { tokenManager } from "@/lib/token-manager" interface User { id: string @@ -21,128 +13,107 @@ interface User { interface AuthContextType { user: User | null - token: string | null - refreshToken: string | null + isAuthenticated: boolean login: (email: string, password: string) => Promise logout: () => void isLoading: boolean - refreshTokenIfNeeded: () => Promise } const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null) - const [token, setToken] = useState(null) - const [refreshToken, setRefreshToken] = useState(null) const [isLoading, setIsLoading] = useState(true) const router = useRouter() - const refreshTimerRef = useRef(null) - // Initialize auth state from localStorage + // Initialize auth state and listen to token manager events useEffect(() => { const initAuth = async () => { - if (typeof window === "undefined") return - - // Don't try to refresh on auth-related pages - const isAuthPage = window.location.pathname === '/login' || - window.location.pathname === '/register' || - window.location.pathname === '/forgot-password' - - const { accessToken, refreshToken: storedRefreshToken } = getStoredTokens() - - if (accessToken && storedRefreshToken) { - // Check if token is expired - if (isTokenExpired(accessToken)) { - // Only try to refresh if not on auth pages - if (!isAuthPage) { - // Try to refresh the token - const response = await refreshAccessToken(storedRefreshToken) - if (response) { - storeTokens(response.access_token, response.refresh_token) - setToken(response.access_token) - setRefreshToken(response.refresh_token) - - // Decode token to get user info - const payload = decodeToken(response.access_token) - if (payload) { - const storedUser = localStorage.getItem("user") - if (storedUser) { - setUser(JSON.parse(storedUser)) - } - } - - // Setup refresh timer - setupRefreshTimer(response.access_token, response.refresh_token) - } else { - // Refresh failed, clear everything - clearTokens() - setUser(null) - setToken(null) - setRefreshToken(null) - } - } else { - // On auth pages with expired token, just clear it - clearTokens() - setUser(null) - setToken(null) - setRefreshToken(null) - } - } else { - // Token is still valid - setToken(accessToken) - setRefreshToken(storedRefreshToken) - - const storedUser = localStorage.getItem("user") - if (storedUser) { - setUser(JSON.parse(storedUser)) - } - - // Setup refresh timer - setupRefreshTimer(accessToken, storedRefreshToken) - } + // Check if we have valid tokens + if (tokenManager.isAuthenticated()) { + // Try to get user info + await fetchUserInfo() } - setIsLoading(false) } + // Set up event listeners + const handleTokensUpdated = () => { + // Tokens were updated (refreshed), update user if needed + if (!user) { + fetchUserInfo() + } + } + + const handleTokensCleared = () => { + // Tokens were cleared, clear user + setUser(null) + } + + const handleSessionExpired = (reason: string) => { + console.log('Session expired:', reason) + setUser(null) + // TokenManager and API client will handle redirect + } + + const handleLogout = () => { + setUser(null) + router.push('/login') + } + + // Register event listeners + tokenManager.on('tokensUpdated', handleTokensUpdated) + tokenManager.on('tokensCleared', handleTokensCleared) + tokenManager.on('sessionExpired', handleSessionExpired) + tokenManager.on('logout', handleLogout) + + // Initialize initAuth() - }, []) - // Setup token refresh timer - const setupRefreshTimer = (accessToken: string, refreshTokenValue: string) => { - // Clear existing timer - if (refreshTimerRef.current) { - clearTimeout(refreshTimerRef.current) - } - - refreshTimerRef.current = setupTokenRefreshTimer( - accessToken, - refreshTokenValue, - (newAccessToken) => { - setToken(newAccessToken) - }, - () => { - // Refresh failed, logout user - logout() - } - ) - } - - // Cleanup timer on unmount - useEffect(() => { + // Cleanup return () => { - if (refreshTimerRef.current) { - clearTimeout(refreshTimerRef.current) - } + tokenManager.off('tokensUpdated', handleTokensUpdated) + tokenManager.off('tokensCleared', handleTokensCleared) + tokenManager.off('sessionExpired', handleSessionExpired) + tokenManager.off('logout', handleLogout) } }, []) + const fetchUserInfo = async () => { + try { + const token = await tokenManager.getAccessToken() + if (!token) return + + const response = await fetch('/api-internal/v1/auth/me', { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }) + + if (response.ok) { + const userData = await response.json() + const user = { + id: userData.id || userData.sub, + email: userData.email, + name: userData.name || userData.email, + role: userData.role || 'user', + } + setUser(user) + + // Store user info for offline access + if (typeof window !== 'undefined') { + localStorage.setItem('user', JSON.stringify(user)) + } + } + } catch (error) { + console.error('Failed to fetch user info:', error) + } + } + const login = async (email: string, password: string) => { setIsLoading(true) try { - // Call real backend login endpoint const response = await fetch('/api-internal/v1/auth/login', { method: 'POST', headers: { @@ -158,38 +129,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { const data = await response.json() - // Store tokens - storeTokens(data.access_token, data.refresh_token) + // Store tokens in TokenManager + tokenManager.setTokens( + data.access_token, + data.refresh_token, + data.expires_in + ) - // Decode token to get user info - const payload = decodeToken(data.access_token) - if (payload) { - // Fetch user details - const userResponse = await fetch('/api-internal/v1/auth/me', { - headers: { - 'Authorization': `Bearer ${data.access_token}`, - }, - }) - - if (userResponse.ok) { - const userData = await userResponse.json() - const user = { - id: userData.id || payload.sub, - email: userData.email || payload.email || email, - name: userData.name || userData.email || email, - role: userData.role || 'user', - } - - localStorage.setItem("user", JSON.stringify(user)) - setUser(user) - } - } + // Fetch user info + await fetchUserInfo() - setToken(data.access_token) - setRefreshToken(data.refresh_token) - - // Setup refresh timer - setupRefreshTimer(data.access_token, data.refresh_token) + // Navigate to dashboard + router.push('/dashboard') } catch (error) { console.error('Login error:', error) @@ -200,56 +151,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const logout = () => { - // Clear refresh timer - if (refreshTimerRef.current) { - clearTimeout(refreshTimerRef.current) - refreshTimerRef.current = null - } - - // Clear state - setUser(null) - setToken(null) - setRefreshToken(null) - - // Clear localStorage - clearTokens() - - // Redirect to login - router.push("/login") - } - - const refreshTokenIfNeeded = async (): Promise => { - if (!token || !refreshToken) { - return false - } - - if (isTokenExpired(token)) { - const response = await refreshAccessToken(refreshToken) - if (response) { - storeTokens(response.access_token, response.refresh_token) - setToken(response.access_token) - setRefreshToken(response.refresh_token) - setupRefreshTimer(response.access_token, response.refresh_token) - return true - } else { - logout() - return false - } - } - - return true + tokenManager.logout() + // Token manager will emit 'logout' event which we handle above } return ( {children} diff --git a/frontend/src/contexts/ModulesContext.tsx b/frontend/src/contexts/ModulesContext.tsx index cb3fca3..1069e96 100644 --- a/frontend/src/contexts/ModulesContext.tsx +++ b/frontend/src/contexts/ModulesContext.tsx @@ -2,6 +2,8 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react" import { apiClient } from "@/lib/api-client" +import { tokenManager } from "@/lib/token-manager" +import { usePathname } from "next/navigation" interface Module { name: string @@ -34,11 +36,21 @@ const ModulesContext = createContext(undefined) export function ModulesProvider({ children }: { children: ReactNode }) { const [modules, setModules] = useState([]) const [enabledModules, setEnabledModules] = useState>(new Set()) - const [isLoading, setIsLoading] = useState(true) + const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [lastUpdated, setLastUpdated] = useState(null) + const pathname = usePathname() + + // Check if we're on an auth page + const isAuthPage = pathname === '/login' || pathname === '/register' || pathname === '/forgot-password' const fetchModules = useCallback(async () => { + // Don't fetch if we're on an auth page or not authenticated + if (isAuthPage || !tokenManager.isAuthenticated()) { + setIsLoading(false) + return + } + try { setIsLoading(true) setError(null) @@ -57,11 +69,14 @@ export function ModulesProvider({ children }: { children: ReactNode }) { setLastUpdated(new Date()) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load modules") + // Only set error if we're authenticated (to avoid noise on auth pages) + if (tokenManager.isAuthenticated()) { + setError(err instanceof Error ? err.message : "Failed to load modules") + } } finally { setIsLoading(false) } - }, []) + }, [isAuthPage]) const refreshModules = useCallback(async () => { await fetchModules() @@ -72,13 +87,22 @@ export function ModulesProvider({ children }: { children: ReactNode }) { }, [enabledModules]) useEffect(() => { - fetchModules() + // Only fetch if authenticated and not on auth page + if (!isAuthPage && tokenManager.isAuthenticated()) { + fetchModules() + } // Set up periodic refresh every 30 seconds to catch module state changes - const interval = setInterval(fetchModules, 30000) + // But only if authenticated + let interval: NodeJS.Timeout | null = null + if (!isAuthPage && tokenManager.isAuthenticated()) { + interval = setInterval(fetchModules, 30000) + } - return () => clearInterval(interval) - }, [fetchModules]) + return () => { + if (interval) clearInterval(interval) + } + }, [fetchModules, isAuthPage]) // Listen for custom events that indicate module state changes useEffect(() => { @@ -93,6 +117,34 @@ export function ModulesProvider({ children }: { children: ReactNode }) { } }, [refreshModules]) + // Listen for authentication changes + useEffect(() => { + const handleTokensUpdated = () => { + // When tokens are updated (user logs in), fetch modules + if (!isAuthPage) { + fetchModules() + } + } + + const handleTokensCleared = () => { + // When tokens are cleared (user logs out), clear modules + setModules([]) + setEnabledModules(new Set()) + setError(null) + setLastUpdated(null) + } + + tokenManager.on('tokensUpdated', handleTokensUpdated) + tokenManager.on('tokensCleared', handleTokensCleared) + tokenManager.on('logout', handleTokensCleared) + + return () => { + tokenManager.off('tokensUpdated', handleTokensUpdated) + tokenManager.off('tokensCleared', handleTokensCleared) + tokenManager.off('logout', handleTokensCleared) + } + }, [fetchModules, isAuthPage]) + return ( = ({ children }) => { - const { user, token } = useAuth(); + const { user, isAuthenticated } = useAuth(); const [installedPlugins, setInstalledPlugins] = useState([]); const [availablePlugins, setAvailablePlugins] = useState([]); const [pluginConfigurations, setPluginConfigurations] = useState>({}); @@ -108,7 +108,7 @@ export const PluginProvider: React.FC = ({ children }) => { const [pluginComponents, setPluginComponents] = useState>>({}); const apiRequest = async (endpoint: string, options: RequestInit = {}) => { - if (!token) { + if (!isAuthenticated) { throw new Error('Authentication required'); } @@ -129,7 +129,7 @@ export const PluginProvider: React.FC = ({ children }) => { }; const refreshInstalledPlugins = useCallback(async () => { - if (!user || !token) { + if (!user || !isAuthenticated) { setError('Authentication required'); return; } @@ -160,10 +160,10 @@ export const PluginProvider: React.FC = ({ children }) => { } finally { setLoading(false); } - }, [user, token]); + }, [user, isAuthenticated]); const searchAvailablePlugins = useCallback(async (query = '', tags: string[] = [], category = '') => { - if (!user || !token) { + if (!user || !isAuthenticated) { setError('Authentication required'); return; } @@ -185,7 +185,7 @@ export const PluginProvider: React.FC = ({ children }) => { } finally { setLoading(false); } - }, [user, token]); + }, [user, isAuthenticated]); const installPlugin = useCallback(async (pluginId: string, version: string): Promise => { try { @@ -487,7 +487,7 @@ export const PluginProvider: React.FC = ({ children }) => { // Load installed plugins on mount, but only when authenticated useEffect(() => { - if (user && token) { + if (user && isAuthenticated) { refreshInstalledPlugins(); } else { // Clear plugin data when not authenticated @@ -496,7 +496,7 @@ export const PluginProvider: React.FC = ({ children }) => { setPluginConfigurations({}); setError(null); } - }, [user, token, refreshInstalledPlugins]); + }, [user, isAuthenticated, refreshInstalledPlugins]); const value: PluginContextType = { // State