mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 15:34:36 +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
|
# 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!)
|
||||||
|
# ===================================
|
||||||
@@ -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,
|
||||||
@@ -277,3 +294,38 @@ async def verify_user_token(
|
|||||||
"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"}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,47 +282,105 @@ 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">
|
||||||
|
{/* OpenAI Compatible Endpoint */}
|
||||||
|
<Card className="bg-empire-darker/50 border-empire-gold/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-blue-700">
|
<CardTitle className="flex items-center gap-2 text-empire-gold">
|
||||||
<ExternalLink className="h-5 w-5" />
|
<Code2 className="h-5 w-5" />
|
||||||
OpenAI-Compatible API Endpoint
|
OpenAI-Compatible API
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-blue-600">
|
<CardDescription className="text-empire-gold/60">
|
||||||
Configure external tools with this endpoint URL. Use any OpenAI-compatible client.
|
Use with any OpenAI-compatible client
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-3">
|
||||||
<code className="flex-1 p-3 bg-white border border-blue-200 rounded-md text-sm font-mono">
|
<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()}
|
{config.getPublicApiUrl()}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => copyToClipboard(config.getPublicApiUrl())}
|
onClick={() => copyToClipboard(config.getPublicApiUrl())}
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
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 className="h-3 w-3" />
|
||||||
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
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-sm text-blue-600">
|
<div className="text-xs text-empire-gold/50">
|
||||||
<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.
|
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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
||||||
{/* Active Modules */}
|
{/* Active Modules */}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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({
|
toast({
|
||||||
title: "Login failed",
|
title: "Account Locked",
|
||||||
description: "Invalid credentials. Please try again.",
|
description: "Too many failed login attempts. Please wait 30 seconds before trying again.",
|
||||||
variant: "destructive",
|
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>
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
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"
|
"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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return { user: null, token: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialAuth = getInitialAuth()
|
// Setup refresh timer
|
||||||
const [user, setUser] = useState<User | null>(initialAuth.user)
|
setupRefreshTimer(response.access_token, response.refresh_token)
|
||||||
const [token, setToken] = useState<string | null>(initialAuth.token)
|
} else {
|
||||||
const [isLoading, setIsLoading] = useState(false) // Not loading if we already have auth
|
// Refresh failed, clear everything
|
||||||
const router = useRouter()
|
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) => {
|
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 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
// Store tokens
|
||||||
if (typeof window !== "undefined") {
|
storeTokens(data.access_token, data.refresh_token)
|
||||||
// Use the actual JWT token for API calls
|
|
||||||
localStorage.setItem("token", authToken)
|
// Decode token to get user info
|
||||||
localStorage.setItem("user", JSON.stringify(demoUser))
|
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
|
localStorage.setItem("user", JSON.stringify(user))
|
||||||
setUser(demoUser)
|
setUser(user)
|
||||||
setToken(authToken)
|
|
||||||
|
|
||||||
// Wait a tick to ensure state has propagated
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50))
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid credentials")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user