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:
jungwoo choi
2025-10-28 16:23:07 +09:00
parent 161f206ae2
commit f4b75b96a5
51 changed files with 2480 additions and 100 deletions

View 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

View 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=

View File

@ -0,0 +1,25 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

View 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()

View 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

View 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()

View 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
)

View 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

View 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"}

View 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"
}
}
)

View 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

View File

@ -0,0 +1,65 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
import os
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
class UserLogin(BaseModel):
username: str
password: str
class UserInDB(BaseModel):
username: str
hashed_password: str
email: str
full_name: Optional[str] = None
is_active: bool = True
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
return token_data

View File

@ -0,0 +1,358 @@
"""
고급 이벤트 컨슈머 with DLQ and Retry
"""
import asyncio
import json
import logging
from typing import Dict, Any, Optional, List
from datetime import datetime, timedelta
from redis import asyncio as aioredis
from aiokafka import AIOKafkaProducer
import sys
sys.path.append('/app')
from shared.kafka import KafkaConsumer, Event, EventType
from event_handlers import EventHandlers
logger = logging.getLogger(__name__)
class RetryPolicy:
"""재시도 정책"""
def __init__(
self,
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 60.0,
exponential_base: float = 2.0
):
self.max_retries = max_retries
self.initial_delay = initial_delay
self.max_delay = max_delay
self.exponential_base = exponential_base
def get_delay(self, retry_count: int) -> float:
"""재시도 지연 시간 계산 (exponential backoff)"""
delay = self.initial_delay * (self.exponential_base ** retry_count)
return min(delay, self.max_delay)
class AdvancedEventConsumer:
def __init__(
self,
topics: List[str],
group_id: str,
redis_url: str = "redis://redis:6379",
bootstrap_servers: str = "kafka:9092",
enable_dlq: bool = True,
dlq_topic: str = "dead-letter-queue"
):
self.topics = topics
self.group_id = group_id
self.bootstrap_servers = bootstrap_servers
self.enable_dlq = enable_dlq
self.dlq_topic = dlq_topic
# Kafka Consumer
self.consumer = KafkaConsumer(
topics=topics,
group_id=group_id,
bootstrap_servers=bootstrap_servers
)
# DLQ Producer
self.dlq_producer: Optional[AIOKafkaProducer] = None
# Redis for retry tracking
self.redis: Optional[aioredis.Redis] = None
self.redis_url = redis_url
# Event handlers
self.handlers: Optional[EventHandlers] = None
# Retry policies per event type
self.retry_policies = {
EventType.USER_CREATED: RetryPolicy(max_retries=3),
EventType.USER_UPDATED: RetryPolicy(max_retries=2),
EventType.USER_DELETED: RetryPolicy(max_retries=5), # 중요한 이벤트
EventType.OAUTH_APP_CREATED: RetryPolicy(max_retries=3),
EventType.OAUTH_TOKEN_ISSUED: RetryPolicy(max_retries=1),
}
# Processing statistics
self.stats = {
"processed": 0,
"failed": 0,
"retried": 0,
"dlq_sent": 0
}
async def start(self):
"""컨슈머 시작"""
try:
# Redis 연결
self.redis = await aioredis.from_url(
self.redis_url,
encoding="utf-8",
decode_responses=True
)
# Event handlers 초기화
self.handlers = EventHandlers(redis_client=self.redis)
# DLQ Producer 초기화
if self.enable_dlq:
self.dlq_producer = AIOKafkaProducer(
bootstrap_servers=self.bootstrap_servers,
value_serializer=lambda v: json.dumps(v).encode()
)
await self.dlq_producer.start()
logger.info(f"DLQ Producer started for topic: {self.dlq_topic}")
# 이벤트 핸들러 등록
self._register_event_handlers()
# Kafka Consumer 시작
await self.consumer.start()
logger.info(f"Advanced Event Consumer started: {self.topics}")
# 통계 리포팅 태스크 시작
asyncio.create_task(self._report_stats())
except Exception as e:
logger.error(f"Failed to start Advanced Event Consumer: {e}")
raise
async def stop(self):
"""컨슈머 종료"""
await self.consumer.stop()
if self.dlq_producer:
await self.dlq_producer.stop()
if self.redis:
await self.redis.close()
logger.info("Advanced Event Consumer stopped")
def _register_event_handlers(self):
"""이벤트 핸들러 등록"""
# 각 이벤트 타입에 대한 핸들러를 래퍼로 감싸서 등록
self.consumer.register_handler(
EventType.USER_CREATED,
self._create_handler_with_retry(
self.handlers.handle_user_created,
EventType.USER_CREATED
)
)
self.consumer.register_handler(
EventType.USER_UPDATED,
self._create_handler_with_retry(
self.handlers.handle_user_updated,
EventType.USER_UPDATED
)
)
self.consumer.register_handler(
EventType.USER_DELETED,
self._create_handler_with_retry(
self.handlers.handle_user_deleted,
EventType.USER_DELETED
)
)
self.consumer.register_handler(
EventType.OAUTH_APP_CREATED,
self._create_handler_with_retry(
self.handlers.handle_oauth_app_created,
EventType.OAUTH_APP_CREATED
)
)
self.consumer.register_handler(
EventType.OAUTH_TOKEN_ISSUED,
self._create_handler_with_retry(
self.handlers.handle_oauth_token_issued,
EventType.OAUTH_TOKEN_ISSUED
)
)
def _create_handler_with_retry(self, handler_func, event_type: EventType):
"""재시도 로직이 포함된 핸들러 래퍼 생성"""
async def wrapper(event: Event):
event_id = f"{event.event_id}:{event.event_type}"
retry_key = f"retry:{event_id}"
try:
# 재시도 횟수 확인
retry_count = 0
if self.redis:
retry_count_str = await self.redis.get(retry_key)
retry_count = int(retry_count_str) if retry_count_str else 0
# 핸들러 실행
await handler_func(event.dict())
# 성공 시 재시도 카운터 삭제
if self.redis and retry_count > 0:
await self.redis.delete(retry_key)
self.stats["processed"] += 1
except Exception as e:
logger.error(f"Error processing {event_type}: {e}")
self.stats["failed"] += 1
# 재시도 처리
retry_policy = self.retry_policies.get(event_type)
if retry_policy and retry_count < retry_policy.max_retries:
await self._handle_retry(event, retry_count, retry_policy, retry_key)
else:
# 최대 재시도 초과 -> DLQ로 전송
await self._send_to_dlq(event, str(e), retry_count)
return wrapper
async def _handle_retry(
self,
event: Event,
retry_count: int,
retry_policy: RetryPolicy,
retry_key: str
):
"""재시도 처리"""
retry_count += 1
delay = retry_policy.get_delay(retry_count)
logger.warning(
f"Retrying event {event.event_id} "
f"(attempt {retry_count}/{retry_policy.max_retries}) "
f"after {delay}s"
)
# 재시도 카운터 저장
if self.redis:
await self.redis.setex(
retry_key,
timedelta(hours=24), # 24시간 후 자동 삭제
retry_count
)
# 지연 후 재처리를 위해 다시 큐에 추가
# 실제 프로덕션에서는 별도의 재시도 토픽 사용 권장
self.stats["retried"] += 1
# 지연 실행
await asyncio.sleep(delay)
# 이벤트 재발행 (재시도 토픽으로)
if hasattr(self, 'retry_producer'):
await self._republish_for_retry(event, retry_count)
async def _send_to_dlq(self, event: Event, error: str, retry_count: int):
"""Dead Letter Queue로 전송"""
if not self.enable_dlq or not self.dlq_producer:
logger.error(f"Failed to process event {event.event_id} after {retry_count} retries")
return
try:
dlq_message = {
"original_event": event.dict(),
"error": error,
"retry_count": retry_count,
"failed_at": datetime.now().isoformat(),
"consumer_group": self.group_id,
"topic": self.topics[0] if self.topics else None
}
await self.dlq_producer.send(
self.dlq_topic,
value=dlq_message
)
self.stats["dlq_sent"] += 1
logger.error(
f"Event {event.event_id} sent to DLQ after {retry_count} retries. "
f"Error: {error}"
)
# Redis에 DLQ 전송 기록
if self.redis:
dlq_key = f"dlq:{event.event_id}"
await self.redis.setex(
dlq_key,
timedelta(days=7), # 7일 보관
json.dumps({
"error": error,
"retry_count": retry_count,
"sent_at": datetime.now().isoformat()
})
)
except Exception as e:
logger.critical(f"Failed to send event to DLQ: {e}")
async def _republish_for_retry(self, event: Event, retry_count: int):
"""재시도를 위한 이벤트 재발행"""
# 실제 구현에서는 별도의 재시도 토픽 사용
# 여기서는 로깅만 수행
logger.info(f"Would republish event {event.event_id} for retry #{retry_count}")
async def _report_stats(self):
"""통계 리포팅 (1분마다)"""
while True:
await asyncio.sleep(60)
logger.info(
f"Event Consumer Stats - "
f"Processed: {self.stats['processed']}, "
f"Failed: {self.stats['failed']}, "
f"Retried: {self.stats['retried']}, "
f"DLQ: {self.stats['dlq_sent']}"
)
# Redis에 통계 저장
if self.redis:
stats_key = f"consumer:stats:{self.group_id}"
await self.redis.hset(
stats_key,
mapping={
**self.stats,
"updated_at": datetime.now().isoformat()
}
)
async def get_dlq_messages(self, limit: int = 10) -> List[Dict[str, Any]]:
"""DLQ 메시지 조회 (관리 목적)"""
if not self.redis:
return []
dlq_keys = await self.redis.keys("dlq:*")
messages = []
for key in dlq_keys[:limit]:
data = await self.redis.get(key)
if data:
event_id = key.replace("dlq:", "")
message = json.loads(data)
message["event_id"] = event_id
messages.append(message)
return messages
async def retry_dlq_message(self, event_id: str) -> bool:
"""DLQ 메시지 수동 재시도"""
# 실제 구현에서는 DLQ에서 메시지를 읽어 재처리
logger.info(f"Manual retry requested for event: {event_id}")
if self.redis:
# 재시도 카운터 리셋
retry_key = f"retry:{event_id}:*"
keys = await self.redis.keys(retry_key)
if keys:
await self.redis.delete(*keys)
return True
return False

