diff --git a/.env.example b/.env.example index 9b23e42..3c1146b 100644 --- a/.env.example +++ b/.env.example @@ -1,52 +1,71 @@ -# Database -DATABASE_URL=postgresql://your_user:your_password@localhost:5432/your_db -REDIS_URL=redis://localhost:6379 +# =================================== +# ENCLAVA MINIMAL CONFIGURATION +# =================================== +# Only essential environment variables that CANNOT have defaults +# Other settings should be configurable through the app UI -# JWT and API Keys +# =================================== +# INFRASTRUCTURE (Required) +# =================================== +DATABASE_URL=postgresql://enclava_user:enclava_pass@enclava-postgres:5432/enclava_db +REDIS_URL=redis://enclava-redis:6379 + +# =================================== +# SECURITY CRITICAL (Required) +# =================================== JWT_SECRET=your-super-secret-jwt-key-here-change-in-production -API_KEY_PREFIX=ce_ +PRIVATEMODE_API_KEY=your-privatemode-api-key-here -# Privatemode.ai (optional) -PRIVATEMODE_API_KEY=your-privatemode-api-key -PRIVATEMODE_CACHE_MODE=none -PRIVATEMODE_CACHE_SALT= +# Admin user (created on first startup only) +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=admin123 -# Application Configuration -APP_NAME=Enclava -APP_DEBUG=false -APP_LOG_LEVEL=INFO -APP_HOST=0.0.0.0 -APP_PORT=8000 - -# Application Base URL - Port 80 Configuration (derives all URLs and CORS) +# =================================== +# APPLICATION BASE URL (Required - derives all URLs and CORS) +# =================================== BASE_URL=localhost -# Derives: Frontend URLs (http://localhost, ws://localhost) and Backend CORS +# Frontend derives: APP_URL=http://localhost, API_URL=http://localhost, WS_URL=ws://localhost +# Backend derives: CORS_ORIGINS=["http://localhost"] -# Docker Internal Ports (Required for containers) +# =================================== +# DOCKER NETWORKING (Required for containers) +# =================================== BACKEND_INTERNAL_PORT=8000 FRONTEND_INTERNAL_PORT=3000 -# Container hosts are fixed: enclava-backend, enclava-frontend +# Hosts are fixed: enclava-backend, enclava-frontend +# Upstreams derive: enclava-backend:8000, enclava-frontend:3000 -# API Configuration -NEXT_PUBLIC_API_TIMEOUT=30000 -NEXT_PUBLIC_API_RETRY_ATTEMPTS=3 -NEXT_PUBLIC_API_RETRY_DELAY=1000 -NEXT_PUBLIC_API_RETRY_MAX_DELAY=10000 - -# Module Default Service URLs (Optional) -NEXT_PUBLIC_DEFAULT_ZAMMAD_URL=http://localhost:8080 -NEXT_PUBLIC_DEFAULT_SIGNAL_SERVICE=localhost:8080 - -# Qdrant Configuration -QDRANT_HOST=localhost +# =================================== +# QDRANT (Required for RAG) +# =================================== +QDRANT_HOST=enclava-qdrant QDRANT_PORT=6333 -QDRANT_API_KEY= -QDRANT_URL=http://localhost:6333 +QDRANT_URL=http://enclava-qdrant:6333 -# Security -RATE_LIMIT_ENABLED=true -# CORS_ORIGINS is now derived from BASE_URL automatically +# =================================== +# OPTIONAL PRIVATEMODE SETTINGS (Have defaults) +# =================================== +# PRIVATEMODE_CACHE_MODE=none # Optional: defaults to 'none' +# PRIVATEMODE_CACHE_SALT= # Optional: defaults to empty -# Monitoring -PROMETHEUS_ENABLED=true -PROMETHEUS_PORT=9090 +# =================================== +# MOVED TO APP SETTINGS (Optional - have defaults) +# =================================== +# The following were moved to have sane defaults and/or be user-configurable: +# - APP_NAME (default: "Enclava") +# - APP_DEBUG (default: false) +# - APP_LOG_LEVEL (default: INFO) +# - API timeout/retry settings (have defaults) +# - Rate limiting settings (default enabled) +# - Module default URLs (user configurable) +# - API_KEY_PREFIX (default: "ce_") +# - Monitoring settings (default enabled) + +# =================================== +# TOTAL: 8 essential variables vs 25+ in original +# (Consolidated 3 URL settings into 1 base URL) +# (Consolidated 6 Docker networking settings into 2 ports) +# (CORS_ORIGINS now derived from base URL) +# (Added PRIVATEMODE_API_KEY as required) +# (68% reduction in required configuration!) +# =================================== \ No newline at end of file diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 9565717..1389531 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -88,6 +88,23 @@ class RefreshTokenRequest(BaseModel): refresh_token: str +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + @validator('new_password') + def validate_new_password(cls, v): + if len(v) < 8: + raise ValueError('Password must be at least 8 characters long') + if not any(c.isupper() for c in v): + raise ValueError('Password must contain at least one uppercase letter') + if not any(c.islower() for c in v): + raise ValueError('Password must contain at least one lowercase letter') + if not any(c.isdigit() for c in v): + raise ValueError('Password must contain at least one digit') + return v + + @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def register( user_data: UserRegisterRequest, @@ -276,4 +293,39 @@ async def verify_user_token( "valid": True, "user_id": current_user["id"], "email": current_user["email"] - } \ No newline at end of file + } + + +@router.post("/change-password") +async def change_password( + password_data: ChangePasswordRequest, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """Change user password""" + + # Get user from database + stmt = select(User).where(User.id == int(current_user["id"])) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Verify current password + if not verify_password(password_data.current_password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect" + ) + + # Update password + user.hashed_password = get_password_hash(password_data.new_password) + user.updated_at = datetime.utcnow() + + await db.commit() + + return {"message": "Password changed successfully"} \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5f2d381..3b429e3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -35,10 +35,9 @@ class Settings(BaseSettings): SESSION_EXPIRE_MINUTES: int = 60 * 24 # 24 hours API_KEY_PREFIX: str = "en_" - # Admin user provisioning - ADMIN_USER: str = "admin" + # Admin user provisioning (used only on first startup) + ADMIN_EMAIL: str = "admin@example.com" ADMIN_PASSWORD: str = "admin123" - ADMIN_EMAIL: Optional[str] = None # Base URL for deriving CORS origins BASE_URL: str = "localhost" diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 6f592c6..4ce8c97 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -116,7 +116,7 @@ async def init_db(): async def create_default_admin(): - """Create default admin user if none exists""" + """Create default admin user if user with ADMIN_EMAIL doesn't exist""" from app.models.user import User from app.core.security import get_password_hash from app.core.config import settings @@ -124,19 +124,20 @@ async def create_default_admin(): try: async with async_session_factory() as session: - # Check if any admin user exists - stmt = select(User).where(User.role == "super_admin") + # Check if user with ADMIN_EMAIL exists + stmt = select(User).where(User.email == settings.ADMIN_EMAIL) result = await session.execute(stmt) - existing_admin = result.scalar_one_or_none() + existing_user = result.scalar_one_or_none() - if existing_admin: - logger.info("Admin user already exists") + if existing_user: + logger.info(f"User with email {settings.ADMIN_EMAIL} already exists - skipping admin creation") return - # Create default admin user from environment variables - admin_username = settings.ADMIN_USER + # Create admin user from environment variables + admin_email = settings.ADMIN_EMAIL admin_password = settings.ADMIN_PASSWORD - admin_email = settings.ADMIN_EMAIL or f"{admin_username}@example.com" + # Generate username from email (part before @) + admin_username = admin_email.split('@')[0] admin_user = User.create_default_admin( email=admin_email, @@ -148,10 +149,10 @@ async def create_default_admin(): await session.commit() logger.warning("=" * 60) - logger.warning("DEFAULT ADMIN USER CREATED") + logger.warning("ADMIN USER CREATED FROM ENVIRONMENT") logger.warning(f"Email: {admin_email}") logger.warning(f"Username: {admin_username}") - logger.warning("Password: [Set via ADMIN_PASSWORD environment variable]") + logger.warning("Password: [Set via ADMIN_PASSWORD - only used on first creation]") logger.warning("PLEASE CHANGE THE PASSWORD AFTER FIRST LOGIN") logger.warning("=" * 60) diff --git a/backend/app/middleware/security.py b/backend/app/middleware/security.py index 43608a2..ce86feb 100644 --- a/backend/app/middleware/security.py +++ b/backend/app/middleware/security.py @@ -104,6 +104,8 @@ class SecurityMiddleware(BaseHTTPMiddleware): "/favicon.ico", "/api/v1/auth/register", "/api/v1/auth/login", + "/api-internal/v1/auth/register", + "/api-internal/v1/auth/login", "/", # Root endpoint ] diff --git a/docker-compose.yml b/docker-compose.yml index 782c29f..9f434a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,8 +42,8 @@ services: - QDRANT_HOST=enclava-qdrant - JWT_SECRET=${JWT_SECRET:-your-jwt-secret-here} - PRIVATEMODE_API_KEY=${PRIVATEMODE_API_KEY:-} - - ADMIN_USER=${ADMIN_USER:-admin} - - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} + - ADMIN_EMAIL=${ADMIN_EMAIL} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} - LOG_LLM_PROMPTS=${LOG_LLM_PROMPTS:-false} - BASE_URL=${BASE_URL} depends_on: diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2a6d409..f95fabf 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -26,7 +26,9 @@ import { CheckCircle, Copy, AlertTriangle, - ExternalLink + ExternalLink, + Bot, + Code2 } from "lucide-react" interface DashboardStats { @@ -55,6 +57,12 @@ interface RecentActivity { type: 'info' | 'success' | 'warning' | 'error' } +interface Chatbot { + id: string + name: string + is_active: boolean +} + export default function DashboardPage() { return ( @@ -70,6 +78,7 @@ function DashboardContent() { const [modules, setModules] = useState([]) const [recentActivity, setRecentActivity] = useState([]) const [loadingStats, setLoadingStats] = useState(true) + const [chatbots, setChatbots] = useState([]) // Get the public API URL from centralized config const getPublicApiUrl = () => { @@ -94,8 +103,9 @@ function DashboardContent() { // Fetch real dashboard stats through API proxy - const [modulesRes] = await Promise.all([ - apiClient.get('/api-internal/v1/modules/').catch(() => null) + const [modulesRes, chatbotsRes] = await Promise.all([ + apiClient.get('/api-internal/v1/modules/').catch(() => null), + apiClient.get('/api-internal/v1/chatbot/instances').catch(() => null) ]) // Set default stats since analytics endpoints removed @@ -125,6 +135,13 @@ function DashboardContent() { setModules([]) } + // Parse chatbots response + if (chatbotsRes && chatbotsRes.chatbots) { + setChatbots(chatbotsRes.chatbots.filter((bot: any) => bot.is_active)) + } else { + setChatbots([]) + } + // No activity data since audit endpoint removed setRecentActivity([]) @@ -265,46 +282,104 @@ function DashboardContent() { - {/* Public API URL Section */} - - - - - OpenAI-Compatible API Endpoint - - - Configure external tools with this endpoint URL. Use any OpenAI-compatible client. - - - -
- - {config.getPublicApiUrl()} - - - -
-
- Quick Setup: Copy this URL and use it as the "API Base URL" in Open WebUI, Continue.dev, or any OpenAI client. -
-
-
+ {/* API Endpoints Section */} +
+ {/* OpenAI Compatible Endpoint */} + + + + + OpenAI-Compatible API + + + Use with any OpenAI-compatible client + + + +
+
+ + {config.getPublicApiUrl()} + + +
+
+ Use as API base URL in Open WebUI, Continue.dev, etc. +
+ +
+
+
+ + {/* Chatbot Endpoints */} + + + + + Chatbot Endpoints + + + Active chatbot instances + + + + {chatbots.length === 0 ? ( +
+

No active chatbots configured

+ +
+ ) : ( +
+
+ {chatbots.slice(0, 3).map(bot => ( +
+ {bot.name} + + Active + +
+ ))} + {chatbots.length > 3 && ( +

+{chatbots.length - 3} more

+ )} +
+ +
+ )} +
+
+
{/* Main Content Grid */}
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 5bf1cf9..027c45e 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -18,13 +18,28 @@ export default function LoginPage() { const [password, setPassword] = useState("") const [showPassword, setShowPassword] = useState(false) const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [attemptCount, setAttemptCount] = useState(0) + const [isLocked, setIsLocked] = useState(false) const { login } = useAuth() const router = useRouter() const { toast } = useToast() const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + + // Check if account is temporarily locked + if (isLocked) { + toast({ + title: "Account Temporarily Locked", + description: "Too many failed attempts. Please try again in 30 seconds.", + variant: "destructive", + }) + return + } + setIsLoading(true) + setError(null) // Clear any previous errors try { await login(email, password) @@ -32,17 +47,48 @@ export default function LoginPage() { title: "Login successful", description: "Welcome to Enclava", }) + // Reset attempt count on successful login + setAttemptCount(0) // Add a small delay to ensure token is fully stored and propagated setTimeout(() => { // For now, do a full page reload to ensure everything is initialized with the new token window.location.href = "/dashboard" }, 100) } catch (error) { - toast({ - title: "Login failed", - description: "Invalid credentials. Please try again.", - variant: "destructive", - }) + const newAttemptCount = attemptCount + 1 + setAttemptCount(newAttemptCount) + + // Lock account after 5 failed attempts + if (newAttemptCount >= 5) { + setIsLocked(true) + setError("Too many failed attempts. Account temporarily locked.") + toast({ + title: "Account Locked", + description: "Too many failed login attempts. Please wait 30 seconds before trying again.", + variant: "destructive", + }) + + // Unlock after 30 seconds + setTimeout(() => { + setIsLocked(false) + setAttemptCount(0) + setError(null) + }, 30000) + } else { + // Set error message for display in the form + const remainingAttempts = 5 - newAttemptCount + setError(`Invalid credentials. ${remainingAttempts} attempt${remainingAttempts === 1 ? '' : 's'} remaining.`) + + // Also show toast for additional feedback + toast({ + title: "Authentication Failed", + description: `Invalid credentials. ${remainingAttempts} attempt${remainingAttempts === 1 ? '' : 's'} remaining before temporary lock.`, + variant: "destructive", + }) + } + + // Clear password field for security + setPassword("") setIsLoading(false) } } @@ -65,6 +111,16 @@ export default function LoginPage() {
+ {error && ( +
+

+ + + + {error} +

+
+ )}
setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value) + setError(null) // Clear error when user starts typing + }} required - className="bg-empire-darker/50 border-empire-gold/20 focus:border-empire-gold" + className={`bg-empire-darker/50 border-empire-gold/20 focus:border-empire-gold ${ + error ? 'border-red-500/50' : '' + }`} />
@@ -85,9 +146,14 @@ export default function LoginPage() { type={showPassword ? "text" : "password"} placeholder="Enter your password" value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value) + setError(null) // Clear error when user starts typing + }} required - className="bg-empire-darker/50 border-empire-gold/20 focus:border-empire-gold pr-10" + className={`bg-empire-darker/50 border-empire-gold/20 focus:border-empire-gold pr-10 ${ + error ? 'border-red-500/50' : '' + }`} /> -
-

