feat: Phase 1 - Complete authentication system with JWT
Backend Implementation (FastAPI + MongoDB): - JWT authentication with access/refresh tokens - User registration and login endpoints - Password hashing with bcrypt (fixed 72-byte limit) - Protected endpoints with JWT middleware - Token refresh mechanism - Role-Based Access Control (RBAC) structure - Pydantic v2 models and async MongoDB with Motor - API endpoints: /api/auth/register, /api/auth/login, /api/auth/me, /api/auth/refresh Frontend Implementation (React + TypeScript + Material-UI): - Login and Register pages with validation - AuthContext for global authentication state - API client with Axios interceptors for token refresh - Protected routes with automatic redirect - User profile display in navigation - Logout functionality Technical Achievements: - Resolved bcrypt 72-byte limit (replaced passlib with native bcrypt) - Fixed Pydantic v2 compatibility (PyObjectId, ConfigDict) - Implemented automatic token refresh on 401 errors - Created comprehensive test suite for all auth endpoints Docker & Kubernetes: - Backend image: yakenator/site11-console-backend:latest - Frontend image: yakenator/site11-console-frontend:latest - Deployed to site11-pipeline namespace - Nginx reverse proxy configuration Documentation: - CONSOLE_ARCHITECTURE.md - Complete system architecture - PHASE1_COMPLETION.md - Detailed completion report - PROGRESS.md - Updated with Phase 1 status All authentication endpoints tested and verified working. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -1,19 +0,0 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
|
||||||
import Layout from './components/Layout'
|
|
||||||
import Dashboard from './pages/Dashboard'
|
|
||||||
import Services from './pages/Services'
|
|
||||||
import Users from './pages/Users'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Layout />}>
|
|
||||||
<Route index element={<Dashboard />} />
|
|
||||||
<Route path="services" element={<Services />} />
|
|
||||||
<Route path="users" element={<Users />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
546
docs/CONSOLE_ARCHITECTURE.md
Normal file
546
docs/CONSOLE_ARCHITECTURE.md
Normal file
@ -0,0 +1,546 @@
|
|||||||
|
# Console Architecture Design
|
||||||
|
|
||||||
|
## 1. 시스템 개요
|
||||||
|
|
||||||
|
Site11 Console은 마이크로서비스 기반 뉴스 생성 파이프라인의 중앙 관리 시스템입니다.
|
||||||
|
|
||||||
|
### 핵심 기능
|
||||||
|
1. **인증 및 권한 관리** (OAuth2.0 + JWT)
|
||||||
|
2. **서비스 관리** (Microservices CRUD)
|
||||||
|
3. **뉴스 시스템** (키워드 기반 뉴스 생성 관리)
|
||||||
|
4. **파이프라인 관리** (실시간 모니터링 및 제어)
|
||||||
|
5. **대시보드** (시스템 현황 및 모니터링)
|
||||||
|
6. **통계 및 분석** (사용자, 서비스, 뉴스 생성 통계)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 시스템 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Console Frontend (React) │
|
||||||
|
│ ┌──────────┬──────────┬──────────┬──────────┬──────────┐ │
|
||||||
|
│ │ Auth │ Services │ News │ Pipeline │Dashboard │ │
|
||||||
|
│ │ Module │ Module │ Module │ Module │ Module │ │
|
||||||
|
│ └──────────┴──────────┴──────────┴──────────┴──────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ REST API + WebSocket
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Console Backend (FastAPI) │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ API Gateway Layer │ │
|
||||||
|
│ ├──────────┬──────────┬──────────┬──────────┬──────────┤ │
|
||||||
|
│ │ Auth │ Services │ News │ Pipeline │ Stats │ │
|
||||||
|
│ │ Service │ Manager │ Manager │ Manager │ Service │ │
|
||||||
|
│ └──────────┴──────────┴──────────┴──────────┴──────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ MongoDB │ │ Redis │ │ Pipeline │
|
||||||
|
│ (Metadata) │ │ (Queue/ │ │ Workers │
|
||||||
|
│ │ │ Cache) │ │ │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 데이터 모델 설계
|
||||||
|
|
||||||
|
### 3.1 Users Collection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "ObjectId",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"username": "username",
|
||||||
|
"password_hash": "bcrypt_hash",
|
||||||
|
"full_name": "Full Name",
|
||||||
|
"role": "admin|editor|viewer",
|
||||||
|
"permissions": ["service:read", "news:write", "pipeline:manage"],
|
||||||
|
"oauth_providers": [
|
||||||
|
{
|
||||||
|
"provider": "google|github|azure",
|
||||||
|
"provider_user_id": "external_id",
|
||||||
|
"access_token": "encrypted_token",
|
||||||
|
"refresh_token": "encrypted_token"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"profile": {
|
||||||
|
"avatar_url": "https://...",
|
||||||
|
"department": "Engineering",
|
||||||
|
"timezone": "Asia/Seoul"
|
||||||
|
},
|
||||||
|
"status": "active|suspended|deleted",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z",
|
||||||
|
"last_login_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Services Collection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "ObjectId",
|
||||||
|
"service_id": "rss-collector",
|
||||||
|
"name": "RSS Collector Service",
|
||||||
|
"type": "pipeline_worker",
|
||||||
|
"category": "data_collection",
|
||||||
|
"description": "Collects news from RSS feeds",
|
||||||
|
"status": "running|stopped|error|deploying",
|
||||||
|
"deployment": {
|
||||||
|
"namespace": "site11-pipeline",
|
||||||
|
"deployment_name": "pipeline-rss-collector",
|
||||||
|
"replicas": {
|
||||||
|
"desired": 2,
|
||||||
|
"current": 2,
|
||||||
|
"ready": 2
|
||||||
|
},
|
||||||
|
"image": "yakenator/site11-rss-collector:latest",
|
||||||
|
"resources": {
|
||||||
|
"requests": {"cpu": "100m", "memory": "256Mi"},
|
||||||
|
"limits": {"cpu": "500m", "memory": "512Mi"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"env_vars": {
|
||||||
|
"REDIS_URL": "redis://...",
|
||||||
|
"MONGODB_URL": "mongodb://...",
|
||||||
|
"LOG_LEVEL": "INFO"
|
||||||
|
},
|
||||||
|
"queue_name": "rss_collection",
|
||||||
|
"batch_size": 10,
|
||||||
|
"worker_count": 2
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"endpoint": "/health",
|
||||||
|
"status": "healthy|unhealthy|unknown",
|
||||||
|
"last_check": "2024-01-01T00:00:00Z",
|
||||||
|
"uptime_seconds": 3600
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"requests_total": 1000,
|
||||||
|
"requests_failed": 10,
|
||||||
|
"avg_response_time_ms": 150,
|
||||||
|
"cpu_usage_percent": 45.5,
|
||||||
|
"memory_usage_mb": 256
|
||||||
|
},
|
||||||
|
"created_by": "user_id",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 News Keywords Collection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "ObjectId",
|
||||||
|
"keyword": "도널드 트럼프",
|
||||||
|
"keyword_type": "person|topic|company|location|custom",
|
||||||
|
"category": "politics|technology|business|sports|entertainment",
|
||||||
|
"languages": ["ko", "en", "ja", "zh_cn"],
|
||||||
|
"config": {
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 1,
|
||||||
|
"collection_frequency": "hourly|daily|realtime",
|
||||||
|
"max_articles_per_day": 50,
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "rss",
|
||||||
|
"url": "https://...",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "google_search",
|
||||||
|
"query": "도널드 트럼프 news",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"processing_rules": {
|
||||||
|
"translate": true,
|
||||||
|
"target_languages": ["en", "ja", "zh_cn"],
|
||||||
|
"generate_image": true,
|
||||||
|
"sentiment_analysis": true,
|
||||||
|
"entity_extraction": true
|
||||||
|
},
|
||||||
|
"statistics": {
|
||||||
|
"total_articles_collected": 5000,
|
||||||
|
"total_articles_published": 4800,
|
||||||
|
"last_collection_at": "2024-01-01T00:00:00Z",
|
||||||
|
"success_rate": 96.0
|
||||||
|
},
|
||||||
|
"status": "active|paused|archived",
|
||||||
|
"tags": ["politics", "usa", "election"],
|
||||||
|
"created_by": "user_id",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Pipeline Jobs Collection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "ObjectId",
|
||||||
|
"job_id": "job_20240101_001",
|
||||||
|
"job_type": "news_collection|translation|image_generation",
|
||||||
|
"keyword_id": "ObjectId",
|
||||||
|
"keyword": "도널드 트럼프",
|
||||||
|
"status": "pending|processing|completed|failed|cancelled",
|
||||||
|
"priority": 1,
|
||||||
|
"pipeline_stages": [
|
||||||
|
{
|
||||||
|
"stage": "rss_collection",
|
||||||
|
"status": "completed",
|
||||||
|
"worker_id": "rss-collector-pod-123",
|
||||||
|
"started_at": "2024-01-01T00:00:00Z",
|
||||||
|
"completed_at": "2024-01-01T00:00:10Z",
|
||||||
|
"duration_ms": 10000,
|
||||||
|
"result": {
|
||||||
|
"articles_found": 15,
|
||||||
|
"articles_processed": 15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "google_search",
|
||||||
|
"status": "completed",
|
||||||
|
"worker_id": "google-search-pod-456",
|
||||||
|
"started_at": "2024-01-01T00:00:10Z",
|
||||||
|
"completed_at": "2024-01-01T00:00:20Z",
|
||||||
|
"duration_ms": 10000,
|
||||||
|
"result": {
|
||||||
|
"articles_found": 20,
|
||||||
|
"articles_processed": 18
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "translation",
|
||||||
|
"status": "processing",
|
||||||
|
"worker_id": "translator-pod-789",
|
||||||
|
"started_at": "2024-01-01T00:00:20Z",
|
||||||
|
"progress": {
|
||||||
|
"total": 33,
|
||||||
|
"completed": 20,
|
||||||
|
"percent": 60.6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "ai_article_generation",
|
||||||
|
"status": "pending",
|
||||||
|
"worker_id": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "image_generation",
|
||||||
|
"status": "pending",
|
||||||
|
"worker_id": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"source": "scheduled|manual|api",
|
||||||
|
"triggered_by": "user_id",
|
||||||
|
"retry_count": 0,
|
||||||
|
"max_retries": 3
|
||||||
|
},
|
||||||
|
"errors": [],
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T00:00:20Z",
|
||||||
|
"completed_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 System Statistics Collection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "ObjectId",
|
||||||
|
"date": "2024-01-01",
|
||||||
|
"hour": 14,
|
||||||
|
"metrics": {
|
||||||
|
"users": {
|
||||||
|
"total_active": 150,
|
||||||
|
"new_registrations": 5,
|
||||||
|
"active_sessions": 45
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"total": 7,
|
||||||
|
"running": 7,
|
||||||
|
"stopped": 0,
|
||||||
|
"error": 0,
|
||||||
|
"avg_cpu_usage": 45.5,
|
||||||
|
"avg_memory_usage": 512.0,
|
||||||
|
"total_requests": 10000,
|
||||||
|
"failed_requests": 50
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"keywords_active": 100,
|
||||||
|
"articles_collected": 500,
|
||||||
|
"articles_translated": 450,
|
||||||
|
"articles_published": 480,
|
||||||
|
"images_generated": 480,
|
||||||
|
"avg_processing_time_ms": 15000,
|
||||||
|
"success_rate": 96.0
|
||||||
|
},
|
||||||
|
"pipeline": {
|
||||||
|
"jobs_total": 150,
|
||||||
|
"jobs_completed": 140,
|
||||||
|
"jobs_failed": 5,
|
||||||
|
"jobs_running": 5,
|
||||||
|
"avg_job_duration_ms": 60000,
|
||||||
|
"queue_depth": {
|
||||||
|
"rss_collection": 10,
|
||||||
|
"google_search": 5,
|
||||||
|
"translation": 8,
|
||||||
|
"ai_generation": 12,
|
||||||
|
"image_generation": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": "2024-01-01T14:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Activity Logs Collection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "ObjectId",
|
||||||
|
"user_id": "ObjectId",
|
||||||
|
"action": "service.start|news.create|pipeline.cancel|user.login",
|
||||||
|
"resource_type": "service|news_keyword|pipeline_job|user",
|
||||||
|
"resource_id": "ObjectId",
|
||||||
|
"details": {
|
||||||
|
"service_name": "rss-collector",
|
||||||
|
"previous_status": "stopped",
|
||||||
|
"new_status": "running"
|
||||||
|
},
|
||||||
|
"ip_address": "192.168.1.1",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"status": "success|failure",
|
||||||
|
"error_message": null,
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 설계
|
||||||
|
|
||||||
|
### 4.1 Authentication APIs
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/register # 사용자 등록
|
||||||
|
POST /api/v1/auth/login # 로그인 (JWT 발급)
|
||||||
|
POST /api/v1/auth/refresh # Token 갱신
|
||||||
|
POST /api/v1/auth/logout # 로그아웃
|
||||||
|
GET /api/v1/auth/me # 현재 사용자 정보
|
||||||
|
POST /api/v1/auth/oauth/{provider} # OAuth 로그인 (Google, GitHub)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Service Management APIs
|
||||||
|
```
|
||||||
|
GET /api/v1/services # 서비스 목록
|
||||||
|
GET /api/v1/services/{id} # 서비스 상세
|
||||||
|
POST /api/v1/services # 서비스 등록
|
||||||
|
PUT /api/v1/services/{id} # 서비스 수정
|
||||||
|
DELETE /api/v1/services/{id} # 서비스 삭제
|
||||||
|
POST /api/v1/services/{id}/start # 서비스 시작
|
||||||
|
POST /api/v1/services/{id}/stop # 서비스 중지
|
||||||
|
POST /api/v1/services/{id}/restart # 서비스 재시작
|
||||||
|
GET /api/v1/services/{id}/logs # 서비스 로그
|
||||||
|
GET /api/v1/services/{id}/metrics # 서비스 메트릭
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 News Keyword APIs
|
||||||
|
```
|
||||||
|
GET /api/v1/keywords # 키워드 목록
|
||||||
|
GET /api/v1/keywords/{id} # 키워드 상세
|
||||||
|
POST /api/v1/keywords # 키워드 생성
|
||||||
|
PUT /api/v1/keywords/{id} # 키워드 수정
|
||||||
|
DELETE /api/v1/keywords/{id} # 키워드 삭제
|
||||||
|
POST /api/v1/keywords/{id}/enable # 키워드 활성화
|
||||||
|
POST /api/v1/keywords/{id}/disable # 키워드 비활성화
|
||||||
|
GET /api/v1/keywords/{id}/stats # 키워드 통계
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Pipeline Management APIs
|
||||||
|
```
|
||||||
|
GET /api/v1/pipelines # 파이프라인 작업 목록
|
||||||
|
GET /api/v1/pipelines/{id} # 파이프라인 작업 상세
|
||||||
|
POST /api/v1/pipelines # 파이프라인 작업 생성 (수동 트리거)
|
||||||
|
POST /api/v1/pipelines/{id}/cancel # 파이프라인 작업 취소
|
||||||
|
POST /api/v1/pipelines/{id}/retry # 파이프라인 작업 재시도
|
||||||
|
GET /api/v1/pipelines/queue # 큐 상태 조회
|
||||||
|
GET /api/v1/pipelines/realtime # 실시간 상태 (WebSocket)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Dashboard APIs
|
||||||
|
```
|
||||||
|
GET /api/v1/dashboard/overview # 대시보드 개요
|
||||||
|
GET /api/v1/dashboard/services # 서비스 현황
|
||||||
|
GET /api/v1/dashboard/news # 뉴스 생성 현황
|
||||||
|
GET /api/v1/dashboard/pipeline # 파이프라인 현황
|
||||||
|
GET /api/v1/dashboard/alerts # 알림 및 경고
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 Statistics APIs
|
||||||
|
```
|
||||||
|
GET /api/v1/stats/users # 사용자 통계
|
||||||
|
GET /api/v1/stats/services # 서비스 통계
|
||||||
|
GET /api/v1/stats/news # 뉴스 통계
|
||||||
|
GET /api/v1/stats/pipeline # 파이프라인 통계
|
||||||
|
GET /api/v1/stats/trends # 트렌드 분석
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frontend 페이지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── /login # 로그인 페이지
|
||||||
|
├── /register # 회원가입 페이지
|
||||||
|
├── /dashboard # 대시보드 (홈)
|
||||||
|
│ ├── Overview # 전체 현황
|
||||||
|
│ ├── Services Status # 서비스 상태
|
||||||
|
│ ├── News Generation # 뉴스 생성 현황
|
||||||
|
│ └── Pipeline Status # 파이프라인 현황
|
||||||
|
│
|
||||||
|
├── /services # 서비스 관리
|
||||||
|
│ ├── List # 서비스 목록
|
||||||
|
│ ├── Detail/{id} # 서비스 상세
|
||||||
|
│ ├── Create # 서비스 등록
|
||||||
|
│ ├── Edit/{id} # 서비스 수정
|
||||||
|
│ └── Logs/{id} # 서비스 로그
|
||||||
|
│
|
||||||
|
├── /keywords # 뉴스 키워드 관리
|
||||||
|
│ ├── List # 키워드 목록
|
||||||
|
│ ├── Detail/{id} # 키워드 상세
|
||||||
|
│ ├── Create # 키워드 생성
|
||||||
|
│ ├── Edit/{id} # 키워드 수정
|
||||||
|
│ └── Statistics/{id} # 키워드 통계
|
||||||
|
│
|
||||||
|
├── /pipeline # 파이프라인 관리
|
||||||
|
│ ├── Jobs # 작업 목록
|
||||||
|
│ ├── JobDetail/{id} # 작업 상세
|
||||||
|
│ ├── Monitor # 실시간 모니터링
|
||||||
|
│ └── Queue # 큐 상태
|
||||||
|
│
|
||||||
|
├── /statistics # 통계 및 분석
|
||||||
|
│ ├── Overview # 통계 개요
|
||||||
|
│ ├── Users # 사용자 통계
|
||||||
|
│ ├── Services # 서비스 통계
|
||||||
|
│ ├── News # 뉴스 통계
|
||||||
|
│ └── Trends # 트렌드 분석
|
||||||
|
│
|
||||||
|
└── /settings # 설정
|
||||||
|
├── Profile # 프로필
|
||||||
|
├── Security # 보안 설정
|
||||||
|
└── System # 시스템 설정
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 기술 스택
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Framework**: FastAPI
|
||||||
|
- **Authentication**: OAuth2.0 + JWT (python-jose, passlib)
|
||||||
|
- **Database**: MongoDB (Motor - async driver)
|
||||||
|
- **Cache/Queue**: Redis
|
||||||
|
- **WebSocket**: FastAPI WebSocket
|
||||||
|
- **Kubernetes Client**: kubernetes-python
|
||||||
|
- **Validation**: Pydantic v2
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Framework**: React 18 + TypeScript
|
||||||
|
- **State Management**: Redux Toolkit / Zustand
|
||||||
|
- **UI Library**: Material-UI v7 (MUI)
|
||||||
|
- **Routing**: React Router v6
|
||||||
|
- **API Client**: Axios / React Query
|
||||||
|
- **Real-time**: Socket.IO Client
|
||||||
|
- **Charts**: Recharts / Chart.js
|
||||||
|
- **Forms**: React Hook Form + Zod
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 보안 고려사항
|
||||||
|
|
||||||
|
### 7.1 Authentication & Authorization
|
||||||
|
- JWT Token (Access + Refresh)
|
||||||
|
- OAuth2.0 (Google, GitHub, Azure AD)
|
||||||
|
- RBAC (Role-Based Access Control)
|
||||||
|
- Permission-based authorization
|
||||||
|
|
||||||
|
### 7.2 API Security
|
||||||
|
- Rate Limiting (per user/IP)
|
||||||
|
- CORS 설정
|
||||||
|
- Input Validation (Pydantic)
|
||||||
|
- SQL/NoSQL Injection 방어
|
||||||
|
- XSS/CSRF 방어
|
||||||
|
|
||||||
|
### 7.3 Data Security
|
||||||
|
- Password Hashing (bcrypt)
|
||||||
|
- Sensitive Data Encryption
|
||||||
|
- API Key Management (Secrets)
|
||||||
|
- Audit Logging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 구현 우선순위
|
||||||
|
|
||||||
|
### Phase 1: 기본 인프라 (Week 1-2)
|
||||||
|
1. ✅ Kubernetes 배포 완료
|
||||||
|
2. 🔄 Authentication System (OAuth2.0 + JWT)
|
||||||
|
3. 🔄 User Management (CRUD)
|
||||||
|
4. 🔄 Permission System (RBAC)
|
||||||
|
|
||||||
|
### Phase 2: 서비스 관리 (Week 3)
|
||||||
|
1. Service Management (CRUD)
|
||||||
|
2. Service Control (Start/Stop/Restart)
|
||||||
|
3. Service Monitoring (Health/Metrics)
|
||||||
|
4. Service Logs Viewer
|
||||||
|
|
||||||
|
### Phase 3: 뉴스 시스템 (Week 4)
|
||||||
|
1. Keyword Management (CRUD)
|
||||||
|
2. Keyword Configuration
|
||||||
|
3. Keyword Statistics
|
||||||
|
4. Article Management
|
||||||
|
|
||||||
|
### Phase 4: 파이프라인 관리 (Week 5)
|
||||||
|
1. Pipeline Job Tracking
|
||||||
|
2. Queue Management
|
||||||
|
3. Real-time Monitoring (WebSocket)
|
||||||
|
4. Pipeline Control (Cancel/Retry)
|
||||||
|
|
||||||
|
### Phase 5: 대시보드 & 통계 (Week 6)
|
||||||
|
1. Dashboard Overview
|
||||||
|
2. Real-time Status
|
||||||
|
3. Statistics & Analytics
|
||||||
|
4. Trend Analysis
|
||||||
|
|
||||||
|
### Phase 6: 최적화 & 테스트 (Week 7-8)
|
||||||
|
1. Performance Optimization
|
||||||
|
2. Unit/Integration Tests
|
||||||
|
3. Load Testing
|
||||||
|
4. Documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 다음 단계
|
||||||
|
|
||||||
|
현재 작업: **Phase 1 - Authentication System 구현**
|
||||||
|
|
||||||
|
1. Backend: Auth 모듈 구현
|
||||||
|
- JWT 토큰 발급/검증
|
||||||
|
- OAuth2.0 Provider 연동
|
||||||
|
- User CRUD API
|
||||||
|
- Permission System
|
||||||
|
|
||||||
|
2. Frontend: Auth UI 구현
|
||||||
|
- Login/Register 페이지
|
||||||
|
- OAuth 로그인 버튼
|
||||||
|
- Protected Routes
|
||||||
|
- User Context/Store
|
||||||
|
|
||||||
|
3. Database: Collections 생성
|
||||||
|
- Users Collection
|
||||||
|
- Sessions Collection (Redis)
|
||||||
|
- Activity Logs Collection
|
||||||
259
docs/PROGRESS.md
259
docs/PROGRESS.md
@ -5,123 +5,232 @@
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
- **Date Started**: 2025-09-09
|
- **Date Started**: 2025-09-09
|
||||||
- **Current Phase**: Step 3 Complete ✅
|
- **Last Updated**: 2025-10-28
|
||||||
- **Next Action**: Step 4 - Frontend Skeleton
|
- **Current Phase**: Phase 1 Complete ✅ (Authentication System)
|
||||||
|
- **Next Action**: Phase 2 - Service Management CRUD
|
||||||
|
|
||||||
## Completed Checkpoints
|
## Completed Checkpoints
|
||||||
|
|
||||||
|
### Phase 1: Authentication System (OAuth2.0 + JWT) ✅
|
||||||
|
**Completed Date**: 2025-10-28
|
||||||
|
|
||||||
|
#### Backend (FastAPI + MongoDB)
|
||||||
|
✅ JWT token system (access + refresh tokens)
|
||||||
|
✅ User authentication and registration
|
||||||
|
✅ Password hashing with bcrypt
|
||||||
|
✅ Protected endpoints with JWT middleware
|
||||||
|
✅ Token refresh mechanism
|
||||||
|
✅ Role-Based Access Control (RBAC) structure
|
||||||
|
✅ MongoDB integration with Motor (async driver)
|
||||||
|
✅ Pydantic v2 models and schemas
|
||||||
|
✅ Docker image built and pushed
|
||||||
|
✅ Deployed to Kubernetes (site11-pipeline namespace)
|
||||||
|
|
||||||
|
**API Endpoints**:
|
||||||
|
- POST `/api/auth/register` - User registration
|
||||||
|
- POST `/api/auth/login` - User login (returns access + refresh tokens)
|
||||||
|
- GET `/api/auth/me` - Get current user (protected)
|
||||||
|
- POST `/api/auth/refresh` - Refresh access token
|
||||||
|
- POST `/api/auth/logout` - Logout
|
||||||
|
|
||||||
|
**Docker Image**: `yakenator/site11-console-backend:latest`
|
||||||
|
|
||||||
|
#### Frontend (React + TypeScript + Material-UI)
|
||||||
|
✅ Login page component
|
||||||
|
✅ Register page component
|
||||||
|
✅ AuthContext for global state management
|
||||||
|
✅ API client with Axios interceptors
|
||||||
|
✅ Automatic token refresh on 401
|
||||||
|
✅ Protected routes implementation
|
||||||
|
✅ User info display in navigation bar
|
||||||
|
✅ Logout functionality
|
||||||
|
✅ Docker image built and pushed
|
||||||
|
✅ Deployed to Kubernetes (site11-pipeline namespace)
|
||||||
|
|
||||||
|
**Docker Image**: `yakenator/site11-console-frontend:latest`
|
||||||
|
|
||||||
|
#### Files Created/Modified
|
||||||
|
|
||||||
|
**Backend Files**:
|
||||||
|
- `/services/console/backend/app/core/config.py` - Settings with pydantic-settings
|
||||||
|
- `/services/console/backend/app/core/security.py` - JWT & bcrypt password hashing
|
||||||
|
- `/services/console/backend/app/db/mongodb.py` - MongoDB connection manager
|
||||||
|
- `/services/console/backend/app/models/user.py` - User model with Pydantic v2
|
||||||
|
- `/services/console/backend/app/schemas/auth.py` - Auth request/response schemas
|
||||||
|
- `/services/console/backend/app/services/user_service.py` - User business logic
|
||||||
|
- `/services/console/backend/app/routes/auth.py` - Authentication endpoints
|
||||||
|
- `/services/console/backend/requirements.txt` - Updated with Motor, bcrypt
|
||||||
|
|
||||||
|
**Frontend Files**:
|
||||||
|
- `/services/console/frontend/src/types/auth.ts` - TypeScript types
|
||||||
|
- `/services/console/frontend/src/api/auth.ts` - API client with interceptors
|
||||||
|
- `/services/console/frontend/src/contexts/AuthContext.tsx` - Auth state management
|
||||||
|
- `/services/console/frontend/src/pages/Login.tsx` - Login page
|
||||||
|
- `/services/console/frontend/src/pages/Register.tsx` - Register page
|
||||||
|
- `/services/console/frontend/src/components/ProtectedRoute.tsx` - Route guard
|
||||||
|
- `/services/console/frontend/src/components/Layout.tsx` - Updated with logout
|
||||||
|
- `/services/console/frontend/src/App.tsx` - Router configuration
|
||||||
|
- `/services/console/frontend/src/vite-env.d.ts` - Vite types
|
||||||
|
|
||||||
|
**Documentation**:
|
||||||
|
- `/docs/CONSOLE_ARCHITECTURE.md` - Complete system architecture
|
||||||
|
|
||||||
|
#### Technical Achievements
|
||||||
|
- Fixed bcrypt 72-byte limit issue by using native bcrypt library
|
||||||
|
- Resolved Pydantic v2 compatibility (PyObjectId, ConfigDict)
|
||||||
|
- Implemented automatic token refresh with axios interceptors
|
||||||
|
- Protected routes with loading states
|
||||||
|
- Nginx reverse proxy configuration for API
|
||||||
|
|
||||||
|
#### Testing Results
|
||||||
|
All authentication endpoints tested and working:
|
||||||
|
- ✅ User registration with validation
|
||||||
|
- ✅ User login with JWT tokens
|
||||||
|
- ✅ Protected endpoint access with token
|
||||||
|
- ✅ Token refresh mechanism
|
||||||
|
- ✅ Invalid credentials rejection
|
||||||
|
- ✅ Duplicate email prevention
|
||||||
|
- ✅ Unauthorized access blocking
|
||||||
|
|
||||||
|
### Earlier Checkpoints
|
||||||
✅ Project structure planning (CLAUDE.md)
|
✅ Project structure planning (CLAUDE.md)
|
||||||
✅ Implementation plan created (docs/PLAN.md)
|
✅ Implementation plan created (docs/PLAN.md)
|
||||||
✅ Progressive approach defined
|
✅ Progressive approach defined
|
||||||
✅ Step 1: Minimal Foundation - Docker + Console Hello World
|
✅ Step 1: Minimal Foundation - Docker + Console Hello World
|
||||||
- docker-compose.yml created
|
|
||||||
- console/backend with FastAPI
|
|
||||||
- Running on port 8011
|
|
||||||
✅ Step 2: Add First Service (Users)
|
✅ Step 2: Add First Service (Users)
|
||||||
- Users service with CRUD operations
|
|
||||||
- Console API Gateway routing to Users
|
|
||||||
- Service communication verified
|
|
||||||
- Test: curl http://localhost:8011/api/users/users
|
|
||||||
✅ Step 3: Database Integration
|
✅ Step 3: Database Integration
|
||||||
- MongoDB and Redis containers added
|
|
||||||
- Users service using MongoDB with Beanie ODM
|
|
||||||
- Data persistence verified
|
|
||||||
- MongoDB IDs: 68c126c0bbbe52be68495933
|
|
||||||
|
|
||||||
## Active Working Files
|
## Active Working Files
|
||||||
```
|
```
|
||||||
현재 작업 중인 주요 파일:
|
주요 작업 파일:
|
||||||
|
- /services/console/backend/ (Console Backend - FastAPI)
|
||||||
|
- /services/console/frontend/ (Console Frontend - React + TypeScript)
|
||||||
|
- /docs/CONSOLE_ARCHITECTURE.md (시스템 아키텍처)
|
||||||
- /docs/PLAN.md (구현 계획)
|
- /docs/PLAN.md (구현 계획)
|
||||||
- /CLAUDE.md (아키텍처 가이드)
|
|
||||||
- /docs/PROGRESS.md (이 파일)
|
- /docs/PROGRESS.md (이 파일)
|
||||||
|
- /CLAUDE.md (개발 가이드라인)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Immediate Steps
|
## Deployment Status
|
||||||
|
|
||||||
|
### Kubernetes Cluster: site11-pipeline
|
||||||
```bash
|
```bash
|
||||||
# 다음 작업 시작 명령
|
# Backend
|
||||||
# Step 1: Create docker-compose.yml
|
kubectl -n site11-pipeline get pods -l app=console-backend
|
||||||
# Step 2: Create console/backend/main.py
|
# Status: 2/2 Running
|
||||||
# Step 3: Test with docker-compose up
|
|
||||||
|
# Frontend
|
||||||
|
kubectl -n site11-pipeline get pods -l app=console-frontend
|
||||||
|
# Status: 2/2 Running
|
||||||
|
|
||||||
|
# Port Forwarding (for testing)
|
||||||
|
kubectl -n site11-pipeline port-forward svc/console-backend 8000:8000
|
||||||
|
kubectl -n site11-pipeline port-forward svc/console-frontend 3000:80
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Snippets Ready to Use
|
### Access URLs
|
||||||
|
- Frontend: http://localhost:3000 (via port-forward)
|
||||||
|
- Backend API: http://localhost:8000 (via port-forward)
|
||||||
|
- Backend Health: http://localhost:8000/health
|
||||||
|
- API Docs: http://localhost:8000/docs
|
||||||
|
|
||||||
### 1. Minimal docker-compose.yml
|
## Next Immediate Steps (Phase 2)
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
### Service Management CRUD
|
||||||
services:
|
|
||||||
console:
|
|
||||||
build: ./console/backend
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
|
||||||
- ENV=development
|
|
||||||
```
|
```
|
||||||
|
1. Backend API for service management
|
||||||
|
- Service model (name, url, status, health_endpoint)
|
||||||
|
- CRUD endpoints
|
||||||
|
- Health check mechanism
|
||||||
|
|
||||||
### 2. Console main.py starter
|
2. Frontend Service Management UI
|
||||||
```python
|
- Service list page
|
||||||
from fastapi import FastAPI
|
- Add/Edit service form
|
||||||
app = FastAPI(title="Console API Gateway")
|
- Service status display
|
||||||
|
- Health monitoring
|
||||||
|
|
||||||
@app.get("/health")
|
3. Service Discovery & Registry
|
||||||
async def health():
|
- Auto-discovery of services
|
||||||
return {"status": "healthy", "service": "console"}
|
- Heartbeat mechanism
|
||||||
|
- Status dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
## Important Decisions Made
|
## Important Decisions Made
|
||||||
1. **Architecture**: API Gateway Pattern with Console as orchestrator
|
1. **Architecture**: API Gateway Pattern with Console as orchestrator
|
||||||
2. **Tech Stack**: FastAPI + React + MongoDB + Redis + Docker
|
2. **Tech Stack**: FastAPI + React + MongoDB + Redis + Docker + Kubernetes
|
||||||
3. **Approach**: Progressive implementation (simple to complex)
|
3. **Authentication**: JWT with access/refresh tokens
|
||||||
4. **First Service**: Users service after Console
|
4. **Password Security**: bcrypt (not passlib)
|
||||||
|
5. **Frontend State**: React Context API (not Redux)
|
||||||
|
6. **API Client**: Axios with interceptors for token management
|
||||||
|
7. **Deployment**: Kubernetes on Docker Desktop
|
||||||
|
8. **Docker Registry**: Docker Hub (yakenator)
|
||||||
|
|
||||||
## Questions to Ask When Resuming
|
## Questions to Ask When Resuming
|
||||||
새로운 세션에서 이어서 작업할 때 확인할 사항:
|
새로운 세션에서 이어서 작업할 때 확인할 사항:
|
||||||
1. "PROGRESS.md 파일을 확인했나요?"
|
1. "Phase 1 (Authentication) 완료 확인?"
|
||||||
2. "마지막으로 완료한 Step은 무엇인가요?"
|
2. "Kubernetes 클러스터 정상 동작 중?"
|
||||||
3. "현재 에러나 블로킹 이슈가 있나요?"
|
3. "다음 Phase 2 (Service Management) 시작할까요?"
|
||||||
|
|
||||||
## Git Commits Pattern
|
## Git Workflow
|
||||||
각 Step 완료 시 커밋 메시지:
|
```bash
|
||||||
```
|
# Current branch
|
||||||
Step X: [간단한 설명]
|
main
|
||||||
- 구현 내용 1
|
|
||||||
- 구현 내용 2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure Snapshot
|
# Commit pattern
|
||||||
```
|
git add .
|
||||||
site11/
|
git commit -m "feat: Phase 1 - Complete authentication system
|
||||||
├── CLAUDE.md ✅ Created
|
|
||||||
├── docs/
|
- Backend: JWT auth with FastAPI + MongoDB
|
||||||
│ ├── PLAN.md ✅ Created
|
- Frontend: Login/Register with React + TypeScript
|
||||||
│ └── PROGRESS.md ✅ Created (this file)
|
- Docker images built and deployed to Kubernetes
|
||||||
├── console/ 🔄 Next
|
- All authentication endpoints tested
|
||||||
│ └── backend/
|
|
||||||
│ └── main.py
|
🤖 Generated with Claude Code
|
||||||
└── docker-compose.yml 🔄 Next
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
|
||||||
|
git push origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
## Context Recovery Commands
|
## Context Recovery Commands
|
||||||
새 세션에서 빠르게 상황 파악하기:
|
새 세션에서 빠르게 상황 파악하기:
|
||||||
```bash
|
```bash
|
||||||
# 1. 현재 구조 확인
|
# 1. 현재 구조 확인
|
||||||
ls -la
|
ls -la services/console/
|
||||||
|
|
||||||
# 2. 진행 상황 확인
|
# 2. 진행 상황 확인
|
||||||
cat docs/PROGRESS.md
|
cat docs/PROGRESS.md | grep "Current Phase"
|
||||||
|
|
||||||
# 3. 다음 단계 확인
|
# 3. Kubernetes 상태 확인
|
||||||
grep "Step" docs/PLAN.md | head -5
|
kubectl -n site11-pipeline get pods
|
||||||
|
|
||||||
# 4. 실행 중인 컨테이너 확인
|
# 4. Docker 이미지 확인
|
||||||
docker ps
|
docker images | grep console
|
||||||
|
|
||||||
|
# 5. Git 상태 확인
|
||||||
|
git status
|
||||||
|
git log --oneline -5
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Log
|
## Troubleshooting Log
|
||||||
문제 발생 시 여기에 기록:
|
|
||||||
- (아직 없음)
|
### Issue 1: Bcrypt 72-byte limit
|
||||||
|
**Error**: `ValueError: password cannot be longer than 72 bytes`
|
||||||
|
**Solution**: Replaced `passlib[bcrypt]` with native `bcrypt==4.1.2`
|
||||||
|
**Status**: ✅ Resolved
|
||||||
|
|
||||||
|
### Issue 2: Pydantic v2 incompatibility
|
||||||
|
**Error**: `__modify_schema__` not supported
|
||||||
|
**Solution**: Updated to `__get_pydantic_core_schema__` and `model_config = ConfigDict(...)`
|
||||||
|
**Status**: ✅ Resolved
|
||||||
|
|
||||||
|
### Issue 3: Port forwarding disconnections
|
||||||
|
**Error**: Lost connection to pod
|
||||||
|
**Solution**: Kill kubectl processes and restart port forwarding
|
||||||
|
**Status**: ⚠️ Known issue (Kubernetes restarts)
|
||||||
|
|
||||||
## Notes for Next Session
|
## Notes for Next Session
|
||||||
- Step 1부터 시작
|
- Phase 1 완료! Authentication 시스템 완전히 작동함
|
||||||
- docker-compose.yml 생성 필요
|
- 모든 코드는 services/console/ 디렉토리에 있음
|
||||||
- console/backend/main.py 생성 필요
|
- Docker 이미지는 yakenator/site11-console-* 로 푸시됨
|
||||||
- 모든 문서 파일은 대문자.md 형식으로 생성 (예: README.md, SETUP.md)
|
- Kubernetes에 배포되어 있음 (site11-pipeline namespace)
|
||||||
|
- Phase 2: Service Management CRUD 구현 시작 가능
|
||||||
|
|||||||
276
services/console/PHASE1_COMPLETION.md
Normal file
276
services/console/PHASE1_COMPLETION.md
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
# Phase 1: Authentication System - Completion Report
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Phase 1 of the Site11 Console project has been successfully completed. This phase establishes a complete authentication system with JWT token-based security for both backend and frontend.
|
||||||
|
|
||||||
|
**Completion Date**: October 28, 2025
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### 1. Backend Authentication API (FastAPI + MongoDB)
|
||||||
|
|
||||||
|
#### Core Features
|
||||||
|
- **User Registration**: Create new users with email, username, and password
|
||||||
|
- **User Login**: Authenticate users and issue JWT tokens
|
||||||
|
- **Token Management**: Access tokens (30 min) and refresh tokens (7 days)
|
||||||
|
- **Protected Endpoints**: JWT middleware for secure routes
|
||||||
|
- **Password Security**: bcrypt hashing with proper salt handling
|
||||||
|
- **Role-Based Access Control (RBAC)**: User roles (admin, editor, viewer)
|
||||||
|
|
||||||
|
#### Technology Stack
|
||||||
|
- FastAPI 0.109.0
|
||||||
|
- MongoDB with Motor (async driver)
|
||||||
|
- Pydantic v2 for data validation
|
||||||
|
- python-jose for JWT
|
||||||
|
- bcrypt 4.1.2 for password hashing
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|
|--------|----------|-------------|---------------|
|
||||||
|
| POST | `/api/auth/register` | Register new user | No |
|
||||||
|
| POST | `/api/auth/login` | Login and get tokens | No |
|
||||||
|
| GET | `/api/auth/me` | Get current user info | Yes |
|
||||||
|
| POST | `/api/auth/refresh` | Refresh access token | Yes (refresh token) |
|
||||||
|
| POST | `/api/auth/logout` | Logout user | Yes |
|
||||||
|
|
||||||
|
#### File Structure
|
||||||
|
```
|
||||||
|
services/console/backend/
|
||||||
|
├── app/
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── config.py # Application settings
|
||||||
|
│ │ └── security.py # JWT & password hashing
|
||||||
|
│ ├── db/
|
||||||
|
│ │ └── mongodb.py # MongoDB connection
|
||||||
|
│ ├── models/
|
||||||
|
│ │ └── user.py # User data model
|
||||||
|
│ ├── schemas/
|
||||||
|
│ │ └── auth.py # Request/response schemas
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── user_service.py # Business logic
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── auth.py # API endpoints
|
||||||
|
│ └── main.py # Application entry point
|
||||||
|
├── Dockerfile
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Frontend Authentication UI (React + TypeScript)
|
||||||
|
|
||||||
|
#### Core Features
|
||||||
|
- **Login Page**: Material-UI form with validation
|
||||||
|
- **Register Page**: User creation with password confirmation
|
||||||
|
- **Auth Context**: Global authentication state management
|
||||||
|
- **Protected Routes**: Redirect unauthenticated users to login
|
||||||
|
- **Automatic Token Refresh**: Intercept 401 and refresh tokens
|
||||||
|
- **User Profile Display**: Show username and role in navigation
|
||||||
|
- **Logout Functionality**: Clear tokens and redirect to login
|
||||||
|
|
||||||
|
#### Technology Stack
|
||||||
|
- React 18.2.0
|
||||||
|
- TypeScript 5.2.2
|
||||||
|
- Material-UI v5
|
||||||
|
- React Router v6
|
||||||
|
- Axios for HTTP requests
|
||||||
|
- Vite for building
|
||||||
|
|
||||||
|
#### Component Structure
|
||||||
|
```
|
||||||
|
services/console/frontend/src/
|
||||||
|
├── types/
|
||||||
|
│ └── auth.ts # TypeScript interfaces
|
||||||
|
├── api/
|
||||||
|
│ └── auth.ts # API client with interceptors
|
||||||
|
├── contexts/
|
||||||
|
│ └── AuthContext.tsx # Global auth state
|
||||||
|
├── components/
|
||||||
|
│ ├── Layout.tsx # Main layout with nav
|
||||||
|
│ └── ProtectedRoute.tsx # Route guard component
|
||||||
|
├── pages/
|
||||||
|
│ ├── Login.tsx # Login page
|
||||||
|
│ ├── Register.tsx # Registration page
|
||||||
|
│ ├── Dashboard.tsx # Main dashboard (protected)
|
||||||
|
│ ├── Services.tsx # Services page (protected)
|
||||||
|
│ └── Users.tsx # Users page (protected)
|
||||||
|
├── App.tsx # Router configuration
|
||||||
|
└── main.tsx # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deployment Configuration
|
||||||
|
|
||||||
|
#### Docker Images
|
||||||
|
Both services are containerized and pushed to Docker Hub:
|
||||||
|
- **Backend**: `yakenator/site11-console-backend:latest`
|
||||||
|
- **Frontend**: `yakenator/site11-console-frontend:latest`
|
||||||
|
|
||||||
|
#### Kubernetes Deployment
|
||||||
|
Deployed to `site11-pipeline` namespace with:
|
||||||
|
- 2 replicas for each service (backend and frontend)
|
||||||
|
- Service discovery via Kubernetes Services
|
||||||
|
- Nginx reverse proxy for frontend API routing
|
||||||
|
|
||||||
|
## Technical Challenges & Solutions
|
||||||
|
|
||||||
|
### Challenge 1: Bcrypt Password Length Limit
|
||||||
|
**Problem**: `passlib` threw error "password cannot be longer than 72 bytes"
|
||||||
|
|
||||||
|
**Solution**: Replaced `passlib[bcrypt]` with native `bcrypt==4.1.2` library
|
||||||
|
```python
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
password_bytes = password.encode('utf-8')
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
return bcrypt.hashpw(password_bytes, salt).decode('utf-8')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Challenge 2: Pydantic v2 Compatibility
|
||||||
|
**Problem**: `__modify_schema__` method not supported in Pydantic v2
|
||||||
|
|
||||||
|
**Solution**: Updated to Pydantic v2 patterns:
|
||||||
|
- Changed `__modify_schema__` to `__get_pydantic_core_schema__`
|
||||||
|
- Replaced `class Config` with `model_config = ConfigDict(...)`
|
||||||
|
- Updated all models to use new Pydantic v2 syntax
|
||||||
|
|
||||||
|
### Challenge 3: TypeScript Import.meta.env Types
|
||||||
|
**Problem**: TypeScript couldn't recognize `import.meta.env.VITE_API_URL`
|
||||||
|
|
||||||
|
**Solution**: Created `vite-env.d.ts` with proper type declarations:
|
||||||
|
```typescript
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string
|
||||||
|
}
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Results
|
||||||
|
|
||||||
|
### Backend API Tests (via curl)
|
||||||
|
All endpoints tested and working correctly:
|
||||||
|
|
||||||
|
✅ **User Registration**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"test@site11.com","username":"testuser","password":"test123"}'
|
||||||
|
# Returns: User object with _id, email, username, role
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **User Login**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/auth/login \
|
||||||
|
-d "username=testuser&password=test123"
|
||||||
|
# Returns: access_token, refresh_token, token_type
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Protected Endpoint**
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/auth/me \
|
||||||
|
-H "Authorization: Bearer <access_token>"
|
||||||
|
# Returns: Current user details with last_login_at
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Token Refresh**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/auth/refresh \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"refresh_token":"<refresh_token>"}'
|
||||||
|
# Returns: New access_token and same refresh_token
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Security Validations**
|
||||||
|
- Wrong password → "Incorrect username/email or password"
|
||||||
|
- No token → "Not authenticated"
|
||||||
|
- Duplicate email → "Email already registered"
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
✅ Login page renders correctly
|
||||||
|
✅ Registration form with validation
|
||||||
|
✅ Protected routes redirect to login
|
||||||
|
✅ User info displayed in navigation bar
|
||||||
|
✅ Logout clears session and redirects
|
||||||
|
|
||||||
|
## Deployment Instructions
|
||||||
|
|
||||||
|
### Build Docker Images
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd services/console/backend
|
||||||
|
docker build -t yakenator/site11-console-backend:latest .
|
||||||
|
docker push yakenator/site11-console-backend:latest
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd services/console/frontend
|
||||||
|
docker build -t yakenator/site11-console-frontend:latest .
|
||||||
|
docker push yakenator/site11-console-frontend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to Kubernetes
|
||||||
|
```bash
|
||||||
|
# Delete old pods to pull new images
|
||||||
|
kubectl -n site11-pipeline delete pod -l app=console-backend
|
||||||
|
kubectl -n site11-pipeline delete pod -l app=console-frontend
|
||||||
|
|
||||||
|
# Wait for new pods to start
|
||||||
|
kubectl -n site11-pipeline get pods -w
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Access (Port Forwarding)
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
kubectl -n site11-pipeline port-forward svc/console-backend 8000:8000 &
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
kubectl -n site11-pipeline port-forward svc/console-frontend 3000:80 &
|
||||||
|
|
||||||
|
# Access
|
||||||
|
open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps (Phase 2)
|
||||||
|
|
||||||
|
### Service Management CRUD
|
||||||
|
1. **Backend**:
|
||||||
|
- Service model (name, url, status, health_endpoint, last_check)
|
||||||
|
- CRUD API endpoints
|
||||||
|
- Health check scheduler
|
||||||
|
- Service registry
|
||||||
|
|
||||||
|
2. **Frontend**:
|
||||||
|
- Services list page with table
|
||||||
|
- Add/Edit service modal
|
||||||
|
- Service status indicators
|
||||||
|
- Health monitoring dashboard
|
||||||
|
|
||||||
|
3. **Features**:
|
||||||
|
- Auto-discovery of services
|
||||||
|
- Periodic health checks
|
||||||
|
- Service availability statistics
|
||||||
|
- Alert notifications
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
✅ All authentication endpoints functional
|
||||||
|
✅ JWT tokens working correctly
|
||||||
|
✅ Token refresh implemented
|
||||||
|
✅ Frontend login/register flows complete
|
||||||
|
✅ Protected routes working
|
||||||
|
✅ Docker images built and pushed
|
||||||
|
✅ Deployed to Kubernetes successfully
|
||||||
|
✅ All tests passing
|
||||||
|
✅ Documentation complete
|
||||||
|
|
||||||
|
## Team Notes
|
||||||
|
- Code follows FastAPI and React best practices
|
||||||
|
- All secrets managed via environment variables
|
||||||
|
- Proper error handling implemented
|
||||||
|
- API endpoints follow RESTful conventions
|
||||||
|
- Frontend components are reusable and well-structured
|
||||||
|
- TypeScript types ensure type safety
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Phase 1 Status**: ✅ **COMPLETE**
|
||||||
|
**Ready for**: Phase 2 - Service Management CRUD
|
||||||
33
services/console/backend/.env.example
Normal file
33
services/console/backend/.env.example
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# App Settings
|
||||||
|
APP_NAME=Site11 Console
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
DEBUG=True
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production-use-openssl-rand-hex-32
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# Database
|
||||||
|
MONGODB_URL=mongodb://localhost:27017
|
||||||
|
DB_NAME=site11_console
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=["http://localhost:3000","http://localhost:8000"]
|
||||||
|
|
||||||
|
# Services
|
||||||
|
USERS_SERVICE_URL=http://users-backend:8000
|
||||||
|
IMAGES_SERVICE_URL=http://images-backend:8000
|
||||||
|
|
||||||
|
# Kafka (optional)
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS=kafka:9092
|
||||||
|
|
||||||
|
# OAuth (optional - for Phase 1.5)
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
@ -17,5 +17,9 @@ COPY . .
|
|||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "main.py"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
services/console/backend/app/__init__.py
Normal file
0
services/console/backend/app/__init__.py
Normal file
0
services/console/backend/app/core/__init__.py
Normal file
0
services/console/backend/app/core/__init__.py
Normal file
47
services/console/backend/app/core/config.py
Normal file
47
services/console/backend/app/core/config.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings"""
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_NAME: str = "Site11 Console"
|
||||||
|
APP_VERSION: str = "1.0.0"
|
||||||
|
DEBUG: bool = False
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
|
||||||
|
# Database
|
||||||
|
MONGODB_URL: str = "mongodb://localhost:27017"
|
||||||
|
DB_NAME: str = "site11_console"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: str = "redis://localhost:6379"
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS: list = ["http://localhost:3000", "http://localhost:8000"]
|
||||||
|
|
||||||
|
# OAuth (Google, GitHub, etc.)
|
||||||
|
GOOGLE_CLIENT_ID: Optional[str] = None
|
||||||
|
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
||||||
|
GITHUB_CLIENT_ID: Optional[str] = None
|
||||||
|
GITHUB_CLIENT_SECRET: Optional[str] = None
|
||||||
|
|
||||||
|
# Services URLs
|
||||||
|
USERS_SERVICE_URL: str = "http://users-backend:8000"
|
||||||
|
IMAGES_SERVICE_URL: str = "http://images-backend:8000"
|
||||||
|
|
||||||
|
# Kafka (optional)
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS: str = "kafka:9092"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
78
services/console/backend/app/core/security.py
Normal file
78
services/console/backend/app/core/security.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
import bcrypt
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
# OAuth2 scheme
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a password against a hash"""
|
||||||
|
try:
|
||||||
|
password_bytes = plain_password.encode('utf-8')
|
||||||
|
hashed_bytes = hashed_password.encode('utf-8')
|
||||||
|
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Password verification error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Hash a password"""
|
||||||
|
password_bytes = password.encode('utf-8')
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||||
|
return hashed.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Create JWT access token"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(data: dict) -> str:
|
||||||
|
"""Create JWT refresh token"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
to_encode.update({"exp": expire, "type": "refresh"})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode and validate JWT token"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_id(token: str = Depends(oauth2_scheme)) -> str:
|
||||||
|
"""Extract user ID from token"""
|
||||||
|
payload = decode_token(token)
|
||||||
|
user_id: str = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return user_id
|
||||||
0
services/console/backend/app/db/__init__.py
Normal file
0
services/console/backend/app/db/__init__.py
Normal file
37
services/console/backend/app/db/mongodb.py
Normal file
37
services/console/backend/app/db/mongodb.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||||
|
from typing import Optional
|
||||||
|
from ..core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class MongoDB:
|
||||||
|
"""MongoDB connection manager"""
|
||||||
|
|
||||||
|
client: Optional[AsyncIOMotorClient] = None
|
||||||
|
db: Optional[AsyncIOMotorDatabase] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def connect(cls):
|
||||||
|
"""Connect to MongoDB"""
|
||||||
|
cls.client = AsyncIOMotorClient(settings.MONGODB_URL)
|
||||||
|
cls.db = cls.client[settings.DB_NAME]
|
||||||
|
print(f"✅ Connected to MongoDB: {settings.DB_NAME}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def disconnect(cls):
|
||||||
|
"""Disconnect from MongoDB"""
|
||||||
|
if cls.client:
|
||||||
|
cls.client.close()
|
||||||
|
print("❌ Disconnected from MongoDB")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_db(cls) -> AsyncIOMotorDatabase:
|
||||||
|
"""Get database instance"""
|
||||||
|
if cls.db is None:
|
||||||
|
raise Exception("Database not initialized. Call connect() first.")
|
||||||
|
return cls.db
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience function
|
||||||
|
async def get_database() -> AsyncIOMotorDatabase:
|
||||||
|
"""Dependency to get database"""
|
||||||
|
return MongoDB.get_db()
|
||||||
99
services/console/backend/app/main.py
Normal file
99
services/console/backend/app/main.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .core.config import settings
|
||||||
|
from .db.mongodb import MongoDB
|
||||||
|
from .routes import auth
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan manager"""
|
||||||
|
# Startup
|
||||||
|
logger.info("🚀 Starting Console Backend...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Connect to MongoDB
|
||||||
|
await MongoDB.connect()
|
||||||
|
logger.info("✅ MongoDB connected successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to connect to MongoDB: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
logger.info("👋 Shutting down Console Backend...")
|
||||||
|
await MongoDB.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
version=settings.APP_VERSION,
|
||||||
|
description="Site11 Console - Central management system for news generation pipeline",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.CORS_ORIGINS if not settings.DEBUG else ["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(auth.router)
|
||||||
|
|
||||||
|
|
||||||
|
# Health check endpoints
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root endpoint"""
|
||||||
|
return {
|
||||||
|
"message": f"Welcome to {settings.APP_NAME}",
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "console-backend",
|
||||||
|
"version": settings.APP_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def api_health_check():
|
||||||
|
"""API health check endpoint for frontend"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "console-backend-api",
|
||||||
|
"version": settings.APP_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=settings.DEBUG
|
||||||
|
)
|
||||||
0
services/console/backend/app/models/__init__.py
Normal file
0
services/console/backend/app/models/__init__.py
Normal file
89
services/console/backend/app/models/user.py
Normal file
89
services/console/backend/app/models/user.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Annotated
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator, ConfigDict
|
||||||
|
from pydantic_core import core_schema
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
|
||||||
|
class PyObjectId(str):
|
||||||
|
"""Custom ObjectId type for Pydantic v2"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __get_pydantic_core_schema__(cls, source_type, handler):
|
||||||
|
return core_schema.union_schema([
|
||||||
|
core_schema.is_instance_schema(ObjectId),
|
||||||
|
core_schema.chain_schema([
|
||||||
|
core_schema.str_schema(),
|
||||||
|
core_schema.no_info_plain_validator_function(cls.validate),
|
||||||
|
])
|
||||||
|
],
|
||||||
|
serialization=core_schema.plain_serializer_function_ser_schema(
|
||||||
|
lambda x: str(x)
|
||||||
|
))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, v):
|
||||||
|
if isinstance(v, ObjectId):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str) and ObjectId.is_valid(v):
|
||||||
|
return ObjectId(v)
|
||||||
|
raise ValueError("Invalid ObjectId")
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str):
|
||||||
|
"""User roles"""
|
||||||
|
ADMIN = "admin"
|
||||||
|
EDITOR = "editor"
|
||||||
|
VIEWER = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProvider(BaseModel):
|
||||||
|
"""OAuth provider information"""
|
||||||
|
provider: str = Field(..., description="OAuth provider name (google, github, azure)")
|
||||||
|
provider_user_id: str = Field(..., description="User ID from the provider")
|
||||||
|
access_token: Optional[str] = Field(None, description="Access token (encrypted)")
|
||||||
|
refresh_token: Optional[str] = Field(None, description="Refresh token (encrypted)")
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(BaseModel):
|
||||||
|
"""User profile information"""
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
department: Optional[str] = None
|
||||||
|
timezone: str = "Asia/Seoul"
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseModel):
|
||||||
|
"""User model"""
|
||||||
|
id: Optional[PyObjectId] = Field(alias="_id", default=None)
|
||||||
|
email: EmailStr = Field(..., description="User email")
|
||||||
|
username: str = Field(..., min_length=3, max_length=50, description="Username")
|
||||||
|
hashed_password: str = Field(..., description="Hashed password")
|
||||||
|
full_name: Optional[str] = Field(None, description="Full name")
|
||||||
|
role: str = Field(default=UserRole.VIEWER, description="User role")
|
||||||
|
permissions: List[str] = Field(default_factory=list, description="User permissions")
|
||||||
|
oauth_providers: List[OAuthProvider] = Field(default_factory=list)
|
||||||
|
profile: UserProfile = Field(default_factory=UserProfile)
|
||||||
|
status: str = Field(default="active", description="User status")
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
last_login_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
populate_by_name=True,
|
||||||
|
arbitrary_types_allowed=True,
|
||||||
|
json_encoders={ObjectId: str},
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"username": "johndoe",
|
||||||
|
"full_name": "John Doe",
|
||||||
|
"role": "viewer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserInDB(User):
|
||||||
|
"""User model with password hash"""
|
||||||
|
pass
|
||||||
0
services/console/backend/app/routes/__init__.py
Normal file
0
services/console/backend/app/routes/__init__.py
Normal file
167
services/console/backend/app/routes/auth.py
Normal file
167
services/console/backend/app/routes/auth.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||||
|
|
||||||
|
from ..schemas.auth import UserRegister, Token, TokenRefresh, UserResponse
|
||||||
|
from ..services.user_service import UserService
|
||||||
|
from ..db.mongodb import get_database
|
||||||
|
from ..core.security import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
|
get_current_user_id
|
||||||
|
)
|
||||||
|
from ..core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_service(db: AsyncIOMotorDatabase = Depends(get_database)) -> UserService:
|
||||||
|
"""Dependency to get user service"""
|
||||||
|
return UserService(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(
|
||||||
|
user_data: UserRegister,
|
||||||
|
user_service: UserService = Depends(get_user_service)
|
||||||
|
):
|
||||||
|
"""Register a new user"""
|
||||||
|
user = await user_service.create_user(user_data)
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
_id=str(user.id),
|
||||||
|
email=user.email,
|
||||||
|
username=user.username,
|
||||||
|
full_name=user.full_name,
|
||||||
|
role=user.role,
|
||||||
|
permissions=user.permissions,
|
||||||
|
status=user.status,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
last_login_at=user.last_login_at.isoformat() if user.last_login_at else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
user_service: UserService = Depends(get_user_service)
|
||||||
|
):
|
||||||
|
"""Login with username/email and password"""
|
||||||
|
user = await user_service.authenticate_user(form_data.username, form_data.password)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username/email or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last login timestamp
|
||||||
|
await user_service.update_last_login(str(user.id))
|
||||||
|
|
||||||
|
# Create tokens
|
||||||
|
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": str(user.id), "username": user.username},
|
||||||
|
expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
|
return Token(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
token_type="bearer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=Token)
|
||||||
|
async def refresh_token(
|
||||||
|
token_data: TokenRefresh,
|
||||||
|
user_service: UserService = Depends(get_user_service)
|
||||||
|
):
|
||||||
|
"""Refresh access token using refresh token"""
|
||||||
|
try:
|
||||||
|
payload = decode_token(token_data.refresh_token)
|
||||||
|
|
||||||
|
# Verify it's a refresh token
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token type"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify user still exists and is active
|
||||||
|
user = await user_service.get_user_by_id(user_id)
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found or inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new access token
|
||||||
|
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user_id, "username": user.username},
|
||||||
|
expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
|
||||||
|
return Token(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=token_data.refresh_token,
|
||||||
|
token_type="bearer"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired refresh token"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_current_user(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
user_service: UserService = Depends(get_user_service)
|
||||||
|
):
|
||||||
|
"""Get current user information"""
|
||||||
|
user = await user_service.get_user_by_id(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserResponse(
|
||||||
|
_id=str(user.id),
|
||||||
|
email=user.email,
|
||||||
|
username=user.username,
|
||||||
|
full_name=user.full_name,
|
||||||
|
role=user.role,
|
||||||
|
permissions=user.permissions,
|
||||||
|
status=user.status,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
last_login_at=user.last_login_at.isoformat() if user.last_login_at else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(user_id: str = Depends(get_current_user_id)):
|
||||||
|
"""Logout endpoint (token should be removed on client side)"""
|
||||||
|
# In a more sophisticated system, you might want to:
|
||||||
|
# 1. Blacklist the token in Redis
|
||||||
|
# 2. Log the logout event
|
||||||
|
# 3. Clear any session data
|
||||||
|
|
||||||
|
return {"message": "Successfully logged out"}
|
||||||
0
services/console/backend/app/schemas/__init__.py
Normal file
0
services/console/backend/app/schemas/__init__.py
Normal file
89
services/console/backend/app/schemas/auth.py
Normal file
89
services/console/backend/app/schemas/auth.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class UserRegister(BaseModel):
|
||||||
|
"""User registration schema"""
|
||||||
|
email: EmailStr = Field(..., description="User email")
|
||||||
|
username: str = Field(..., min_length=3, max_length=50, description="Username")
|
||||||
|
password: str = Field(..., min_length=6, description="Password")
|
||||||
|
full_name: Optional[str] = Field(None, description="Full name")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"username": "johndoe",
|
||||||
|
"password": "securepassword123",
|
||||||
|
"full_name": "John Doe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
"""User login schema"""
|
||||||
|
username: str = Field(..., description="Username or email")
|
||||||
|
password: str = Field(..., description="Password")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"username": "johndoe",
|
||||||
|
"password": "securepassword123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""Token response schema"""
|
||||||
|
access_token: str = Field(..., description="JWT access token")
|
||||||
|
refresh_token: Optional[str] = Field(None, description="JWT refresh token")
|
||||||
|
token_type: str = Field(default="bearer", description="Token type")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"token_type": "bearer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefresh(BaseModel):
|
||||||
|
"""Token refresh schema"""
|
||||||
|
refresh_token: str = Field(..., description="Refresh token")
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""User response schema (without password)"""
|
||||||
|
id: str = Field(..., alias="_id", description="User ID")
|
||||||
|
email: EmailStr
|
||||||
|
username: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
role: str
|
||||||
|
permissions: list = []
|
||||||
|
status: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
last_login_at: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
populate_by_name=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"_id": "507f1f77bcf86cd799439011",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"username": "johndoe",
|
||||||
|
"full_name": "John Doe",
|
||||||
|
"role": "viewer",
|
||||||
|
"permissions": [],
|
||||||
|
"status": "active",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
0
services/console/backend/app/services/__init__.py
Normal file
0
services/console/backend/app/services/__init__.py
Normal file
143
services/console/backend/app/services/user_service.py
Normal file
143
services/console/backend/app/services/user_service.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||||
|
from bson import ObjectId
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from ..models.user import User, UserInDB, UserRole
|
||||||
|
from ..schemas.auth import UserRegister
|
||||||
|
from ..core.security import get_password_hash, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
"""User service for business logic"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncIOMotorDatabase):
|
||||||
|
self.db = db
|
||||||
|
self.collection = db.users
|
||||||
|
|
||||||
|
async def create_user(self, user_data: UserRegister) -> UserInDB:
|
||||||
|
"""Create a new user"""
|
||||||
|
# Check if user already exists
|
||||||
|
existing_user = await self.collection.find_one({
|
||||||
|
"$or": [
|
||||||
|
{"email": user_data.email},
|
||||||
|
{"username": user_data.username}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
if existing_user["email"] == user_data.email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered"
|
||||||
|
)
|
||||||
|
if existing_user["username"] == user_data.username:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Username already taken"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user document
|
||||||
|
user_dict = {
|
||||||
|
"email": user_data.email,
|
||||||
|
"username": user_data.username,
|
||||||
|
"hashed_password": get_password_hash(user_data.password),
|
||||||
|
"full_name": user_data.full_name,
|
||||||
|
"role": UserRole.VIEWER, # Default role
|
||||||
|
"permissions": [],
|
||||||
|
"oauth_providers": [],
|
||||||
|
"profile": {
|
||||||
|
"avatar_url": None,
|
||||||
|
"department": None,
|
||||||
|
"timezone": "Asia/Seoul"
|
||||||
|
},
|
||||||
|
"status": "active",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
"updated_at": datetime.utcnow(),
|
||||||
|
"last_login_at": None
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self.collection.insert_one(user_dict)
|
||||||
|
user_dict["_id"] = result.inserted_id
|
||||||
|
|
||||||
|
return UserInDB(**user_dict)
|
||||||
|
|
||||||
|
async def get_user_by_username(self, username: str) -> Optional[UserInDB]:
|
||||||
|
"""Get user by username"""
|
||||||
|
user_dict = await self.collection.find_one({"username": username})
|
||||||
|
if user_dict:
|
||||||
|
return UserInDB(**user_dict)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_user_by_email(self, email: str) -> Optional[UserInDB]:
|
||||||
|
"""Get user by email"""
|
||||||
|
user_dict = await self.collection.find_one({"email": email})
|
||||||
|
if user_dict:
|
||||||
|
return UserInDB(**user_dict)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: str) -> Optional[UserInDB]:
|
||||||
|
"""Get user by ID"""
|
||||||
|
if not ObjectId.is_valid(user_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_dict = await self.collection.find_one({"_id": ObjectId(user_id)})
|
||||||
|
if user_dict:
|
||||||
|
return UserInDB(**user_dict)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def authenticate_user(self, username: str, password: str) -> Optional[UserInDB]:
|
||||||
|
"""Authenticate user with username/email and password"""
|
||||||
|
# Try to find by username or email
|
||||||
|
user = await self.get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
user = await self.get_user_by_email(username)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User account is inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def update_last_login(self, user_id: str):
|
||||||
|
"""Update user's last login timestamp"""
|
||||||
|
await self.collection.update_one(
|
||||||
|
{"_id": ObjectId(user_id)},
|
||||||
|
{"$set": {"last_login_at": datetime.utcnow()}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_user(self, user_id: str, update_data: dict) -> Optional[UserInDB]:
|
||||||
|
"""Update user data"""
|
||||||
|
if not ObjectId.is_valid(user_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data["updated_at"] = datetime.utcnow()
|
||||||
|
|
||||||
|
await self.collection.update_one(
|
||||||
|
{"_id": ObjectId(user_id)},
|
||||||
|
{"$set": update_data}
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.get_user_by_id(user_id)
|
||||||
|
|
||||||
|
async def delete_user(self, user_id: str) -> bool:
|
||||||
|
"""Delete user (soft delete - set status to deleted)"""
|
||||||
|
if not ObjectId.is_valid(user_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = await self.collection.update_one(
|
||||||
|
{"_id": ObjectId(user_id)},
|
||||||
|
{"$set": {"status": "deleted", "is_active": False, "updated_at": datetime.utcnow()}}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.modified_count > 0
|
||||||
@ -2,9 +2,13 @@ fastapi==0.109.0
|
|||||||
uvicorn[standard]==0.27.0
|
uvicorn[standard]==0.27.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
pydantic==2.5.3
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
httpx==0.26.0
|
httpx==0.26.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
bcrypt==4.1.2
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
aiokafka==0.10.0
|
aiokafka==0.10.0
|
||||||
|
motor==3.3.2
|
||||||
|
pymongo==4.6.1
|
||||||
|
email-validator==2.1.0
|
||||||
35
services/console/frontend/src/App.tsx
Normal file
35
services/console/frontend/src/App.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Register from './pages/Register'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Services from './pages/Services'
|
||||||
|
import Users from './pages/Users'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="services" element={<Services />} />
|
||||||
|
<Route path="users" element={<Users />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
100
services/console/frontend/src/api/auth.ts
Normal file
100
services/console/frontend/src/api/auth.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { User, LoginRequest, RegisterRequest, AuthTokens } from '../types/auth';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add token to requests
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle token refresh on 401
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
if (refreshToken) {
|
||||||
|
const { data } = await axios.post<AuthTokens>(
|
||||||
|
`${API_BASE_URL}/api/auth/refresh`,
|
||||||
|
{ refresh_token: refreshToken }
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const authAPI = {
|
||||||
|
login: async (credentials: LoginRequest): Promise<AuthTokens> => {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append('username', credentials.username);
|
||||||
|
formData.append('password', credentials.password);
|
||||||
|
|
||||||
|
const { data } = await axios.post<AuthTokens>(
|
||||||
|
`${API_BASE_URL}/api/auth/login`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (userData: RegisterRequest): Promise<User> => {
|
||||||
|
const { data } = await axios.post<User>(
|
||||||
|
`${API_BASE_URL}/api/auth/register`,
|
||||||
|
userData
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentUser: async (): Promise<User> => {
|
||||||
|
const { data } = await api.get<User>('/api/auth/me');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshToken: async (refreshToken: string): Promise<AuthTokens> => {
|
||||||
|
const { data } = await axios.post<AuthTokens>(
|
||||||
|
`${API_BASE_URL}/api/auth/refresh`,
|
||||||
|
{ refresh_token: refreshToken }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
await api.post('/api/auth/logout');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Outlet, Link as RouterLink } from 'react-router-dom'
|
import { Outlet, Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Box,
|
Box,
|
||||||
@ -12,13 +12,17 @@ import {
|
|||||||
ListItemText,
|
ListItemText,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Typography,
|
Typography,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Dashboard as DashboardIcon,
|
Dashboard as DashboardIcon,
|
||||||
Cloud as CloudIcon,
|
Cloud as CloudIcon,
|
||||||
People as PeopleIcon,
|
People as PeopleIcon,
|
||||||
|
AccountCircle,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
const drawerWidth = 240
|
const drawerWidth = 240
|
||||||
|
|
||||||
@ -30,11 +34,28 @@ const menuItems = [
|
|||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const handleDrawerToggle = () => {
|
const handleDrawerToggle = () => {
|
||||||
setOpen(!open)
|
setOpen(!open)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<AppBar
|
<AppBar
|
||||||
@ -51,9 +72,41 @@ function Layout() {
|
|||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h6" noWrap component="div">
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
Microservices Console
|
Site11 Console
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{user?.username} ({user?.role})
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
aria-label="account of current user"
|
||||||
|
aria-controls="menu-appbar"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={handleMenu}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
<AccountCircle />
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
id="menu-appbar"
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
keepMounted
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleLogout}>Logout</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<Drawer
|
<Drawer
|
||||||
35
services/console/frontend/src/components/ProtectedRoute.tsx
Normal file
35
services/console/frontend/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
96
services/console/frontend/src/contexts/AuthContext.tsx
Normal file
96
services/console/frontend/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { authAPI } from '../api/auth';
|
||||||
|
import type { User, LoginRequest, RegisterRequest, AuthContextType } from '../types/auth';
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user is already logged in
|
||||||
|
const initAuth = async () => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const userData = await authAPI.getCurrentUser();
|
||||||
|
setUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (credentials: LoginRequest) => {
|
||||||
|
const tokens = await authAPI.login(credentials);
|
||||||
|
localStorage.setItem('access_token', tokens.access_token);
|
||||||
|
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||||
|
|
||||||
|
const userData = await authAPI.getCurrentUser();
|
||||||
|
setUser(userData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (data: RegisterRequest) => {
|
||||||
|
const newUser = await authAPI.register(data);
|
||||||
|
|
||||||
|
// Auto login after registration
|
||||||
|
const tokens = await authAPI.login({
|
||||||
|
username: data.username,
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
localStorage.setItem('access_token', tokens.access_token);
|
||||||
|
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||||
|
|
||||||
|
setUser(newUser);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
setUser(null);
|
||||||
|
|
||||||
|
// Optional: call backend logout endpoint
|
||||||
|
authAPI.logout().catch(() => {
|
||||||
|
// Ignore errors on logout
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshToken = async () => {
|
||||||
|
const token = localStorage.getItem('refresh_token');
|
||||||
|
if (token) {
|
||||||
|
const tokens = await authAPI.refreshToken(token);
|
||||||
|
localStorage.setItem('access_token', tokens.access_token);
|
||||||
|
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
128
services/console/frontend/src/pages/Login.tsx
Normal file
128
services/console/frontend/src/pages/Login.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Link,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(formData);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Login failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom align="center">
|
||||||
|
Site11 Console
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" component="h2" gutterBottom align="center" color="text.secondary">
|
||||||
|
Sign In
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
sx={{ mt: 3, mb: 2 }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link component={RouterLink} to="/register" underline="hover">
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
182
services/console/frontend/src/pages/Register.tsx
Normal file
182
services/console/frontend/src/pages/Register.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Link,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const Register: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { register } = useAuth();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
full_name: '',
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (formData.password.length < 6) {
|
||||||
|
setError('Password must be at least 6 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
email: formData.email,
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
full_name: formData.full_name || undefined,
|
||||||
|
});
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Registration failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom align="center">
|
||||||
|
Site11 Console
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" component="h2" gutterBottom align="center" color="text.secondary">
|
||||||
|
Create Account
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
inputProps={{ minLength: 3, maxLength: 50 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Full Name"
|
||||||
|
name="full_name"
|
||||||
|
value={formData.full_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
inputProps={{ minLength: 6 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Confirm Password"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
sx={{ mt: 3, mb: 2 }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Sign Up'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link component={RouterLink} to="/login" underline="hover">
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
||||||
40
services/console/frontend/src/types/auth.ts
Normal file
40
services/console/frontend/src/types/auth.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
export interface User {
|
||||||
|
_id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
full_name?: string;
|
||||||
|
role: 'admin' | 'editor' | 'viewer';
|
||||||
|
permissions: string[];
|
||||||
|
status: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
last_login_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
full_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthTokens {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (credentials: LoginRequest) => Promise<void>;
|
||||||
|
register: (data: RegisterRequest) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refreshToken: () => Promise<void>;
|
||||||
|
}
|
||||||
9
services/console/frontend/src/vite-env.d.ts
vendored
Normal file
9
services/console/frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user