View File

@ -0,0 +1,213 @@
"""
이벤트 핸들러 모듈
각 이벤트 타입별 처리 로직 구현
"""
import logging
from typing import Dict, Any, Optional
from datetime import datetime
import json
import asyncio
from redis import asyncio as aioredis
logger = logging.getLogger(__name__)
class EventHandlers:
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
self.redis = redis_client
self.retry_counts: Dict[str, int] = {}
async def handle_user_created(self, event: Dict[str, Any]):
"""사용자 생성 이벤트 처리"""
try:
user_id = event.get('data', {}).get('user_id')
username = event.get('data', {}).get('username')
email = event.get('data', {}).get('email')
logger.info(f"Processing USER_CREATED: {username} ({user_id})")
# Redis 캐시 무효화
if self.redis:
await self.redis.delete(f"user:{user_id}")
await self.redis.delete("users:list")
# 추가 처리 로직
# - 환영 이메일 발송 준비
# - 초기 설정 생성
# - 분석 데이터 기록
await self._publish_notification({
"type": "user.welcome",
"user_id": user_id,
"email": email,
"username": username,
"timestamp": datetime.now().isoformat()
})
logger.info(f"Successfully processed USER_CREATED for {username}")
except Exception as e:
logger.error(f"Error handling USER_CREATED: {e}")
raise
async def handle_user_updated(self, event: Dict[str, Any]):
"""사용자 업데이트 이벤트 처리"""
try:
user_id = event.get('data', {}).get('user_id')
updated_fields = event.get('data', {}).get('updated_fields', [])
logger.info(f"Processing USER_UPDATED: {user_id}, fields: {updated_fields}")
# Redis 캐시 무효화
if self.redis:
await self.redis.delete(f"user:{user_id}")
await self.redis.delete("users:list")
# 프로필 사진 변경 시 이미지 캐시도 무효화
if 'profile_picture' in updated_fields:
await self.redis.delete(f"user:profile_picture:{user_id}")
# 프로필 완성도 계산
if 'profile_picture' in updated_fields or 'bio' in updated_fields:
await self._calculate_profile_completeness(user_id)
logger.info(f"Successfully processed USER_UPDATED for {user_id}")
except Exception as e:
logger.error(f"Error handling USER_UPDATED: {e}")
raise
async def handle_user_deleted(self, event: Dict[str, Any]):
"""사용자 삭제 이벤트 처리"""
try:
user_id = event.get('data', {}).get('user_id')
username = event.get('data', {}).get('username')
logger.info(f"Processing USER_DELETED: {username} ({user_id})")
# Redis에서 모든 관련 데이터 삭제
if self.redis:
# 사용자 캐시 삭제
await self.redis.delete(f"user:{user_id}")
await self.redis.delete("users:list")
# 세션 삭제
session_keys = await self.redis.keys(f"session:*:{user_id}")
if session_keys:
await self.redis.delete(*session_keys)
# 프로필 이미지 캐시 삭제
await self.redis.delete(f"user:profile_picture:{user_id}")
# 관련 데이터 정리 이벤트 발행
await self._publish_cleanup_event({
"user_id": user_id,
"username": username,
"timestamp": datetime.now().isoformat()
})
logger.info(f"Successfully processed USER_DELETED for {username}")
except Exception as e:
logger.error(f"Error handling USER_DELETED: {e}")
raise
async def handle_oauth_app_created(self, event: Dict[str, Any]):
"""OAuth 앱 생성 이벤트 처리"""
try:
app_id = event.get('data', {}).get('app_id')
app_name = event.get('data', {}).get('name')
owner_id = event.get('data', {}).get('owner_id')
logger.info(f"Processing OAUTH_APP_CREATED: {app_name} ({app_id})")
# 앱 생성 알림
await self._publish_notification({
"type": "oauth.app_created",
"app_id": app_id,
"app_name": app_name,
"owner_id": owner_id,
"timestamp": datetime.now().isoformat()
})
logger.info(f"Successfully processed OAUTH_APP_CREATED for {app_name}")
except Exception as e:
logger.error(f"Error handling OAUTH_APP_CREATED: {e}")
raise
async def handle_oauth_token_issued(self, event: Dict[str, Any]):
"""OAuth 토큰 발급 이벤트 처리"""
try:
client_id = event.get('data', {}).get('client_id')
user_id = event.get('data', {}).get('user_id')
scopes = event.get('data', {}).get('scopes', [])
logger.info(f"Processing OAUTH_TOKEN_ISSUED: client={client_id}, user={user_id}")
# 보안 감사 로그
await self._log_security_event({
"type": "oauth.token_issued",
"client_id": client_id,
"user_id": user_id,
"scopes": scopes,
"timestamp": datetime.now().isoformat()
})
# 사용 통계 업데이트
if self.redis:
await self.redis.hincrby(f"oauth:stats:{client_id}", "tokens_issued", 1)
await self.redis.sadd(f"oauth:users:{client_id}", user_id)
logger.info(f"Successfully processed OAUTH_TOKEN_ISSUED")
except Exception as e:
logger.error(f"Error handling OAUTH_TOKEN_ISSUED: {e}")
raise
async def _publish_notification(self, notification: Dict[str, Any]):
"""알림 이벤트 발행"""
# 향후 Notification 서비스로 이벤트 발행
logger.debug(f"Publishing notification: {notification}")
if self.redis:
await self.redis.lpush(
"notifications:queue",
json.dumps(notification)
)
async def _publish_cleanup_event(self, cleanup_data: Dict[str, Any]):
"""정리 이벤트 발행"""
# 향후 각 서비스로 정리 이벤트 발행
logger.debug(f"Publishing cleanup event: {cleanup_data}")
if self.redis:
await self.redis.lpush(
"cleanup:queue",
json.dumps(cleanup_data)
)
async def _calculate_profile_completeness(self, user_id: str):
"""프로필 완성도 계산"""
# 향후 프로필 완성도 계산 로직
logger.debug(f"Calculating profile completeness for user: {user_id}")
if self.redis:
# 임시로 Redis에 저장
await self.redis.hset(
f"user:stats:{user_id}",
"profile_updated_at",
datetime.now().isoformat()
)
async def _log_security_event(self, event_data: Dict[str, Any]):
"""보안 이벤트 로깅"""
logger.info(f"Security event: {event_data}")
if self.redis:
await self.redis.lpush(
"security:audit_log",
json.dumps(event_data)
)
# 최근 100개만 유지
await self.redis.ltrim("security:audit_log", 0, 99)