Demo credentials:

-

Email: admin@example.com

-

Password: admin123

-
diff --git a/frontend/src/app/test-auth/page.tsx b/frontend/src/app/test-auth/page.tsx new file mode 100644 index 0000000..5525585 --- /dev/null +++ b/frontend/src/app/test-auth/page.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useAuth } from "@/contexts/AuthContext" +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" + +export default function TestAuthPage() { + const { user, token, refreshToken, logout } = useAuth() + const [testResult, setTestResult] = useState("") + const [isLoading, setIsLoading] = useState(false) + + const testApiCall = async () => { + setIsLoading(true) + try { + const result = await apiClient.get('/api-internal/v1/auth/me') + setTestResult(`API call successful! User: ${JSON.stringify(result, null, 2)}`) + } catch (error) { + setTestResult(`API call failed: ${error}`) + } + setIsLoading(false) + } + + const getTokenInfo = () => { + if (!token) return "No token" + + const payload = decodeToken(token) + if (!payload) return "Invalid token" + + const now = Math.floor(Date.now() / 1000) + const timeUntilExpiry = payload.exp - now + const expiryDate = new Date(payload.exp * 1000) + + return ` +Token expires in: ${Math.floor(timeUntilExpiry / 60)} minutes ${timeUntilExpiry % 60} seconds +Expiry time: ${expiryDate.toLocaleString()} +Token payload: ${JSON.stringify(payload, null, 2)} + ` + } + + return ( +
+

