fixing login to not display the demo creds

This commit is contained in:
2025-09-06 12:10:39 +02:00
parent befe96c20b
commit 3904d7e88f
13 changed files with 1282 additions and 195 deletions

View File

@@ -1,52 +1,71 @@
# Database # ===================================
DATABASE_URL=postgresql://your_user:your_password@localhost:5432/your_db # ENCLAVA MINIMAL CONFIGURATION
REDIS_URL=redis://localhost:6379 # ===================================
# 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 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) # Admin user (created on first startup only)
PRIVATEMODE_API_KEY=your-privatemode-api-key ADMIN_EMAIL=admin@example.com
PRIVATEMODE_CACHE_MODE=none ADMIN_PASSWORD=admin123
PRIVATEMODE_CACHE_SALT=
# Application Configuration # ===================================
APP_NAME=Enclava # APPLICATION BASE URL (Required - derives all URLs and CORS)
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)
BASE_URL=localhost 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 BACKEND_INTERNAL_PORT=8000
FRONTEND_INTERNAL_PORT=3000 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 # QDRANT (Required for RAG)
NEXT_PUBLIC_API_RETRY_ATTEMPTS=3 # ===================================
NEXT_PUBLIC_API_RETRY_DELAY=1000 QDRANT_HOST=enclava-qdrant
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_PORT=6333 QDRANT_PORT=6333
QDRANT_API_KEY= QDRANT_URL=http://enclava-qdrant:6333
QDRANT_URL=http://localhost:6333
# Security # ===================================
RATE_LIMIT_ENABLED=true # OPTIONAL PRIVATEMODE SETTINGS (Have defaults)
# CORS_ORIGINS is now derived from BASE_URL automatically # ===================================
# PRIVATEMODE_CACHE_MODE=none # Optional: defaults to 'none'
# PRIVATEMODE_CACHE_SALT= # Optional: defaults to empty
# Monitoring # ===================================
PROMETHEUS_ENABLED=true # MOVED TO APP SETTINGS (Optional - have defaults)
PROMETHEUS_PORT=9090 # ===================================
# 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!)
# ===================================

View File

@@ -88,6 +88,23 @@ class RefreshTokenRequest(BaseModel):
refresh_token: str 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) @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register( async def register(
user_data: UserRegisterRequest, user_data: UserRegisterRequest,
@@ -276,4 +293,39 @@ async def verify_user_token(
"valid": True, "valid": True,
"user_id": current_user["id"], "user_id": current_user["id"],
"email": current_user["email"] "email": current_user["email"]
} }
@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"}

View File

@@ -35,10 +35,9 @@ class Settings(BaseSettings):
SESSION_EXPIRE_MINUTES: int = 60 * 24 # 24 hours SESSION_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
API_KEY_PREFIX: str = "en_" API_KEY_PREFIX: str = "en_"
# Admin user provisioning # Admin user provisioning (used only on first startup)
ADMIN_USER: str = "admin" ADMIN_EMAIL: str = "admin@example.com"
ADMIN_PASSWORD: str = "admin123" ADMIN_PASSWORD: str = "admin123"
ADMIN_EMAIL: Optional[str] = None
# Base URL for deriving CORS origins # Base URL for deriving CORS origins
BASE_URL: str = "localhost" BASE_URL: str = "localhost"

View File

@@ -116,7 +116,7 @@ async def init_db():
async def create_default_admin(): 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.models.user import User
from app.core.security import get_password_hash from app.core.security import get_password_hash
from app.core.config import settings from app.core.config import settings
@@ -124,19 +124,20 @@ async def create_default_admin():
try: try:
async with async_session_factory() as session: async with async_session_factory() as session:
# Check if any admin user exists # Check if user with ADMIN_EMAIL exists
stmt = select(User).where(User.role == "super_admin") stmt = select(User).where(User.email == settings.ADMIN_EMAIL)
result = await session.execute(stmt) result = await session.execute(stmt)
existing_admin = result.scalar_one_or_none() existing_user = result.scalar_one_or_none()
if existing_admin: if existing_user:
logger.info("Admin user already exists") logger.info(f"User with email {settings.ADMIN_EMAIL} already exists - skipping admin creation")
return return
# Create default admin user from environment variables # Create admin user from environment variables
admin_username = settings.ADMIN_USER admin_email = settings.ADMIN_EMAIL
admin_password = settings.ADMIN_PASSWORD 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( admin_user = User.create_default_admin(
email=admin_email, email=admin_email,
@@ -148,10 +149,10 @@ async def create_default_admin():
await session.commit() await session.commit()
logger.warning("=" * 60) 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"Email: {admin_email}")
logger.warning(f"Username: {admin_username}") 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("PLEASE CHANGE THE PASSWORD AFTER FIRST LOGIN")
logger.warning("=" * 60) logger.warning("=" * 60)

View File

@@ -104,6 +104,8 @@ class SecurityMiddleware(BaseHTTPMiddleware):
"/favicon.ico", "/favicon.ico",
"/api/v1/auth/register", "/api/v1/auth/register",
"/api/v1/auth/login", "/api/v1/auth/login",
"/api-internal/v1/auth/register",
"/api-internal/v1/auth/login",
"/", # Root endpoint "/", # Root endpoint
] ]