View File

@ -0,0 +1,347 @@
from fastapi import FastAPI, HTTPException, Request, Response, Depends, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordRequestForm
import uvicorn
from datetime import datetime, timedelta
import httpx
import os
import asyncio
import logging
from typing import Any
from contextlib import asynccontextmanager
from auth import (
Token, UserLogin, UserInDB,
verify_password, get_password_hash,
create_access_token, get_current_user,
ACCESS_TOKEN_EXPIRE_MINUTES
)
# Import event consumer
from event_consumer import AdvancedEventConsumer
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Global event consumer instance
event_consumer = None
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
global event_consumer
try:
# Initialize and start event consumer
event_consumer = AdvancedEventConsumer(
topics=["user-events", "oauth-events"],
group_id="console-consumer-group",
redis_url=os.getenv("REDIS_URL", "redis://redis:6379"),
bootstrap_servers=os.getenv("KAFKA_BOOTSTRAP_SERVERS", "kafka:9092"),
enable_dlq=True,
dlq_topic="dead-letter-queue"
)
await event_consumer.start()
logger.info("Event consumer started successfully")
except Exception as e:
logger.error(f"Failed to start event consumer: {e}")
# Continue without event consumer (degraded mode)
event_consumer = None
yield
# Shutdown
if event_consumer:
await event_consumer.stop()
logger.info("Event consumer stopped")
app = FastAPI(
title="Console API Gateway",
description="Central orchestrator for microservices",
version="0.1.0",
lifespan=lifespan
)
# Service URLs from environment
USERS_SERVICE_URL = os.getenv("USERS_SERVICE_URL", "http://users-backend:8000")
IMAGES_SERVICE_URL = os.getenv("IMAGES_SERVICE_URL", "http://images-backend:8000")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root():
return {
"message": "Console API Gateway",
"status": "running",
"timestamp": datetime.now().isoformat()
}
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"service": "console",
"timestamp": datetime.now().isoformat(),
"event_consumer": "running" if event_consumer else "not running"
}
@app.get("/api/health")
async def api_health_check():
"""API health check endpoint for frontend"""
return {
"status": "healthy",
"service": "console-backend",
"timestamp": datetime.now().isoformat()
}
@app.get("/api/users/health")
async def users_health_check():
"""Users service health check endpoint"""
# TODO: Replace with actual users service health check when implemented
return {
"status": "healthy",
"service": "users-service",
"timestamp": datetime.now().isoformat()
}
# Event Management Endpoints
@app.get("/api/events/stats")
async def get_event_stats(current_user = Depends(get_current_user)):
"""Get event consumer statistics"""
if not event_consumer:
raise HTTPException(status_code=503, detail="Event consumer not available")
return {
"stats": event_consumer.stats,
"timestamp": datetime.now().isoformat()
}
@app.get("/api/events/dlq")
async def get_dlq_messages(
limit: int = 10,
current_user = Depends(get_current_user)
):
"""Get messages from Dead Letter Queue"""
if not event_consumer:
raise HTTPException(status_code=503, detail="Event consumer not available")
messages = await event_consumer.get_dlq_messages(limit=limit)
return {
"messages": messages,
"count": len(messages),
"timestamp": datetime.now().isoformat()
}
@app.post("/api/events/dlq/{event_id}/retry")
async def retry_dlq_message(
event_id: str,
current_user = Depends(get_current_user)
):
"""Manually retry a message from DLQ"""
if not event_consumer:
raise HTTPException(status_code=503, detail="Event consumer not available")
success = await event_consumer.retry_dlq_message(event_id)
if not success:
raise HTTPException(status_code=404, detail="Event not found in DLQ")
return {
"status": "retry_initiated",
"event_id": event_id,
"timestamp": datetime.now().isoformat()
}
@app.get("/api/events/schemas")
async def get_event_schemas():
"""Get all event schemas documentation"""
from shared.kafka.schema_registry import SchemaRegistry
schemas = SchemaRegistry.get_all_schemas()
return {
"schemas": schemas,
"version": "1.0.0",
"timestamp": datetime.now().isoformat()
}
# Authentication endpoints
@app.post("/api/auth/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
"""Login endpoint for authentication"""
# For demo purposes - in production, check against database
# This is temporary until we integrate with Users service
demo_users = {
"admin": {
"username": "admin",
"hashed_password": get_password_hash("admin123"),
"email": "admin@site11.com",
"full_name": "Administrator",
"is_active": True
},
"user": {
"username": "user",
"hashed_password": get_password_hash("user123"),
"email": "user@site11.com",
"full_name": "Test User",
"is_active": True
}
}
user = demo_users.get(form_data.username)
if not user or not verify_password(form_data.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user["username"]}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/api/auth/me")
async def get_me(current_user = Depends(get_current_user)):
"""Get current user information"""
return {
"username": current_user.username,
"email": f"{current_user.username}@site11.com",
"is_active": True
}
@app.post("/api/auth/logout")
async def logout(current_user = Depends(get_current_user)):
"""Logout endpoint"""
# In a real application, you might want to blacklist the token
return {"message": "Successfully logged out"}
@app.get("/api/status")
async def system_status():
services_status = {}
# Check Users service
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{USERS_SERVICE_URL}/health", timeout=2.0)
services_status["users"] = "online" if response.status_code == 200 else "error"
except:
services_status["users"] = "offline"
# Check Images service
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{IMAGES_SERVICE_URL}/health", timeout=2.0)
services_status["images"] = "online" if response.status_code == 200 else "error"
except:
services_status["images"] = "offline"
# Other services (not yet implemented)
services_status["oauth"] = "pending"
services_status["applications"] = "pending"
services_status["data"] = "pending"
services_status["statistics"] = "pending"
return {
"console": "online",
"services": services_status,
"timestamp": datetime.now().isoformat()
}
# Protected endpoint example
@app.get("/api/protected")
async def protected_route(current_user = Depends(get_current_user)):
"""Example of a protected route"""
return {
"message": "This is a protected route",
"user": current_user.username
}
# API Gateway - Route to Images service
@app.api_route("/api/images/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_images(path: str, request: Request):
"""Proxy requests to Images service (public for image proxy)"""
try:
async with httpx.AsyncClient() as client:
# Build the target URL
url = f"{IMAGES_SERVICE_URL}/api/v1/{path}"
# Get request body if exists
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Forward the request
response = await client.request(
method=request.method,
url=url,
headers={
key: value for key, value in request.headers.items()
if key.lower() not in ["host", "content-length"]
},
content=body,
params=request.query_params
)
# Return the response
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers)
)
except httpx.ConnectError:
raise HTTPException(status_code=503, detail="Images service unavailable")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# API Gateway - Route to Users service
@app.api_route("/api/users/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy_to_users(path: str, request: Request, current_user = Depends(get_current_user)):
"""Proxy requests to Users service (protected)"""
try:
async with httpx.AsyncClient() as client:
# Build the target URL
url = f"{USERS_SERVICE_URL}/{path}"
# Get request body if exists
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Forward the request
response = await client.request(
method=request.method,
url=url,
headers={
key: value for key, value in request.headers.items()
if key.lower() not in ["host", "content-length"]
},
content=body,
params=request.query_params
)
# Return the response
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers)
)
except httpx.ConnectError:
raise HTTPException(status_code=503, detail="Users service unavailable")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True
)

