mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 07:24:34 +01:00
fixing login to not display the demo creds
This commit is contained in:
99
.env.example
99
.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!)
|
||||
# ===================================
|
||||
@@ -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,
|
||||
@@ -277,3 +294,38 @@ async def verify_user_token(
|
||||
"user_id": current_user["id"],
|
||||
"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"}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
<ProtectedRoute>
|
||||
@@ -70,6 +78,7 @@ function DashboardContent() {
|
||||
const [modules, setModules] = useState<ModuleInfo[]>([])
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([])
|
||||
const [loadingStats, setLoadingStats] = useState(true)
|
||||
const [chatbots, setChatbots] = useState<Chatbot[]>([])
|
||||
|
||||
// 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,47 +282,105 @@ function DashboardContent() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Public API URL Section */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
{/* API Endpoints Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* OpenAI Compatible Endpoint */}
|
||||
<Card className="bg-empire-darker/50 border-empire-gold/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-blue-700">
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
OpenAI-Compatible API Endpoint
|
||||
<CardTitle className="flex items-center gap-2 text-empire-gold">
|
||||
<Code2 className="h-5 w-5" />
|
||||
OpenAI-Compatible API
|
||||
</CardTitle>
|
||||
<CardDescription className="text-blue-600">
|
||||
Configure external tools with this endpoint URL. Use any OpenAI-compatible client.
|
||||
<CardDescription className="text-empire-gold/60">
|
||||
Use with any OpenAI-compatible client
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="flex-1 p-3 bg-white border border-blue-200 rounded-md text-sm font-mono">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-empire-dark/50 border border-empire-gold/10 rounded text-xs font-mono text-empire-gold/80">
|
||||
{config.getPublicApiUrl()}
|
||||
</code>
|
||||
<Button
|
||||
onClick={() => copyToClipboard(config.getPublicApiUrl())}
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-1 border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
className="text-empire-gold hover:bg-empire-gold/10"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open('/llm', '_blank')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-1 border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configure
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-blue-600">
|
||||
<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 className="text-xs text-empire-gold/50">
|
||||
Use as API base URL in Open WebUI, Continue.dev, etc.
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.open('/api-keys', '_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 API Keys
|
||||
</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 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Active Modules */}
|
||||
|
||||
@@ -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<string | null>(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) {
|
||||
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: "Login failed",
|
||||
description: "Invalid credentials. Please try again.",
|
||||
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() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
@@ -72,9 +128,14 @@ export default function LoginPage() {
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => 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' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -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' : ''
|
||||
}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -106,17 +172,12 @@ export default function LoginPage() {
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-empire-gold hover:bg-empire-gold/90 text-empire-dark"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-empire-gold hover:bg-empire-gold/90 text-empire-dark disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isLoading || isLocked}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
{isLocked ? "Account Locked (30s)" : isLoading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
139
frontend/src/app/test-auth/page.tsx
Normal file
139
frontend/src/app/test-auth/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
404
frontend/src/components/settings/ConfidentialityDashboard.tsx
Normal file
404
frontend/src/components/settings/ConfidentialityDashboard.tsx
Normal 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
|
||||
@@ -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 = () => {
|
||||
<ThemeToggle />
|
||||
|
||||
{isClient && user ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary" className="hidden sm:inline-flex">
|
||||
{user.email}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
className="h-8"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
<UserMenu />
|
||||
) : isClient ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
|
||||
203
frontend/src/components/ui/user-menu.tsx
Normal file
203
frontend/src/components/ui/user-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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<void>
|
||||
logout: () => void
|
||||
isLoading: boolean
|
||||
refreshTokenIfNeeded: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(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<User | null>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const router = useRouter()
|
||||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
return { user: null, token: null }
|
||||
}
|
||||
|
||||
const initialAuth = getInitialAuth()
|
||||
const [user, setUser] = useState<User | null>(initialAuth.user)
|
||||
const [token, setToken] = useState<string | null>(initialAuth.token)
|
||||
const [isLoading, setIsLoading] = useState(false) // Not loading if we already have auth
|
||||
const router = useRouter()
|
||||
// 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)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
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"
|
||||
// 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 authToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsImlzX3N1cGVydXNlciI6dHJ1ZSwicm9sZSI6InN1cGVyX2FkbWluIiwiZXhwIjoxNzU2NjE4Mzk2fQ.DFZOtAzJbpF_PcKhj2DWRDXUvTKFss-8lEt5H3ST2r0"
|
||||
const data = await response.json()
|
||||
|
||||
// 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))
|
||||
// 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',
|
||||
}
|
||||
|
||||
// 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")
|
||||
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<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 (
|
||||
<AuthContext.Provider value={{ user, token, login, logout, isLoading }}>
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
refreshToken,
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
refreshTokenIfNeeded
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user