diff --git a/console/frontend/src/App.tsx b/console/frontend/src/App.tsx
deleted file mode 100644
index 7a5285e..0000000
--- a/console/frontend/src/App.tsx
+++ /dev/null
@@ -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 (
-
- }>
- } />
- } />
- } />
-
-
- )
-}
-
-export default App
\ No newline at end of file
diff --git a/docs/CONSOLE_ARCHITECTURE.md b/docs/CONSOLE_ARCHITECTURE.md
new file mode 100644
index 0000000..fb7276c
--- /dev/null
+++ b/docs/CONSOLE_ARCHITECTURE.md
@@ -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
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index e1d2872..e4269bd 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -5,123 +5,232 @@
## Current Status
- **Date Started**: 2025-09-09
-- **Current Phase**: Step 3 Complete ✅
-- **Next Action**: Step 4 - Frontend Skeleton
+- **Last Updated**: 2025-10-28
+- **Current Phase**: Phase 1 Complete ✅ (Authentication System)
+- **Next Action**: Phase 2 - Service Management CRUD
## 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)
✅ Implementation plan created (docs/PLAN.md)
✅ Progressive approach defined
✅ 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)
- - 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
- - MongoDB and Redis containers added
- - Users service using MongoDB with Beanie ODM
- - Data persistence verified
- - MongoDB IDs: 68c126c0bbbe52be68495933
## Active Working Files
```
-현재 작업 중인 주요 파일:
+주요 작업 파일:
+- /services/console/backend/ (Console Backend - FastAPI)
+- /services/console/frontend/ (Console Frontend - React + TypeScript)
+- /docs/CONSOLE_ARCHITECTURE.md (시스템 아키텍처)
- /docs/PLAN.md (구현 계획)
-- /CLAUDE.md (아키텍처 가이드)
- /docs/PROGRESS.md (이 파일)
+- /CLAUDE.md (개발 가이드라인)
```
-## Next Immediate Steps
+## Deployment Status
+
+### Kubernetes Cluster: site11-pipeline
```bash
-# 다음 작업 시작 명령
-# Step 1: Create docker-compose.yml
-# Step 2: Create console/backend/main.py
-# Step 3: Test with docker-compose up
+# Backend
+kubectl -n site11-pipeline get pods -l app=console-backend
+# Status: 2/2 Running
+
+# 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
-```yaml
-version: '3.8'
-services:
- console:
- build: ./console/backend
- ports:
- - "8000:8000"
- environment:
- - ENV=development
+## Next Immediate Steps (Phase 2)
+
+### Service Management CRUD
```
+1. Backend API for service management
+ - Service model (name, url, status, health_endpoint)
+ - CRUD endpoints
+ - Health check mechanism
-### 2. Console main.py starter
-```python
-from fastapi import FastAPI
-app = FastAPI(title="Console API Gateway")
+2. Frontend Service Management UI
+ - Service list page
+ - Add/Edit service form
+ - Service status display
+ - Health monitoring
-@app.get("/health")
-async def health():
- return {"status": "healthy", "service": "console"}
+3. Service Discovery & Registry
+ - Auto-discovery of services
+ - Heartbeat mechanism
+ - Status dashboard
```
## Important Decisions Made
1. **Architecture**: API Gateway Pattern with Console as orchestrator
-2. **Tech Stack**: FastAPI + React + MongoDB + Redis + Docker
-3. **Approach**: Progressive implementation (simple to complex)
-4. **First Service**: Users service after Console
+2. **Tech Stack**: FastAPI + React + MongoDB + Redis + Docker + Kubernetes
+3. **Authentication**: JWT with access/refresh tokens
+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
새로운 세션에서 이어서 작업할 때 확인할 사항:
-1. "PROGRESS.md 파일을 확인했나요?"
-2. "마지막으로 완료한 Step은 무엇인가요?"
-3. "현재 에러나 블로킹 이슈가 있나요?"
+1. "Phase 1 (Authentication) 완료 확인?"
+2. "Kubernetes 클러스터 정상 동작 중?"
+3. "다음 Phase 2 (Service Management) 시작할까요?"
-## Git Commits Pattern
-각 Step 완료 시 커밋 메시지:
-```
-Step X: [간단한 설명]
-- 구현 내용 1
-- 구현 내용 2
-```
+## Git Workflow
+```bash
+# Current branch
+main
-## Directory Structure Snapshot
-```
-site11/
-├── CLAUDE.md ✅ Created
-├── docs/
-│ ├── PLAN.md ✅ Created
-│ └── PROGRESS.md ✅ Created (this file)
-├── console/ 🔄 Next
-│ └── backend/
-│ └── main.py
-└── docker-compose.yml 🔄 Next
+# Commit pattern
+git add .
+git commit -m "feat: Phase 1 - Complete authentication system
+
+- Backend: JWT auth with FastAPI + MongoDB
+- Frontend: Login/Register with React + TypeScript
+- Docker images built and deployed to Kubernetes
+- All authentication endpoints tested
+
+🤖 Generated with Claude Code
+Co-Authored-By: Claude "
+
+git push origin main
```
## Context Recovery Commands
새 세션에서 빠르게 상황 파악하기:
```bash
# 1. 현재 구조 확인
-ls -la
+ls -la services/console/
# 2. 진행 상황 확인
-cat docs/PROGRESS.md
+cat docs/PROGRESS.md | grep "Current Phase"
-# 3. 다음 단계 확인
-grep "Step" docs/PLAN.md | head -5
+# 3. Kubernetes 상태 확인
+kubectl -n site11-pipeline get pods
-# 4. 실행 중인 컨테이너 확인
-docker ps
+# 4. Docker 이미지 확인
+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
-- Step 1부터 시작
-- docker-compose.yml 생성 필요
-- console/backend/main.py 생성 필요
-- 모든 문서 파일은 대문자.md 형식으로 생성 (예: README.md, SETUP.md)
\ No newline at end of file
+- Phase 1 완료! Authentication 시스템 완전히 작동함
+- 모든 코드는 services/console/ 디렉토리에 있음
+- Docker 이미지는 yakenator/site11-console-* 로 푸시됨
+- Kubernetes에 배포되어 있음 (site11-pipeline namespace)
+- Phase 2: Service Management CRUD 구현 시작 가능
diff --git a/services/console/PHASE1_COMPLETION.md b/services/console/PHASE1_COMPLETION.md
new file mode 100644
index 0000000..06876c7
--- /dev/null
+++ b/services/console/PHASE1_COMPLETION.md
@@ -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 "
+# 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":""}'
+# 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
diff --git a/services/console/backend/.env.example b/services/console/backend/.env.example
new file mode 100644
index 0000000..e57bc1f
--- /dev/null
+++ b/services/console/backend/.env.example
@@ -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=
diff --git a/console/backend/Dockerfile b/services/console/backend/Dockerfile
similarity index 72%
rename from console/backend/Dockerfile
rename to services/console/backend/Dockerfile
index 2515968..aa83ad8 100644
--- a/console/backend/Dockerfile
+++ b/services/console/backend/Dockerfile
@@ -17,5 +17,9 @@ COPY . .
# Expose port
EXPOSE 8000
+# Environment variables
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONPATH=/app
+
# Run the application
-CMD ["python", "main.py"]
\ No newline at end of file
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
diff --git a/services/console/backend/app/__init__.py b/services/console/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/console/backend/app/core/__init__.py b/services/console/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/console/backend/app/core/config.py b/services/console/backend/app/core/config.py
new file mode 100644
index 0000000..99f7214
--- /dev/null
+++ b/services/console/backend/app/core/config.py
@@ -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()
diff --git a/services/console/backend/app/core/security.py b/services/console/backend/app/core/security.py
new file mode 100644
index 0000000..950896d
--- /dev/null
+++ b/services/console/backend/app/core/security.py
@@ -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
diff --git a/services/console/backend/app/db/__init__.py b/services/console/backend/app/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/console/backend/app/db/mongodb.py b/services/console/backend/app/db/mongodb.py
new file mode 100644
index 0000000..faa6530
--- /dev/null
+++ b/services/console/backend/app/db/mongodb.py
@@ -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()
diff --git a/services/console/backend/app/main.py b/services/console/backend/app/main.py
new file mode 100644
index 0000000..8e6e983
--- /dev/null
+++ b/services/console/backend/app/main.py
@@ -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
+ )
diff --git a/services/console/backend/app/models/__init__.py b/services/console/backend/app/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/console/backend/app/models/user.py b/services/console/backend/app/models/user.py
new file mode 100644
index 0000000..55e5e58
--- /dev/null
+++ b/services/console/backend/app/models/user.py
@@ -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
diff --git a/services/console/backend/app/routes/__init__.py b/services/console/backend/app/routes/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/console/backend/app/routes/auth.py b/services/console/backend/app/routes/auth.py
new file mode 100644
index 0000000..0bd4bc7
--- /dev/null
+++ b/services/console/backend/app/routes/auth.py
@@ -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"}
diff --git a/services/console/backend/app/schemas/__init__.py b/services/console/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/console/backend/app/schemas/auth.py b/services/console/backend/app/schemas/auth.py
new file mode 100644
index 0000000..5cacb39
--- /dev/null
+++ b/services/console/backend/app/schemas/auth.py
@@ -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"
+ }
+ }
+ )
diff --git a/services/console/backend/app/services/__init__.py b/services/console/backend/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/console/backend/app/services/user_service.py b/services/console/backend/app/services/user_service.py
new file mode 100644
index 0000000..dc9d344
--- /dev/null
+++ b/services/console/backend/app/services/user_service.py
@@ -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
diff --git a/console/backend/auth.py b/services/console/backend/auth.py
similarity index 100%
rename from console/backend/auth.py
rename to services/console/backend/auth.py
diff --git a/console/backend/event_consumer.py b/services/console/backend/event_consumer.py
similarity index 100%
rename from console/backend/event_consumer.py
rename to services/console/backend/event_consumer.py
diff --git a/console/backend/event_handlers.py b/services/console/backend/event_handlers.py
similarity index 100%
rename from console/backend/event_handlers.py
rename to services/console/backend/event_handlers.py
diff --git a/console/backend/main.py b/services/console/backend/main.py
similarity index 100%
rename from console/backend/main.py
rename to services/console/backend/main.py
diff --git a/console/backend/requirements.txt b/services/console/backend/requirements.txt
similarity index 60%
rename from console/backend/requirements.txt
rename to services/console/backend/requirements.txt
index 37d8b9b..a98f091 100644
--- a/console/backend/requirements.txt
+++ b/services/console/backend/requirements.txt
@@ -2,9 +2,13 @@ fastapi==0.109.0
uvicorn[standard]==0.27.0
python-dotenv==1.0.0
pydantic==2.5.3
+pydantic-settings==2.1.0
httpx==0.26.0
python-jose[cryptography]==3.3.0
-passlib[bcrypt]==1.7.4
+bcrypt==4.1.2
python-multipart==0.0.6
redis==5.0.1
-aiokafka==0.10.0
\ No newline at end of file
+aiokafka==0.10.0
+motor==3.3.2
+pymongo==4.6.1
+email-validator==2.1.0
\ No newline at end of file
diff --git a/console/backend/shared/kafka/__init__.py b/services/console/backend/shared/kafka/__init__.py
similarity index 100%
rename from console/backend/shared/kafka/__init__.py
rename to services/console/backend/shared/kafka/__init__.py
diff --git a/console/backend/shared/kafka/consumer.py b/services/console/backend/shared/kafka/consumer.py
similarity index 100%
rename from console/backend/shared/kafka/consumer.py
rename to services/console/backend/shared/kafka/consumer.py
diff --git a/console/backend/shared/kafka/events.py b/services/console/backend/shared/kafka/events.py
similarity index 100%
rename from console/backend/shared/kafka/events.py
rename to services/console/backend/shared/kafka/events.py
diff --git a/console/backend/shared/kafka/producer.py b/services/console/backend/shared/kafka/producer.py
similarity index 100%
rename from console/backend/shared/kafka/producer.py
rename to services/console/backend/shared/kafka/producer.py
diff --git a/console/backend/shared/kafka/schema_registry.py b/services/console/backend/shared/kafka/schema_registry.py
similarity index 100%
rename from console/backend/shared/kafka/schema_registry.py
rename to services/console/backend/shared/kafka/schema_registry.py
diff --git a/console/frontend/Dockerfile b/services/console/frontend/Dockerfile
similarity index 100%
rename from console/frontend/Dockerfile
rename to services/console/frontend/Dockerfile
diff --git a/console/frontend/index.html b/services/console/frontend/index.html
similarity index 100%
rename from console/frontend/index.html
rename to services/console/frontend/index.html
diff --git a/console/frontend/nginx.conf b/services/console/frontend/nginx.conf
similarity index 100%
rename from console/frontend/nginx.conf
rename to services/console/frontend/nginx.conf
diff --git a/console/frontend/package.json b/services/console/frontend/package.json
similarity index 100%
rename from console/frontend/package.json
rename to services/console/frontend/package.json
diff --git a/services/console/frontend/src/App.tsx b/services/console/frontend/src/App.tsx
new file mode 100644
index 0000000..2b65528
--- /dev/null
+++ b/services/console/frontend/src/App.tsx
@@ -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 (
+
+
+ } />
+ } />
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+
+ } />
+
+
+ )
+}
+
+export default App
\ No newline at end of file
diff --git a/services/console/frontend/src/api/auth.ts b/services/console/frontend/src/api/auth.ts
new file mode 100644
index 0000000..d41a852
--- /dev/null
+++ b/services/console/frontend/src/api/auth.ts
@@ -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(
+ `${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 => {
+ const formData = new URLSearchParams();
+ formData.append('username', credentials.username);
+ formData.append('password', credentials.password);
+
+ const { data } = await axios.post(
+ `${API_BASE_URL}/api/auth/login`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ }
+ );
+ return data;
+ },
+
+ register: async (userData: RegisterRequest): Promise => {
+ const { data } = await axios.post(
+ `${API_BASE_URL}/api/auth/register`,
+ userData
+ );
+ return data;
+ },
+
+ getCurrentUser: async (): Promise => {
+ const { data } = await api.get('/api/auth/me');
+ return data;
+ },
+
+ refreshToken: async (refreshToken: string): Promise => {
+ const { data } = await axios.post(
+ `${API_BASE_URL}/api/auth/refresh`,
+ { refresh_token: refreshToken }
+ );
+ return data;
+ },
+
+ logout: async (): Promise => {
+ await api.post('/api/auth/logout');
+ },
+};
+
+export default api;
diff --git a/console/frontend/src/components/Layout.tsx b/services/console/frontend/src/components/Layout.tsx
similarity index 56%
rename from console/frontend/src/components/Layout.tsx
rename to services/console/frontend/src/components/Layout.tsx
index 5c93367..c89616f 100644
--- a/console/frontend/src/components/Layout.tsx
+++ b/services/console/frontend/src/components/Layout.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { Outlet, Link as RouterLink } from 'react-router-dom'
+import { Outlet, Link as RouterLink, useNavigate } from 'react-router-dom'
import {
AppBar,
Box,
@@ -12,13 +12,17 @@ import {
ListItemText,
Toolbar,
Typography,
+ Menu,
+ MenuItem,
} from '@mui/material'
import {
Menu as MenuIcon,
Dashboard as DashboardIcon,
Cloud as CloudIcon,
People as PeopleIcon,
+ AccountCircle,
} from '@mui/icons-material'
+import { useAuth } from '../contexts/AuthContext'
const drawerWidth = 240
@@ -30,11 +34,28 @@ const menuItems = [
function Layout() {
const [open, setOpen] = useState(true)
+ const [anchorEl, setAnchorEl] = useState(null)
+ const { user, logout } = useAuth()
+ const navigate = useNavigate()
const handleDrawerToggle = () => {
setOpen(!open)
}
+ const handleMenu = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget)
+ }
+
+ const handleClose = () => {
+ setAnchorEl(null)
+ }
+
+ const handleLogout = () => {
+ logout()
+ navigate('/login')
+ handleClose()
+ }
+
return (
-
- Microservices Console
+
+ Site11 Console
+
+
+ {user?.username} ({user?.role})
+
+
+
+
+
+
= ({ children }) => {
+ const { isAuthenticated, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return <>{children}>;
+};
+
+export default ProtectedRoute;
diff --git a/services/console/frontend/src/contexts/AuthContext.tsx b/services/console/frontend/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000..5a68967
--- /dev/null
+++ b/services/console/frontend/src/contexts/AuthContext.tsx
@@ -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(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 = ({ children }) => {
+ const [user, setUser] = useState(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 {children};
+};
diff --git a/console/frontend/src/main.tsx b/services/console/frontend/src/main.tsx
similarity index 100%
rename from console/frontend/src/main.tsx
rename to services/console/frontend/src/main.tsx
diff --git a/console/frontend/src/pages/Dashboard.tsx b/services/console/frontend/src/pages/Dashboard.tsx
similarity index 100%
rename from console/frontend/src/pages/Dashboard.tsx
rename to services/console/frontend/src/pages/Dashboard.tsx
diff --git a/services/console/frontend/src/pages/Login.tsx b/services/console/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..97cb9f2
--- /dev/null
+++ b/services/console/frontend/src/pages/Login.tsx
@@ -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) => {
+ 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 (
+
+
+
+
+ Site11 Console
+
+
+ Sign In
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+ Don't have an account?{' '}
+
+ Sign Up
+
+
+
+
+
+
+
+ );
+};
+
+export default Login;
diff --git a/services/console/frontend/src/pages/Register.tsx b/services/console/frontend/src/pages/Register.tsx
new file mode 100644
index 0000000..fc01836
--- /dev/null
+++ b/services/console/frontend/src/pages/Register.tsx
@@ -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) => {
+ 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 (
+
+
+
+
+ Site11 Console
+
+
+ Create Account
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ Already have an account?{' '}
+
+ Sign In
+
+
+
+
+
+
+
+ );
+};
+
+export default Register;
diff --git a/console/frontend/src/pages/Services.tsx b/services/console/frontend/src/pages/Services.tsx
similarity index 100%
rename from console/frontend/src/pages/Services.tsx
rename to services/console/frontend/src/pages/Services.tsx
diff --git a/console/frontend/src/pages/Users.tsx b/services/console/frontend/src/pages/Users.tsx
similarity index 100%
rename from console/frontend/src/pages/Users.tsx
rename to services/console/frontend/src/pages/Users.tsx
diff --git a/services/console/frontend/src/types/auth.ts b/services/console/frontend/src/types/auth.ts
new file mode 100644
index 0000000..0c4b1c2
--- /dev/null
+++ b/services/console/frontend/src/types/auth.ts
@@ -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;
+ register: (data: RegisterRequest) => Promise;
+ logout: () => void;
+ refreshToken: () => Promise;
+}
diff --git a/services/console/frontend/src/vite-env.d.ts b/services/console/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..d02ada6
--- /dev/null
+++ b/services/console/frontend/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_URL?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/console/frontend/tsconfig.json b/services/console/frontend/tsconfig.json
similarity index 100%
rename from console/frontend/tsconfig.json
rename to services/console/frontend/tsconfig.json
diff --git a/console/frontend/tsconfig.node.json b/services/console/frontend/tsconfig.node.json
similarity index 100%
rename from console/frontend/tsconfig.node.json
rename to services/console/frontend/tsconfig.node.json
diff --git a/console/frontend/vite.config.ts b/services/console/frontend/vite.config.ts
similarity index 100%
rename from console/frontend/vite.config.ts
rename to services/console/frontend/vite.config.ts