View File

@ -0,0 +1,14 @@
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
bcrypt==4.1.2
python-multipart==0.0.6
redis==5.0.1
aiokafka==0.10.0
motor==3.3.2
pymongo==4.6.1
email-validator==2.1.0

View File

@ -0,0 +1,6 @@
from .producer import KafkaProducer
from .consumer import KafkaConsumer
from .events import Event, EventType
from .schema_registry import SchemaRegistry
__all__ = ['KafkaProducer', 'KafkaConsumer', 'Event', 'EventType', 'SchemaRegistry']

View File

@ -0,0 +1,125 @@
import json
import asyncio
from typing import Optional, Callable, Dict, Any, List
from aiokafka import AIOKafkaConsumer
from aiokafka.errors import KafkaError
import logging
from .events import Event, EventType
logger = logging.getLogger(__name__)
class KafkaConsumer:
def __init__(
self,
topics: List[str],
group_id: str,
bootstrap_servers: str = "kafka:9092"
):
self.topics = topics
self.group_id = group_id
self.bootstrap_servers = bootstrap_servers
self._consumer: Optional[AIOKafkaConsumer] = None
self._handlers: Dict[EventType, List[Callable]] = {}
self._running = False
def register_handler(self, event_type: EventType, handler: Callable):
"""이벤트 타입별 핸들러 등록"""
if event_type not in self._handlers:
self._handlers[event_type] = []
self._handlers[event_type].append(handler)
logger.info(f"Registered handler for {event_type}")
async def start(self):
"""Kafka Consumer 시작"""
try:
self._consumer = AIOKafkaConsumer(
*self.topics,
bootstrap_servers=self.bootstrap_servers,
group_id=self.group_id,
value_deserializer=lambda v: json.loads(v.decode()),
auto_offset_reset='earliest',
enable_auto_commit=True,
auto_commit_interval_ms=1000,
session_timeout_ms=30000,
heartbeat_interval_ms=10000
)
await self._consumer.start()
self._running = True
logger.info(f"Kafka Consumer started: {self.topics} (group: {self.group_id})")
# 메시지 처리 루프 시작
asyncio.create_task(self._consume_messages())
except Exception as e:
logger.error(f"Failed to start Kafka Consumer: {e}")
raise
async def stop(self):
"""Kafka Consumer 종료"""
self._running = False
if self._consumer:
await self._consumer.stop()
logger.info("Kafka Consumer stopped")
async def _consume_messages(self):
"""메시지 소비 루프"""
if not self._consumer:
return
while self._running:
try:
# 메시지 배치로 가져오기 (최대 100ms 대기)
msg_batch = await self._consumer.getmany(timeout_ms=100)
for tp, messages in msg_batch.items():
for msg in messages:
await self._process_message(msg.value)
except KafkaError as e:
logger.error(f"Kafka error: {e}")
await asyncio.sleep(1)
except Exception as e:
logger.error(f"Error processing messages: {e}")
await asyncio.sleep(1)
async def _process_message(self, message: Dict[str, Any]):
"""개별 메시지 처리"""
try:
# Event 객체로 변환
event = Event(**message)
# 등록된 핸들러 실행
handlers = self._handlers.get(event.event_type, [])
for handler in handlers:
try:
if asyncio.iscoroutinefunction(handler):
await handler(event)
else:
handler(event)
except Exception as e:
logger.error(f"Handler error for {event.event_type}: {e}")
if not handlers:
logger.debug(f"No handlers for event type: {event.event_type}")
except Exception as e:
logger.error(f"Failed to process message: {e}")
async def consume_one(self, timeout: float = 1.0) -> Optional[Event]:
"""단일 메시지 소비 (테스트/디버깅용)"""
if not self._consumer:
return None
try:
msg = await asyncio.wait_for(
self._consumer.getone(),
timeout=timeout
)
return Event(**msg.value)
except asyncio.TimeoutError:
return None
except Exception as e:
logger.error(f"Error consuming message: {e}")
return None