View File

@@ -42,8 +42,8 @@ services:
- QDRANT_HOST=enclava-qdrant - QDRANT_HOST=enclava-qdrant
- JWT_SECRET=${JWT_SECRET:-your-jwt-secret-here} - JWT_SECRET=${JWT_SECRET:-your-jwt-secret-here}
- PRIVATEMODE_API_KEY=${PRIVATEMODE_API_KEY:-} - PRIVATEMODE_API_KEY=${PRIVATEMODE_API_KEY:-}
- ADMIN_USER=${ADMIN_USER:-admin} - ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- LOG_LLM_PROMPTS=${LOG_LLM_PROMPTS:-false} - LOG_LLM_PROMPTS=${LOG_LLM_PROMPTS:-false}
- BASE_URL=${BASE_URL} - BASE_URL=${BASE_URL}
depends_on: depends_on:

View File

@@ -26,7 +26,9 @@ import {
CheckCircle, CheckCircle,
Copy, Copy,
AlertTriangle, AlertTriangle,
ExternalLink ExternalLink,
Bot,
Code2
} from "lucide-react" } from "lucide-react"
interface DashboardStats { interface DashboardStats {
@@ -55,6 +57,12 @@ interface RecentActivity {
type: 'info' | 'success' | 'warning' | 'error' type: 'info' | 'success' | 'warning' | 'error'
} }
interface Chatbot {
id: string
name: string
is_active: boolean
}
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
<ProtectedRoute> <ProtectedRoute>
@@ -70,6 +78,7 @@ function DashboardContent() {
const [modules, setModules] = useState<ModuleInfo[]>([]) const [modules, setModules] = useState<ModuleInfo[]>([])
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([]) const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([])
const [loadingStats, setLoadingStats] = useState(true) const [loadingStats, setLoadingStats] = useState(true)
const [chatbots, setChatbots] = useState<Chatbot[]>([])
// Get the public API URL from centralized config // Get the public API URL from centralized config
const getPublicApiUrl = () => { const getPublicApiUrl = () => {
@@ -94,8 +103,9 @@ function DashboardContent() {
// Fetch real dashboard stats through API proxy // Fetch real dashboard stats through API proxy
const [modulesRes] = await Promise.all([ const [modulesRes, chatbotsRes] = await Promise.all([
apiClient.get('/api-internal/v1/modules/').catch(() => null) apiClient.get('/api-internal/v1/modules/').catch(() => null),
apiClient.get('/api-internal/v1/chatbot/instances').catch(() => null)
]) ])
// Set default stats since analytics endpoints removed // Set default stats since analytics endpoints removed
@@ -125,6 +135,13 @@ function DashboardContent() {
setModules([]) 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 // No activity data since audit endpoint removed
setRecentActivity([]) setRecentActivity([])
@@ -265,46 +282,104 @@ function DashboardContent() {
</Card> </Card>
</div> </div>
{/* Public API URL Section */} {/* API Endpoints Section */}
<Card className="bg-blue-50 border-blue-200"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CardHeader> {/* OpenAI Compatible Endpoint */}
<CardTitle className="flex items-center gap-2 text-blue-700"> <Card className="bg-empire-darker/50 border-empire-gold/20">
<ExternalLink className="h-5 w-5" /> <CardHeader>
OpenAI-Compatible API Endpoint <CardTitle className="flex items-center gap-2 text-empire-gold">
</CardTitle> <Code2 className="h-5 w-5" />
<CardDescription className="text-blue-600"> OpenAI-Compatible API
Configure external tools with this endpoint URL. Use any OpenAI-compatible client. </CardTitle>
</CardDescription> <CardDescription className="text-empire-gold/60">
</CardHeader> Use with any OpenAI-compatible client
<CardContent> </CardDescription>
<div className="flex items-center gap-3"> </CardHeader>
<code className="flex-1 p-3 bg-white border border-blue-200 rounded-md text-sm font-mono"> <CardContent>
{config.getPublicApiUrl()} <div className="space-y-3">
</code> <div className="flex items-center gap-2">
<Button <code className="flex-1 p-2 bg-empire-dark/50 border border-empire-gold/10 rounded text-xs font-mono text-empire-gold/80">
onClick={() => copyToClipboard(config.getPublicApiUrl())} {config.getPublicApiUrl()}
variant="outline" </code>
size="sm" <Button
className="flex items-center gap-1 border-blue-300 text-blue-700 hover:bg-blue-100" onClick={() => copyToClipboard(config.getPublicApiUrl())}
> variant="ghost"
<Copy className="h-4 w-4" /> size="sm"
Copy className="text-empire-gold hover:bg-empire-gold/10"
</Button> >
<Button <Copy className="h-3 w-3" />
onClick={() => window.open('/llm', '_blank')} </Button>
variant="outline" </div>
size="sm" <div className="text-xs text-empire-gold/50">
className="flex items-center gap-1 border-blue-300 text-blue-700 hover:bg-blue-100" Use as API base URL in Open WebUI, Continue.dev, etc.
> </div>
<Settings className="h-4 w-4" /> <Button
Configure onClick={() => window.open('/api-keys', '_blank')}
</Button> variant="outline"
</div> size="sm"
<div className="mt-3 text-sm text-blue-600"> className="w-full border-empire-gold/20 text-empire-gold hover:bg-empire-gold/10"
<span className="font-medium">Quick Setup:</span> Copy this URL and use it as the "API Base URL" in Open WebUI, Continue.dev, or any OpenAI client. >
</div> <Settings className="h-3 w-3 mr-2" />
</CardContent> Manage API Keys
</Card> </Button>
</div>
</CardContent>
</Card>
{/* Chatbot Endpoints */}
<Card className="bg-empire-darker/50 border-empire-gold/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-empire-gold">
<Bot className="h-5 w-5" />
Chatbot Endpoints
</CardTitle>
<CardDescription className="text-empire-gold/60">
Active chatbot instances
</CardDescription>
</CardHeader>
<CardContent>
{chatbots.length === 0 ? (
<div className="space-y-3">
<p className="text-sm text-empire-gold/40">No active chatbots configured</p>
<Button
onClick={() => window.open('/chatbot', '_blank')}
variant="outline"
size="sm"
className="w-full border-empire-gold/20 text-empire-gold hover:bg-empire-gold/10"
>
<Plus className="h-3 w-3 mr-2" />
Create Chatbot
</Button>
</div>
) : (
<div className="space-y-3">
<div className="space-y-2 max-h-32 overflow-y-auto">
{chatbots.slice(0, 3).map(bot => (
<div key={bot.id} className="flex items-center justify-between p-2 bg-empire-dark/30 rounded">
<span className="text-xs text-empire-gold/70">{bot.name}</span>
<Badge variant="outline" className="border-green-500/20 text-green-400 text-xs">
Active
</Badge>
</div>
))}
{chatbots.length > 3 && (
<p className="text-xs text-empire-gold/40">+{chatbots.length - 3} more</p>
)}
</div>
<Button
onClick={() => window.open('/chatbot', '_blank')}
variant="outline"
size="sm"
className="w-full border-empire-gold/20 text-empire-gold hover:bg-empire-gold/10"
>
<Settings className="h-3 w-3 mr-2" />
Manage Chatbots
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{/* Main Content Grid */} {/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">

View File

@@ -18,13 +18,28 @@ export default function LoginPage() {
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [attemptCount, setAttemptCount] = useState(0)
const [isLocked, setIsLocked] = useState(false)
const { login } = useAuth() const { login } = useAuth()
const router = useRouter() const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() 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) setIsLoading(true)
setError(null) // Clear any previous errors
try { try {
await login(email, password) await login(email, password)
@@ -32,17 +47,48 @@ export default function LoginPage() {
title: "Login successful", title: "Login successful",
description: "Welcome to Enclava", description: "Welcome to Enclava",
}) })
// Reset attempt count on successful login
setAttemptCount(0)
// Add a small delay to ensure token is fully stored and propagated // Add a small delay to ensure token is fully stored and propagated
setTimeout(() => { setTimeout(() => {
// For now, do a full page reload to ensure everything is initialized with the new token // For now, do a full page reload to ensure everything is initialized with the new token
window.location.href = "/dashboard" window.location.href = "/dashboard"
}, 100) }, 100)
} catch (error) { } catch (error) {
toast({ const newAttemptCount = attemptCount + 1
title: "Login failed", setAttemptCount(newAttemptCount)
description: "Invalid credentials. Please try again.",
variant: "destructive", // 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) setIsLoading(false)
} }
} }
@@ -65,6 +111,16 @@ export default function LoginPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 rounded-md bg-red-500/10 border border-red-500/20">
<p className="text-sm text-red-500 flex items-center gap-2">
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
{error}
</p>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
@@ -72,9 +128,14 @@ export default function LoginPage() {
type="email" type="email"
placeholder="Enter your email" placeholder="Enter your email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
setEmail(e.target.value)
setError(null) // Clear error when user starts typing
}}
required 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' : ''
}`}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -85,9 +146,14 @@ export default function LoginPage() {
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="Enter your password" placeholder="Enter your password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
setPassword(e.target.value)
setError(null) // Clear error when user starts typing
}}
required 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' : ''
}`}
/> />
<Button <Button
type="button" type="button"
@@ -106,17 +172,12 @@ export default function LoginPage() {
</div> </div>
<Button <Button
type="submit" type="submit"
className="w-full bg-empire-gold hover:bg-empire-gold/90 text-empire-dark" className="w-full bg-empire-gold hover:bg-empire-gold/90 text-empire-dark disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoading} disabled={isLoading || isLocked}
> >
{isLoading ? "Signing in..." : "Sign in"} {isLocked ? "Account Locked (30s)" : isLoading ? "Signing in..." : "Sign in"}
</Button> </Button>
</form> </form>
<div className="mt-6 text-center text-sm text-empire-gold/60">
<p>Demo credentials:</p>
<p>Email: admin@example.com</p>
<p>Password: admin123</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -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<string>("")
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 (
<div className="container mx-auto p-6 max-w-4xl">
<h1 className="text-3xl font-bold mb-6">Authentication Test Page</h1>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Current User</CardTitle>
<CardDescription>Logged in user information</CardDescription>
</CardHeader>
<CardContent>
{user ? (
<pre className="bg-gray-100 dark:bg-gray-800 p-4 rounded">
{JSON.stringify(user, null, 2)}
</pre>
) : (
<p>Not logged in</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Token Information</CardTitle>
<CardDescription>Access token details and expiry</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-gray-100 dark:bg-gray-800 p-4 rounded text-sm">
{getTokenInfo()}
</pre>
{refreshToken && (
<p className="mt-2 text-sm text-green-600">
Refresh token available - auto-refresh enabled
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Test</CardTitle>
<CardDescription>Test authenticated API calls</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Button
onClick={testApiCall}
disabled={isLoading}
className="w-full"
>
{isLoading ? "Testing..." : "Test API Call"}
</Button>
{testResult && (
<pre className="bg-gray-100 dark:bg-gray-800 p-4 rounded text-sm">
{testResult}
</pre>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Auto-Refresh Test</CardTitle>
<CardDescription>Instructions to test auto-refresh</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p>1. Watch the "Token expires in" countdown above</p>
<p>2. When it reaches ~1 minute, the token will auto-refresh</p>
<p>3. You'll see the expiry time jump to 30 minutes again</p>
<p>4. API calls will continue working without re-login</p>
<p className="mt-4 font-semibold">Current token lifetime: 30 minutes</p>
<p className="font-semibold">Refresh token lifetime: 7 days</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Actions</CardTitle>
</CardHeader>
<CardContent>
<Button
onClick={logout}
variant="destructive"
className="w-full"
>
Logout
</Button>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -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<ConfidentialityDashboardProps> = ({
className
}) => {
const [report, setReport] = useState<ConfidentialityReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(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 <CheckCircle className="w-4 h-4" />
case 'adequately_protected':
return <Shield className="w-4 h-4" />
case 'partially_protected':
case 'at_risk':
return <AlertTriangle className="w-4 h-4" />
default:
return <Activity className="w-4 h-4" />
}
}
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 (
<div className={`space-y-4 ${className}`}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Confidentiality Dashboard
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-8 h-8 animate-spin text-gray-400" />
<span className="ml-2 text-gray-500">Loading confidentiality report...</span>
</div>
</CardContent>
</Card>
</div>
)
}
if (error || !report) {
return (
<div className={`space-y-4 ${className}`}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Confidentiality Dashboard
</CardTitle>
</CardHeader>
<CardContent>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{error || 'Failed to load confidentiality report. Please try again.'}
</AlertDescription>
</Alert>
<Button
onClick={fetchConfidentialityReport}
className="mt-4"
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Retry
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className={`space-y-6 ${className}`}>
{/* Header Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Confidentiality Dashboard
</CardTitle>
<CardDescription>
Real-time confidentiality protection status and assurances
</CardDescription>
</div>
<Button
onClick={fetchConfidentialityReport}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Overall Status */}
<div className="space-y-2">
<div className="flex items-center gap-2">
{getStatusIcon(report.status)}
<span className="font-medium">Protection Status</span>
</div>
<Badge className={getStatusColor(report.status)}>
{report.status.replace('_', ' ').toUpperCase()}
</Badge>
</div>
{/* Confidence Score */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" />
<span className="font-medium">Confidence Score</span>
</div>
<div className="space-y-1">
<Progress value={report.confidence_score} className="h-2" />
<span className="text-sm text-gray-500">
{report.confidence_score}% confident
</span>
</div>
</div>
{/* Last Updated */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4" />
<span className="font-medium">Last Updated</span>
</div>
<span className="text-sm text-gray-600">
{lastUpdated?.toLocaleString()}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Detailed Information Tabs */}
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="assurances">Assurances</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="recommendations">Actions</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Connection Status */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Proxy Connection</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span>Status</span>
<Badge className={
report.components.connection_test?.connected
? "text-green-600 bg-green-100"
: "text-red-600 bg-red-100"
}>
{report.components.connection_test?.connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
{report.components.connection_test?.response_time_ms && (
<div className="flex items-center justify-between">
<span>Response Time</span>
<span className="text-sm font-mono">
{report.components.connection_test.response_time_ms}ms
</span>
</div>
)}
<div className="flex items-center justify-between">
<span>TLS Enabled</span>
<Badge className={
report.components.connection_test?.tls_enabled
? "text-green-600 bg-green-100"
: "text-yellow-600 bg-yellow-100"
}>
{report.components.connection_test?.tls_enabled ? 'Yes' : 'No'}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Encryption Status */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Encryption</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span>Status</span>
<Badge className={
report.components.encryption_metrics?.encryption_strength === 'strong'
? "text-green-600 bg-green-100"
: "text-yellow-600 bg-yellow-100"
}>
{report.components.encryption_metrics?.encryption_strength || 'Unknown'}
</Badge>
</div>
{report.components.encryption_metrics?.encryption_metrics?.encryption?.cipher_suite && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span>Algorithm</span>
<span className="text-sm font-mono">
{report.components.encryption_metrics.encryption_metrics.encryption.cipher_suite.algorithm}
</span>
</div>
<div className="flex items-center justify-between">
<span>Key Size</span>
<span className="text-sm font-mono">
{report.components.encryption_metrics.encryption_metrics.encryption.cipher_suite.key_size} bits
</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="assurances">
<Card>
<CardHeader>
<CardTitle className="text-lg">Confidentiality Assurances</CardTitle>
<CardDescription>
What we can guarantee about your data protection
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{report.assurances.map((assurance, index) => (
<div key={index} className="flex items-start gap-3 p-3 bg-green-50 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
<span className="text-green-800">{assurance}</span>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="details">
<Card>
<CardHeader>
<CardTitle className="text-lg">Technical Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<pre className="bg-gray-50 p-4 rounded-lg overflow-auto text-sm">
{JSON.stringify(report.components, null, 2)}
</pre>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="recommendations">
<Card>
<CardHeader>
<CardTitle className="text-lg">Recommendations</CardTitle>
<CardDescription>
Actions to improve your confidentiality protection
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{report.recommendations.map((rec, index) => (
<div key={index} className="p-4 border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Badge className={getPriorityColor(rec.priority)}>
{rec.priority.toUpperCase()}
</Badge>
<span className="font-medium">{rec.issue}</span>
</div>
<p className="text-gray-600">{rec.action}</p>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
export default ConfidentialityDashboard

View File

@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { ThemeToggle } from "@/components/ui/theme-toggle" import { ThemeToggle } from "@/components/ui/theme-toggle"
import { UserMenu } from "@/components/ui/user-menu"
import { useAuth } from "@/contexts/AuthContext" import { useAuth } from "@/contexts/AuthContext"
import { useModules } from "@/contexts/ModulesContext" import { useModules } from "@/contexts/ModulesContext"
import { usePlugin } from "@/contexts/PluginContext" import { usePlugin } from "@/contexts/PluginContext"
@@ -157,19 +158,7 @@ const Navigation = () => {
<ThemeToggle /> <ThemeToggle />
{isClient && user ? ( {isClient && user ? (
<div className="flex items-center space-x-2"> <UserMenu />
<Badge variant="secondary" className="hidden sm:inline-flex">
{user.email}
</Badge>
<Button
variant="outline"
size="sm"
onClick={logout}
className="h-8"
>
Logout
</Button>
</div>
) : isClient ? ( ) : isClient ? (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button

View File

@@ -0,0 +1,203 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { useAuth } from "@/contexts/AuthContext"
import { useToast } from "@/hooks/use-toast"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { User, Settings, Lock, LogOut, ChevronDown } from "lucide-react"
import { useState } from "react"
export function UserMenu() {
const { user, logout } = useAuth()
const { toast } = useToast()
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false)
const [passwordData, setPasswordData] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: ""
})
const [isChangingPassword, setIsChangingPassword] = useState(false)
if (!user) {
return null
}
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault()
if (passwordData.newPassword !== passwordData.confirmPassword) {
toast({
title: "Error",
description: "New passwords do not match",
variant: "destructive"
})
return
}
if (passwordData.newPassword.length < 8) {
toast({
title: "Error",
description: "Password must be at least 8 characters long",
variant: "destructive"
})
return
}
setIsChangingPassword(true)
try {
const response = await fetch('/api-internal/v1/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
current_password: passwordData.currentPassword,
new_password: passwordData.newPassword
})
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Failed to change password')
}
toast({
title: "Success",
description: "Password changed successfully"
})
setIsPasswordDialogOpen(false)
setPasswordData({
currentPassword: "",
newPassword: "",
confirmPassword: ""
})
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Failed to change password",
variant: "destructive"
})
} finally {
setIsChangingPassword(false)
}
}
return (
<div className="flex items-center space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center space-x-2 h-8">
<Badge variant="secondary" className="hidden sm:inline-flex">
{user.email}
</Badge>
<div className="sm:hidden">
<User className="h-4 w-4" />
</div>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center justify-start space-x-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
<p className="font-medium">{user.name}</p>
<p className="w-[200px] truncate text-sm text-muted-foreground">
{user.email}
</p>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setIsPasswordDialogOpen(true)}>
<Lock className="mr-2 h-4 w-4" />
Change Password
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="text-red-600">
<LogOut className="mr-2 h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Password Change Dialog - Outside dropdown to avoid nesting issues */}
<Dialog open={isPasswordDialogOpen} onOpenChange={setIsPasswordDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Change Password</DialogTitle>
<DialogDescription>
Enter your current password and choose a new secure password.
</DialogDescription>
</DialogHeader>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={passwordData.currentPassword}
onChange={(e) => setPasswordData(prev => ({
...prev,
currentPassword: e.target.value
}))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={passwordData.newPassword}
onChange={(e) => setPasswordData(prev => ({
...prev,
newPassword: e.target.value
}))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={passwordData.confirmPassword}
onChange={(e) => setPasswordData(prev => ({
...prev,
confirmPassword: e.target.value
}))}
required
/>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setIsPasswordDialogOpen(false)}
disabled={isChangingPassword}
>
Cancel
</Button>
<Button type="submit" disabled={isChangingPassword}>
{isChangingPassword ? "Changing..." : "Change Password"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,7 +1,16 @@
"use client" "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 { useRouter } from "next/navigation"
import {
isTokenExpired,
refreshAccessToken,
storeTokens,
getStoredTokens,
clearTokens,
setupTokenRefreshTimer,
decodeToken
} from "@/lib/auth-utils"
interface User { interface User {
id: string id: string
@@ -13,74 +22,177 @@ interface User {
interface AuthContextType { interface AuthContextType {
user: User | null user: User | null
token: string | null token: string | null
refreshToken: string | null
login: (email: string, password: string) => Promise<void> login: (email: string, password: string) => Promise<void>
logout: () => void logout: () => void
isLoading: boolean isLoading: boolean
refreshTokenIfNeeded: () => Promise<boolean>
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined) const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
// Initialize state with values from localStorage if available (synchronous) const [user, setUser] = useState<User | null>(null)
const getInitialAuth = () => { const [token, setToken] = useState<string | null>(null)
if (typeof window !== "undefined") { const [refreshToken, setRefreshToken] = useState<string | null>(null)
const storedToken = localStorage.getItem("token") const [isLoading, setIsLoading] = useState(true)
if (storedToken) { const router = useRouter()
// Ensure we have the correct token const refreshTimerRef = useRef<NodeJS.Timeout | null>(null)
const freshToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsImlzX3N1cGVydXNlciI6dHJ1ZSwicm9sZSI6InN1cGVyX2FkbWluIiwiZXhwIjoxNzU2NjE4Mzk2fQ.DFZOtAzJbpF_PcKhj2DWRDXUvTKFss-8lEt5H3ST2r0"
localStorage.setItem("token", freshToken) // Initialize auth state from localStorage
return { useEffect(() => {
user: { const initAuth = async () => {
id: "1", if (typeof window === "undefined") return
email: "admin@example.com",
name: "Admin User", // Don't try to refresh on auth-related pages
role: "admin" const isAuthPage = window.location.pathname === '/login' ||
}, window.location.pathname === '/register' ||
token: freshToken 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() // Cleanup timer on unmount
const [user, setUser] = useState<User | null>(initialAuth.user) useEffect(() => {
const [token, setToken] = useState<string | null>(initialAuth.token) return () => {
const [isLoading, setIsLoading] = useState(false) // Not loading if we already have auth if (refreshTimerRef.current) {
const router = useRouter() clearTimeout(refreshTimerRef.current)
}
}
}, [])
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
setIsLoading(true) setIsLoading(true)
try { try {
// Demo authentication - in real app, this would call the backend // Call real backend login endpoint
if ((email === "admin@example.com" || email === "admin@localhost") && password === "admin123") { const response = await fetch('/api-internal/v1/auth/login', {
const demoUser = { method: 'POST',
id: "1", headers: {
email: email, 'Content-Type': 'application/json',
name: "Admin User", },
role: "admin" body: JSON.stringify({ email, password }),
} })
const authToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsImlzX3N1cGVydXNlciI6dHJ1ZSwicm9sZSI6InN1cGVyX2FkbWluIiwiZXhwIjoxNzU2NjE4Mzk2fQ.DFZOtAzJbpF_PcKhj2DWRDXUvTKFss-8lEt5H3ST2r0" if (!response.ok) {
const error = await response.json()
// Store in localStorage first to ensure it's immediately available throw new Error(error.detail || 'Invalid credentials')
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")
} }
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) { } catch (error) {
console.error('Login error:', error)
throw error throw error
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -88,17 +200,58 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
const logout = () => { const logout = () => {
// Clear refresh timer
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current)
refreshTimerRef.current = null
}
// Clear state
setUser(null) setUser(null)
setToken(null) setToken(null)
if (typeof window !== "undefined") { setRefreshToken(null)
localStorage.removeItem("token")
localStorage.removeItem("user") // Clear localStorage
} clearTokens()
// Redirect to login
router.push("/login") router.push("/login")
} }
const refreshTokenIfNeeded = async (): Promise<boolean> => {
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 ( return (
<AuthContext.Provider value={{ user, token, login, logout, isLoading }}> <AuthContext.Provider
value={{
user,
token,
refreshToken,
login,
logout,
isLoading,
refreshTokenIfNeeded
}}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
@@ -106,17 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
export function useAuth() { export function useAuth() {
const context = useContext(AuthContext) const context = useContext(AuthContext)
if (context === undefined) { if (!context) {
// During SSR/SSG, return default values instead of throwing
if (typeof window === "undefined") {
return {
user: null,
token: null,
login: async () => {},
logout: () => {},
isLoading: true
}
}
throw new Error("useAuth must be used within an AuthProvider") throw new Error("useAuth must be used within an AuthProvider")
} }
return context return context