Authentication Test Page

+ +
+ + + Current User + Logged in user information + + + {user ? ( +
+                {JSON.stringify(user, null, 2)}
+              
+ ) : ( +

Not logged in

+ )} +
+
+ + + + Token Information + Access token details and expiry + + +
+              {getTokenInfo()}
+            
+ {refreshToken && ( +

+ ✓ Refresh token available - auto-refresh enabled +

+ )} +
+
+ + + + API Test + Test authenticated API calls + + +
+ + + {testResult && ( +
+                  {testResult}
+                
+ )} +
+
+
+ + + + Auto-Refresh Test + Instructions to test auto-refresh + + +
+

1. Watch the "Token expires in" countdown above

+

2. When it reaches ~1 minute, the token will auto-refresh

+

3. You'll see the expiry time jump to 30 minutes again

+

4. API calls will continue working without re-login

+

Current token lifetime: 30 minutes

+

Refresh token lifetime: 7 days

+
+
+
+ + + + Actions + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/settings/ConfidentialityDashboard.tsx b/frontend/src/components/settings/ConfidentialityDashboard.tsx new file mode 100644 index 0000000..3eba586 --- /dev/null +++ b/frontend/src/components/settings/ConfidentialityDashboard.tsx @@ -0,0 +1,404 @@ +"use client" + +import React, { useState, useEffect } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Progress } from "@/components/ui/progress" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Shield, Lock, Activity, AlertTriangle, CheckCircle, RefreshCw } from 'lucide-react' + +interface ConfidentialityReport { + report_timestamp: string + confidence_score: number + status: string + components: { + confidentiality_status: any + encryption_metrics: any + security_audit: any + connection_test: any + proxy_status: any + } + assurances: string[] + recommendations: Array<{ + priority: string + issue: string + action: string + }> +} + +interface ConfidentialityDashboardProps { + className?: string +} + +export const ConfidentialityDashboard: React.FC = ({ + className +}) => { + const [report, setReport] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [lastUpdated, setLastUpdated] = useState(null) + + const fetchConfidentialityReport = async () => { + try { + setLoading(true) + setError(null) + + const response = await fetch('/api/v1/tee/confidentiality-report', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('api_key')}`, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch confidentiality report: ${response.status}`) + } + + const data = await response.json() + + if (data.success) { + setReport(data.data) + setLastUpdated(new Date()) + } else { + throw new Error('Failed to get confidentiality report') + } + } catch (err) { + console.error('Error fetching confidentiality report:', err) + setError(err instanceof Error ? err.message : 'Unknown error occurred') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchConfidentialityReport() + + // Auto-refresh every 5 minutes + const interval = setInterval(fetchConfidentialityReport, 5 * 60 * 1000) + return () => clearInterval(interval) + }, []) + + const getStatusColor = (status: string) => { + switch (status) { + case 'fully_protected': + return 'text-green-600 bg-green-100' + case 'well_protected': + return 'text-green-600 bg-green-100' + case 'adequately_protected': + return 'text-yellow-600 bg-yellow-100' + case 'partially_protected': + return 'text-orange-600 bg-orange-100' + case 'at_risk': + return 'text-red-600 bg-red-100' + default: + return 'text-gray-600 bg-gray-100' + } + } + + const getStatusIcon = (status: string) => { + switch (status) { + case 'fully_protected': + case 'well_protected': + return + case 'adequately_protected': + return + case 'partially_protected': + case 'at_risk': + return + default: + return + } + } + + const getPriorityColor = (priority: string) => { + switch (priority.toLowerCase()) { + case 'critical': + return 'text-red-600 bg-red-100' + case 'high': + return 'text-orange-600 bg-orange-100' + case 'medium': + return 'text-yellow-600 bg-yellow-100' + case 'low': + return 'text-blue-600 bg-blue-100' + default: + return 'text-gray-600 bg-gray-100' + } + } + + if (loading) { + return ( +
+ + + + + Confidentiality Dashboard + + + +
+ + Loading confidentiality report... +
+
+
+
+ ) + } + + if (error || !report) { + return ( +
+ + + + + Confidentiality Dashboard + + + + + + + {error || 'Failed to load confidentiality report. Please try again.'} + + + + + +
+ ) + } + + return ( +
+ {/* Header Card */} + + +
+
+ + + Confidentiality Dashboard + + + Real-time confidentiality protection status and assurances + +
+ +
+
+ +
+ {/* Overall Status */} +
+
+ {getStatusIcon(report.status)} + Protection Status +
+ + {report.status.replace('_', ' ').toUpperCase()} + +
+ + {/* Confidence Score */} +
+
+ + Confidence Score +
+
+ + + {report.confidence_score}% confident + +
+
+ + {/* Last Updated */} +
+
+ + Last Updated +
+ + {lastUpdated?.toLocaleString()} + +
+
+
+
+ + {/* Detailed Information Tabs */} + + + Overview + Assurances + Details + Actions + + + +
+ {/* Connection Status */} + + + Proxy Connection + + +
+
+ Status + + {report.components.connection_test?.connected ? 'Connected' : 'Disconnected'} + +
+ {report.components.connection_test?.response_time_ms && ( +
+ Response Time + + {report.components.connection_test.response_time_ms}ms + +
+ )} +
+ TLS Enabled + + {report.components.connection_test?.tls_enabled ? 'Yes' : 'No'} + +
+
+
+
+ + {/* Encryption Status */} + + + Encryption + + +
+
+ Status + + {report.components.encryption_metrics?.encryption_strength || 'Unknown'} + +
+ {report.components.encryption_metrics?.encryption_metrics?.encryption?.cipher_suite && ( +
+
+ Algorithm + + {report.components.encryption_metrics.encryption_metrics.encryption.cipher_suite.algorithm} + +
+
+ Key Size + + {report.components.encryption_metrics.encryption_metrics.encryption.cipher_suite.key_size} bits + +
+
+ )} +
+
+
+
+
+ + + + + Confidentiality Assurances + + What we can guarantee about your data protection + + + +
+ {report.assurances.map((assurance, index) => ( +
+ + {assurance} +
+ ))} +
+
+
+
+ + + + + Technical Details + + +
+
+                  {JSON.stringify(report.components, null, 2)}
+                
+
+
+
+
+ + + + + Recommendations + + Actions to improve your confidentiality protection + + + +
+ {report.recommendations.map((rec, index) => ( +
+
+ + {rec.priority.toUpperCase()} + + {rec.issue} +
+

{rec.action}

+
+ ))} +
+
+
+
+
+
+ ) +} + +export default ConfidentialityDashboard \ No newline at end of file diff --git a/frontend/src/components/ui/navigation.tsx b/frontend/src/components/ui/navigation.tsx index 7ca0bea..3743a69 100644 --- a/frontend/src/components/ui/navigation.tsx +++ b/frontend/src/components/ui/navigation.tsx @@ -7,6 +7,7 @@ import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { ThemeToggle } from "@/components/ui/theme-toggle" +import { UserMenu } from "@/components/ui/user-menu" import { useAuth } from "@/contexts/AuthContext" import { useModules } from "@/contexts/ModulesContext" import { usePlugin } from "@/contexts/PluginContext" @@ -157,19 +158,7 @@ const Navigation = () => { {isClient && user ? ( -
- - {user.email} - - -
+ ) : isClient ? (
+ + +
+
+

{user.name}

+

+ {user.email} +

+
+
+ + setIsPasswordDialogOpen(true)}> + + Change Password + + + + + Logout + +
+ + + {/* Password Change Dialog - Outside dropdown to avoid nesting issues */} + + + + Change Password + + Enter your current password and choose a new secure password. + + +
+
+ + setPasswordData(prev => ({ + ...prev, + currentPassword: e.target.value + }))} + required + /> +
+
+ + setPasswordData(prev => ({ + ...prev, + newPassword: e.target.value + }))} + required + /> +
+
+ + setPasswordData(prev => ({ + ...prev, + confirmPassword: e.target.value + }))} + required + /> +
+
+ + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index b3e4299..497b3ff 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,7 +1,16 @@ "use client" -import { createContext, useContext, useState, useEffect, ReactNode } from "react" +import { createContext, useContext, useState, useEffect, ReactNode, useRef } from "react" import { useRouter } from "next/navigation" +import { + isTokenExpired, + refreshAccessToken, + storeTokens, + getStoredTokens, + clearTokens, + setupTokenRefreshTimer, + decodeToken +} from "@/lib/auth-utils" interface User { id: string @@ -13,74 +22,177 @@ interface User { interface AuthContextType { user: User | null token: string | null + refreshToken: string | null login: (email: string, password: string) => Promise logout: () => void isLoading: boolean + refreshTokenIfNeeded: () => Promise } const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: ReactNode }) { - // Initialize state with values from localStorage if available (synchronous) - const getInitialAuth = () => { - if (typeof window !== "undefined") { - const storedToken = localStorage.getItem("token") - if (storedToken) { - // Ensure we have the correct token - const freshToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsImlzX3N1cGVydXNlciI6dHJ1ZSwicm9sZSI6InN1cGVyX2FkbWluIiwiZXhwIjoxNzU2NjE4Mzk2fQ.DFZOtAzJbpF_PcKhj2DWRDXUvTKFss-8lEt5H3ST2r0" - localStorage.setItem("token", freshToken) - return { - user: { - id: "1", - email: "admin@example.com", - name: "Admin User", - role: "admin" - }, - token: freshToken + 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 + 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) } } + + setIsLoading(false) } - return { user: null, token: null } + + 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() + } + ) } - const initialAuth = getInitialAuth() - const [user, setUser] = useState(initialAuth.user) - const [token, setToken] = useState(initialAuth.token) - const [isLoading, setIsLoading] = useState(false) // Not loading if we already have auth - const router = useRouter() + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current) + } + } + }, []) const login = async (email: string, password: string) => { setIsLoading(true) try { - // Demo authentication - in real app, this would call the backend - if ((email === "admin@example.com" || email === "admin@localhost") && password === "admin123") { - const demoUser = { - id: "1", - email: email, - name: "Admin User", - role: "admin" - } - - const authToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsImlzX3N1cGVydXNlciI6dHJ1ZSwicm9sZSI6InN1cGVyX2FkbWluIiwiZXhwIjoxNzU2NjE4Mzk2fQ.DFZOtAzJbpF_PcKhj2DWRDXUvTKFss-8lEt5H3ST2r0" - - // Store in localStorage first to ensure it's immediately available - if (typeof window !== "undefined") { - // Use the actual JWT token for API calls - localStorage.setItem("token", authToken) - localStorage.setItem("user", JSON.stringify(demoUser)) - } - - // Then update state - setUser(demoUser) - setToken(authToken) - - // Wait a tick to ensure state has propagated - await new Promise(resolve => setTimeout(resolve, 50)) - } else { - throw new Error("Invalid credentials") + // Call real backend login endpoint + const response = await fetch('/api-internal/v1/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || 'Invalid credentials') } + + const data = await response.json() + + // Store tokens + storeTokens(data.access_token, data.refresh_token) + + // 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) + } + } + + setToken(data.access_token) + setRefreshToken(data.refresh_token) + + // Setup refresh timer + setupRefreshTimer(data.access_token, data.refresh_token) + } catch (error) { + console.error('Login error:', error) throw error } finally { setIsLoading(false) @@ -88,17 +200,58 @@ 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) - if (typeof window !== "undefined") { - localStorage.removeItem("token") - localStorage.removeItem("user") - } + 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 + } + return ( - + {children} ) @@ -106,17 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { export function useAuth() { const context = useContext(AuthContext) - if (context === undefined) { - // During SSR/SSG, return default values instead of throwing - if (typeof window === "undefined") { - return { - user: null, - token: null, - login: async () => {}, - logout: () => {}, - isLoading: true - } - } + if (!context) { throw new Error("useAuth must be used within an AuthProvider") } return context