View File

@ -0,0 +1,31 @@
from enum import Enum
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Any, Optional, Dict
class EventType(str, Enum):
USER_CREATED = "user.created"
USER_UPDATED = "user.updated"
USER_DELETED = "user.deleted"
USER_LOGIN = "user.login"
IMAGE_UPLOADED = "image.uploaded"
IMAGE_CACHED = "image.cached"
IMAGE_DELETED = "image.deleted"
TASK_CREATED = "task.created"
TASK_COMPLETED = "task.completed"
TASK_FAILED = "task.failed"
class Event(BaseModel):
event_type: EventType
timestamp: datetime = Field(default_factory=datetime.now)
service: str
data: Dict[str, Any]
correlation_id: Optional[str] = None
user_id: Optional[str] = None
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}

View File

@ -0,0 +1,101 @@
import json
import asyncio
from typing import Optional, Dict, Any
from aiokafka import AIOKafkaProducer
from aiokafka.errors import KafkaError
import logging
from .events import Event
logger = logging.getLogger(__name__)
class KafkaProducer:
def __init__(self, bootstrap_servers: str = "kafka:9092"):
self.bootstrap_servers = bootstrap_servers
self._producer: Optional[AIOKafkaProducer] = None
async def start(self):
"""Kafka Producer 시작"""
try:
self._producer = AIOKafkaProducer(
bootstrap_servers=self.bootstrap_servers,
value_serializer=lambda v: json.dumps(v).encode(),
compression_type="gzip",
acks='all',
retry_backoff_ms=100
)
await self._producer.start()
logger.info(f"Kafka Producer started: {self.bootstrap_servers}")
except Exception as e:
logger.error(f"Failed to start Kafka Producer: {e}")
raise
async def stop(self):
"""Kafka Producer 종료"""
if self._producer:
await self._producer.stop()
logger.info("Kafka Producer stopped")
async def send_event(self, topic: str, event: Event) -> bool:
"""이벤트 전송"""
if not self._producer:
logger.error("Producer not started")
return False
try:
event_dict = event.dict()
event_dict['timestamp'] = event.timestamp.isoformat()
await self._producer.send_and_wait(
topic,
value=event_dict,
key=event.correlation_id.encode() if event.correlation_id else None
)
logger.info(f"Event sent to {topic}: {event.event_type}")
return True
except KafkaError as e:
logger.error(f"Failed to send event to {topic}: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error sending event: {e}")
return False
async def send_batch(self, topic: str, events: list[Event]) -> int:
"""여러 이벤트를 배치로 전송"""
if not self._producer:
logger.error("Producer not started")
return 0
sent_count = 0
batch = self._producer.create_batch()
for event in events:
event_dict = event.dict()
event_dict['timestamp'] = event.timestamp.isoformat()
metadata = batch.append(
key=event.correlation_id.encode() if event.correlation_id else None,
value=json.dumps(event_dict).encode(),
timestamp=None
)
if metadata is None:
# 배치가 가득 찼으면 전송하고 새 배치 생성
await self._producer.send_batch(batch, topic)
sent_count += len(batch)
batch = self._producer.create_batch()
batch.append(
key=event.correlation_id.encode() if event.correlation_id else None,
value=json.dumps(event_dict).encode(),
timestamp=None
)
# 남은 배치 전송
if batch:
await self._producer.send_batch(batch, topic)
sent_count += len(batch)
logger.info(f"Sent {sent_count} events to {topic}")
return sent_count

View File

@ -0,0 +1,333 @@
"""
이벤트 스키마 레지스트리
이벤트 스키마 정의 및 버전 관리
"""
from typing import Dict, Any, Optional, List, Literal
from enum import Enum
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
import json
class SchemaVersion(str, Enum):
V1 = "1.0.0"
V2 = "2.0.0"
class EventSchemaBase(BaseModel):
"""이벤트 스키마 베이스"""
event_id: str = Field(..., description="고유 이벤트 ID")
event_type: str = Field(..., description="이벤트 타입")
timestamp: datetime = Field(default_factory=datetime.now, description="이벤트 발생 시간")
version: str = Field(default=SchemaVersion.V1, description="스키마 버전")
service: str = Field(..., description="이벤트 발생 서비스")
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
# User Events Schemas
class UserCreatedSchema(EventSchemaBase):
"""사용자 생성 이벤트 스키마"""
event_type: Literal["USER_CREATED"] = "USER_CREATED"
data: Dict[str, Any] = Field(..., description="이벤트 데이터")
@field_validator('data')
@classmethod
def validate_data(cls, v):
required_fields = ['user_id', 'username', 'email']
for field in required_fields:
if field not in v:
raise ValueError(f"Missing required field: {field}")
return v
class UserUpdatedSchema(EventSchemaBase):
"""사용자 업데이트 이벤트 스키마"""
event_type: Literal["USER_UPDATED"] = "USER_UPDATED"
data: Dict[str, Any] = Field(..., description="이벤트 데이터")
@field_validator('data')
@classmethod
def validate_data(cls, v):
required_fields = ['user_id']
optional_fields = ['username', 'email', 'full_name', 'profile_picture',
'bio', 'location', 'website', 'updated_fields']
for field in required_fields:
if field not in v:
raise ValueError(f"Missing required field: {field}")
# updated_fields가 있으면 검증
if 'updated_fields' in v and not isinstance(v['updated_fields'], list):
raise ValueError("updated_fields must be a list")
return v
class UserDeletedSchema(EventSchemaBase):
"""사용자 삭제 이벤트 스키마"""
event_type: Literal["USER_DELETED"] = "USER_DELETED"
data: Dict[str, Any] = Field(..., description="이벤트 데이터")
@field_validator('data')
@classmethod
def validate_data(cls, v):
required_fields = ['user_id', 'username']
for field in required_fields:
if field not in v:
raise ValueError(f"Missing required field: {field}")
return v
# OAuth Events Schemas
class OAuthAppCreatedSchema(EventSchemaBase):
"""OAuth 앱 생성 이벤트 스키마"""
event_type: Literal["OAUTH_APP_CREATED"] = "OAUTH_APP_CREATED"
data: Dict[str, Any] = Field(..., description="이벤트 데이터")
@field_validator('data')
@classmethod
def validate_data(cls, v):
required_fields = ['app_id', 'name', 'owner_id', 'client_id']
for field in required_fields:
if field not in v:
raise ValueError(f"Missing required field: {field}")
return v
class OAuthTokenIssuedSchema(EventSchemaBase):
"""OAuth 토큰 발급 이벤트 스키마"""
event_type: Literal["OAUTH_TOKEN_ISSUED"] = "OAUTH_TOKEN_ISSUED"
data: Dict[str, Any] = Field(..., description="이벤트 데이터")
@field_validator('data')
@classmethod
def validate_data(cls, v):
required_fields = ['client_id', 'grant_type']
optional_fields = ['user_id', 'scopes', 'expires_in']
for field in required_fields:
if field not in v:
raise ValueError(f"Missing required field: {field}")
# scopes가 있으면 리스트여야 함
if 'scopes' in v and not isinstance(v['scopes'], list):
raise ValueError("scopes must be a list")
return v
class OAuthTokenRevokedSchema(EventSchemaBase):
"""OAuth 토큰 폐기 이벤트 스키마"""
event_type: Literal["OAUTH_TOKEN_REVOKED"] = "OAUTH_TOKEN_REVOKED"
data: Dict[str, Any] = Field(..., description="이벤트 데이터")
@field_validator('data')
@classmethod
def validate_data(cls, v):
required_fields = ['token_id', 'client_id']
optional_fields = ['user_id', 'revoked_by']
for field in required_fields:
if field not in v:
raise ValueError(f"Missing required field: {field}")
return v
# Image Events Schemas
class ImageUploadedSchema(EventSchemaBase):
"""이미지 업로드 이벤트 스키마"""
event_type: Literal["IMAGE_UPLOADED"] = "IMAGE_UPLOADED"
data: Dict[str, Any] = Field(..., description="이벤트 데이터")
@field_validator('data')
@classmethod
def validate_data(cls, v):
required_fields = ['image_id', 'user_id', 'url']
optional_fields = ['size', 'mime_type', 'width', 'height', 'thumbnail_url']
for field in required_fields:
if field not in v:
raise ValueError(f"Missing required field: {field}")
return v
class ImageProcessedSchema(EventSchemaBase):
"""이미지 처리 완료 이벤트 스키마"""
event_type: Literal["IMAGE_PROCESSED"] = "IMAGE_PROCESSED"
data: Dict[str, Any] = Field(..., description="이벤트 데이터")
@field_validator('data')
@classmethod
def validate_data(cls, v):
required_fields = ['image_id', 'process_type']
optional_fields = ['original_url', 'processed_url', 'processing_time_ms']
for field in required_fields:
if field not in v:
raise ValueError(f"Missing required field: {field}")
return v
class SchemaRegistry:
"""스키마 레지스트리"""
# 스키마 매핑
SCHEMAS = {
"USER_CREATED": UserCreatedSchema,
"USER_UPDATED": UserUpdatedSchema,
"USER_DELETED": UserDeletedSchema,
"OAUTH_APP_CREATED": OAuthAppCreatedSchema,
"OAUTH_TOKEN_ISSUED": OAuthTokenIssuedSchema,
"OAUTH_TOKEN_REVOKED": OAuthTokenRevokedSchema,
"IMAGE_UPLOADED": ImageUploadedSchema,
"IMAGE_PROCESSED": ImageProcessedSchema,
}
# 스키마 버전 호환성 매트릭스
COMPATIBILITY_MATRIX = {
SchemaVersion.V1: [SchemaVersion.V1],
SchemaVersion.V2: [SchemaVersion.V1, SchemaVersion.V2], # V2는 V1과 호환
}
@classmethod
def get_schema(cls, event_type: str) -> Optional[type]:
"""이벤트 타입에 대한 스키마 반환"""
return cls.SCHEMAS.get(event_type)
@classmethod
def validate_event(cls, event_data: Dict[str, Any]) -> tuple[bool, Optional[str]]:
"""이벤트 데이터 검증"""
try:
event_type = event_data.get('event_type')
if not event_type:
return False, "Missing event_type"
schema_class = cls.get_schema(event_type)
if not schema_class:
return False, f"Unknown event type: {event_type}"
# 스키마 검증
schema_class(**event_data)
return True, None
except Exception as e:
return False, str(e)
@classmethod
def is_compatible(cls, from_version: str, to_version: str) -> bool:
"""버전 호환성 확인"""
from_v = SchemaVersion(from_version)
to_v = SchemaVersion(to_version)
compatible_versions = cls.COMPATIBILITY_MATRIX.get(to_v, [])
return from_v in compatible_versions
@classmethod
def migrate_event(
cls,
event_data: Dict[str, Any],
from_version: str,
to_version: str
) -> Dict[str, Any]:
"""이벤트 데이터 마이그레이션"""
if from_version == to_version:
return event_data
if not cls.is_compatible(from_version, to_version):
raise ValueError(f"Cannot migrate from {from_version} to {to_version}")
# 버전별 마이그레이션 로직
if from_version == SchemaVersion.V1 and to_version == SchemaVersion.V2:
# V1 -> V2 마이그레이션 예시
event_data['version'] = SchemaVersion.V2
# 새로운 필드 추가 (기본값)
if 'metadata' not in event_data:
event_data['metadata'] = {}
return event_data
@classmethod
def get_all_schemas(cls) -> Dict[str, Dict[str, Any]]:
"""모든 스키마 정보 반환 (문서화용)"""
schemas_info = {}
for event_type, schema_class in cls.SCHEMAS.items():
schemas_info[event_type] = {
"description": schema_class.__doc__,
"fields": schema_class.schema(),
"version": SchemaVersion.V1,
"example": cls._generate_example(schema_class)
}
return schemas_info
@classmethod
def _generate_example(cls, schema_class: type) -> Dict[str, Any]:
"""스키마 예시 생성"""
examples = {
"USER_CREATED": {
"event_id": "evt_123456",
"event_type": "USER_CREATED",
"timestamp": datetime.now().isoformat(),
"version": "1.0.0",
"service": "users",
"data": {
"user_id": "usr_abc123",
"username": "johndoe",
"email": "john@example.com"
}
},
"USER_UPDATED": {
"event_id": "evt_123457",
"event_type": "USER_UPDATED",
"timestamp": datetime.now().isoformat(),
"version": "1.0.0",
"service": "users",
"data": {
"user_id": "usr_abc123",
"updated_fields": ["profile_picture", "bio"],
"profile_picture": "https://example.com/pic.jpg",
"bio": "Updated bio"
}
},
"OAUTH_TOKEN_ISSUED": {
"event_id": "evt_123458",
"event_type": "OAUTH_TOKEN_ISSUED",
"timestamp": datetime.now().isoformat(),
"version": "1.0.0",
"service": "oauth",
"data": {
"client_id": "app_xyz789",
"user_id": "usr_abc123",
"grant_type": "authorization_code",
"scopes": ["profile", "email"],
"expires_in": 3600
}
}
}
return examples.get(schema_class.__fields__['event_type'].default, {})
@classmethod
def export_schemas(cls, format: str = "json") -> str:
"""스키마 내보내기"""
schemas = cls.get_all_schemas()
if format == "json":
return json.dumps(schemas, indent=2, default=str)
elif format == "markdown":
return cls._export_as_markdown(schemas)
else:
raise ValueError(f"Unsupported format: {format}")
@classmethod
def _export_as_markdown(cls, schemas: Dict[str, Dict[str, Any]]) -> str:
"""마크다운 형식으로 내보내기"""
md = "# Event Schema Registry\n\n"
for event_type, info in schemas.items():
md += f"## {event_type}\n\n"
md += f"{info['description']}\n\n"
md += f"**Version:** {info['version']}\n\n"
md += "**Example:**\n```json\n"
md += json.dumps(info['example'], indent=2, default=str)
md += "\n```\n\n"
return md

View File

@ -0,0 +1,20 @@
# Build stage
FROM node:18-alpine as builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Console - Microservices Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://console-backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@ -0,0 +1,33 @@
{
"name": "console-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.15.2",
"@mui/icons-material": "^5.15.2",
"axios": "^1.6.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View 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

View 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;

View File

@ -0,0 +1,155 @@
import { useState } from 'react'
import { Outlet, Link as RouterLink, useNavigate } from 'react-router-dom'
import {
AppBar,
Box,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
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
const menuItems = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
{ text: 'Services', icon: <CloudIcon />, path: '/services' },
{ text: 'Users', icon: <PeopleIcon />, path: '/users' },
]
function Layout() {
const [open, setOpen] = useState(true)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { user, logout } = useAuth()
const navigate = useNavigate()
const handleDrawerToggle = () => {
setOpen(!open)
}
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleLogout = () => {
logout()
navigate('/login')
handleClose()
}
return (
<Box sx={{ display: 'flex' }}>
<AppBar
position="fixed"
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
Site11 Console
</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>
</AppBar>
<Drawer
variant="persistent"
anchor="left"
open={open}
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
},
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton component={RouterLink} to={item.path}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
marginLeft: open ? `${drawerWidth}px` : 0,
transition: 'margin 0.3s',
}}
>
<Toolbar />
<Outlet />
</Box>
</Box>
)
}
export default Layout

View 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;

View 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>;
};

View File

@ -0,0 +1,29 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import App from './App'
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
)

View File

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react'
import {
Grid,
Paper,
Typography,
Box,
Card,
CardContent,
Chip,
} from '@mui/material'
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon
} from '@mui/icons-material'
import axios from 'axios'
interface ServiceStatus {
name: string
status: 'healthy' | 'unhealthy'
endpoint: string
lastChecked: string
}
function Dashboard() {
const [services, setServices] = useState<ServiceStatus[]>([])
const [stats, setStats] = useState({
totalServices: 0,
healthyServices: 0,
unhealthyServices: 0,
})
useEffect(() => {
checkServices()
const interval = setInterval(checkServices, 10000)
return () => clearInterval(interval)
}, [])
const checkServices = async () => {
const serviceChecks = [
{ name: 'Console Backend', endpoint: '/api/health' },
{ name: 'Users Service', endpoint: '/api/users/health' },
]
const results = await Promise.all(
serviceChecks.map(async (service) => {
try {
await axios.get(service.endpoint)
return {
...service,
status: 'healthy' as const,
lastChecked: new Date().toLocaleTimeString(),
}
} catch {
return {
...service,
status: 'unhealthy' as const,
lastChecked: new Date().toLocaleTimeString(),
}
}
})
)
setServices(results)
const healthy = results.filter(s => s.status === 'healthy').length
setStats({
totalServices: results.length,
healthyServices: healthy,
unhealthyServices: results.length - healthy,
})
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Services
</Typography>
<Typography variant="h3">
{stats.totalServices}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Healthy Services
</Typography>
<Typography variant="h3" color="success.main">
{stats.healthyServices}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Unhealthy Services
</Typography>
<Typography variant="h3" color="error.main">
{stats.unhealthyServices}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Service Status
</Typography>
<Grid container spacing={2}>
{services.map((service) => (
<Grid item xs={12} md={6} key={service.name}>
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6">{service.name}</Typography>
<Typography variant="body2" color="textSecondary">
{service.endpoint}
</Typography>
<Typography variant="caption" color="textSecondary">
Last checked: {service.lastChecked}
</Typography>
</Box>
<Chip
label={service.status}
color={service.status === 'healthy' ? 'success' : 'error'}
icon={service.status === 'healthy' ? <CheckCircleIcon /> : <ErrorIcon />}
/>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Paper>
</Box>
)
}
export default Dashboard

View 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;

View 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;

View File

@ -0,0 +1,98 @@
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
} from '@mui/material'
const servicesData = [
{
id: 1,
name: 'Console',
type: 'API Gateway',
port: 8011,
status: 'Running',
description: 'Central orchestrator and API gateway',
},
{
id: 2,
name: 'Users',
type: 'Microservice',
port: 8001,
status: 'Running',
description: 'User management service',
},
{
id: 3,
name: 'MongoDB',
type: 'Database',
port: 27017,
status: 'Running',
description: 'Document database for persistence',
},
{
id: 4,
name: 'Redis',
type: 'Cache',
port: 6379,
status: 'Running',
description: 'In-memory cache and pub/sub',
},
]
function Services() {
return (
<Box>
<Typography variant="h4" gutterBottom>
Services
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Service Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Port</TableCell>
<TableCell>Status</TableCell>
<TableCell>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{servicesData.map((service) => (
<TableRow key={service.id}>
<TableCell>
<Typography variant="subtitle2">{service.name}</Typography>
</TableCell>
<TableCell>
<Chip
label={service.type}
size="small"
color={service.type === 'API Gateway' ? 'primary' : 'default'}
/>
</TableCell>
<TableCell>{service.port}</TableCell>
<TableCell>
<Chip
label={service.status}
size="small"
color="success"
/>
</TableCell>
<TableCell>{service.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)
}
export default Services

View File

@ -0,0 +1,208 @@
import { useState, useEffect } from 'react'
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
IconButton,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
} from '@mui/material'
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from '@mui/icons-material'
import axios from 'axios'
interface User {
_id: string
username: string
email: string
full_name?: string
created_at: string
}
function Users() {
const [users, setUsers] = useState<User[]>([])
const [openDialog, setOpenDialog] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [formData, setFormData] = useState({
username: '',
email: '',
full_name: '',
})
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
const response = await axios.get('/api/users/')
setUsers(response.data)
} catch (error) {
console.error('Failed to fetch users:', error)
}
}
const handleOpenDialog = (user?: User) => {
if (user) {
setEditingUser(user)
setFormData({
username: user.username,
email: user.email,
full_name: user.full_name || '',
})
} else {
setEditingUser(null)
setFormData({
username: '',
email: '',
full_name: '',
})
}
setOpenDialog(true)
}
const handleCloseDialog = () => {
setOpenDialog(false)
setEditingUser(null)
setFormData({
username: '',
email: '',
full_name: '',
})
}
const handleSubmit = async () => {
try {
if (editingUser) {
await axios.put(`/api/users/${editingUser._id}`, formData)
} else {
await axios.post('/api/users/', formData)
}
fetchUsers()
handleCloseDialog()
} catch (error) {
console.error('Failed to save user:', error)
}
}
const handleDelete = async (id: string) => {
if (confirm('Are you sure you want to delete this user?')) {
try {
await axios.delete(`/api/users/${id}`)
fetchUsers()
} catch (error) {
console.error('Failed to delete user:', error)
}
}
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h4">
Users
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
Add User
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Full Name</TableCell>
<TableCell>Created At</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user._id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.full_name || '-'}</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => handleOpenDialog(user)}
>
<EditIcon />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(user._id)}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{editingUser ? 'Edit User' : 'Add New User'}
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
fullWidth
required
/>
<TextField
label="Email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
fullWidth
required
/>
<TextField
label="Full Name"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
fullWidth
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained">
{editingUser ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
export default Users

View 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>;
}

View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://console-backend:8000',
changeOrigin: true
}
}
}
})