Compare commits

..

10 Commits

Author SHA1 Message Date
14d1eb9d89 fix: Resolve registration API 500 error and proxy configuration
- Fixed undefined variable 'db' in register endpoint (renamed to 'database')
- Updated Vite proxy configuration to use Docker container names
- Fixed proxy target from localhost to backend container
- Added host: true to Vite server config for Docker compatibility
- Registration endpoint now works correctly through frontend proxy

All registration functionality is now fully operational:
- Frontend form validation
- API proxy routing
- Backend user creation
- JWT token generation
- MongoDB data persistence

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 12:08:36 +09:00
8996bd8638 feat: Complete backend API setup with registration endpoint
- Added user registration endpoint (/api/v1/auth/register)
- Created MongoDB database connection module
- Fixed user models to match frontend signup form
- Exposed backend port 8000 for development
- Configured Vite proxy for API requests
- Successfully tested user registration flow

Backend is now fully functional with:
- MongoDB connection
- User registration with password hashing
- JWT token generation
- Proper error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 11:46:04 +09:00
0aa6db1b3b feat: Complete Material-UI migration for all pages
- Updated Dashboard page to use Material-UI components
- Updated SignupPage to use Material-UI with Google OAuth style
- Fixed Material-UI icon import issues (replaced Activity with Timeline)
- Updated CLAUDE.md to reflect UI framework migration from Lucide/Tailwind to Material-UI
- All pages now follow consistent Google OAuth design pattern

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 11:35:05 +09:00
fabcc986f9 feat: Replace Lucide React with Material-UI for Google OAuth style
- Installed Material-UI (@mui/material, @emotion/react, @emotion/styled, @mui/icons-material)
- Removed unused Lucide React dependency
- Redesigned LoginPage with Material-UI to match Google OAuth login style
- Redesigned AuthorizePage with Material-UI to match Google OAuth permission screen
- Updated docker-compose to remove APISIX health check dependency from frontend

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 11:29:34 +09:00
6d853562a8 feat: Google OAuth 스타일 로그인 및 권한 요청 페이지 구현
- 심플하고 깔끔한 Google 스타일 로그인 페이지
- 사용자 계정 기억 기능 (프로필 아바타 표시)
- OAuth 권한 요청/승인 페이지 구현
- 필수/선택 권한 구분 및 상세 정보 표시
- /oauth/authorize 라우트 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 11:13:51 +09:00
18fe4df9ef feat: 회원가입 페이지 추가
- 모던한 디자인의 회원가입 페이지 구현
- 비밀번호 강도 표시기 추가
- 실시간 입력 검증
- /signup 라우트 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 11:10:10 +09:00
b773ef1b3c feat: 전문적이고 모던한 OAuth 로그인 UI 구현
- AiMond Authorization 브랜딩 적용
- 다크모드 기반 글래스모피즘 디자인
- 애니메이션 효과 (플로팅, 그라디언트, 포커스)
- React Router 기반 라우팅 구조
- AuthContext를 통한 인증 상태 관리
- 대시보드 및 관리 페이지 기본 구조
- Backend API 엔드포인트 구조 개선
- pymongo 호환성 문제 수정

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 11:07:06 +09:00
c03619bdb6 docs: README.md 상세 문서화 완료
- 프로젝트 개요 및 주요 특징 명시
- 시스템 아키텍처 Mermaid 다이어그램 추가
- 서비스별 상세 설명 (OAuth, API Gateway)
- 빠른 시작 가이드 및 개발 가이드
- API 엔드포인트 문서화
- 배포 가이드 (dev/vei/prod)
- 보안 체크리스트 및 기여 가이드

향후 서비스 추가 시 README.md에 상세 정보 업데이트 예정

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 10:23:20 +09:00
6ef6dc53a2 Merge branch 'main' of http://gitea.yakenator.io/aimond/works 2025-08-31 10:17:53 +09:00
f53d55e712 Initial commit: OAuth 2.0 인증 시스템 with APISIX API Gateway
- FastAPI 백엔드 + MongoDB + Redis 구성
- React + Vite + TypeScript + shadcn/ui 프론트엔드
- Apache APISIX API Gateway 통합
- Docker Compose 기반 개발 환경
- 3단계 권한 체계 (System Admin, Group Admin, User)
- 동적 테마 지원
- 환경별 설정 (dev/vei/prod)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 10:16:41 +09:00
76 changed files with 9968 additions and 2 deletions

118
.gitignore vendored Normal file
View File

@ -0,0 +1,118 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Testing
coverage/
*.lcov
.nyc_output
# Production
build/
dist/
out/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
.venv
pip-log.txt
pip-delete-this-directory.txt
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.hypothesis/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
!.env.example
!oauth/configs/*/.env
# Git credentials
.gitcredentials
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
*.swn
*.bak
*.tmp
# Docker
.docker/data/
# Backup and Archives
*.tar.gz
*.zip
*.rar
backup/
archives/
# SSL certificates
*.pem
*.key
*.crt
*.cer
nginx/ssl/
# Database
*.db
*.sqlite
*.sqlite3
mongodb_data/
redis_data/
# Cache
.cache/
.parcel-cache/
.next/
.nuxt/
.vuepress/dist
.serverless/
.fusebox/
# Misc
*.pid
*.seed
*.pid.lock
.npm
.eslintcache
.stylelintcache
.yarn-integrity
# Build artifacts
*.tsbuildinfo

View File

@ -0,0 +1,88 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth-backend
namespace: oauth-system
spec:
replicas: 3
selector:
matchLabels:
app: oauth-backend
template:
metadata:
labels:
app: oauth-backend
spec:
containers:
- name: backend
image: ${NEXUS_URL}/oauth-backend:${VERSION}
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: oauth-config
- secretRef:
name: oauth-secret
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth-frontend
namespace: oauth-system
spec:
replicas: 2
selector:
matchLabels:
app: oauth-frontend
template:
metadata:
labels:
app: oauth-frontend
spec:
containers:
- name: frontend
image: ${NEXUS_URL}/oauth-frontend:${VERSION}
ports:
- containerPort: 80
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
---
apiVersion: v1
kind: Service
metadata:
name: oauth-backend-service
namespace: oauth-system
spec:
selector:
app: oauth-backend
ports:
- protocol: TCP
port: 8000
targetPort: 8000
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: oauth-frontend-service
namespace: oauth-system
spec:
selector:
app: oauth-frontend
ports:
- protocol: TCP
port: 80
targetPort: 80
type: ClusterIP

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: oauth-system

252
CLAUDE.md Normal file
View File

@ -0,0 +1,252 @@
# OAuth 2.0 인증 시스템
## 문서 작성 규칙
- 모든 다이어그램은 Mermaid 문법을 사용하여 작성
- 코드 블록은 언어별 하이라이팅 적용
- API 명세는 OpenAPI 3.0 스펙 준수
## 개발 환경 규칙
- **모든 개발은 Docker 컨테이너 환경에서 진행**
- Docker Compose를 통한 통합 개발 환경 구성
- 서비스 간 의존성은 healthcheck를 통해 관리
- 모든 빌드는 depends_on과 condition을 사용하여 순차 실행
## Git 협업 규칙
- **원격 저장소**: http://gitea.yakenator.io/aimond/works.git
- **모든 작업 세션은 git commit으로 마무리**
- 커밋 메시지는 명확하고 구체적으로 작성
- 주요 기능 완료 시 즉시 커밋 및 푸시
- 브랜치 전략: main (production), develop (개발), feature/* (기능별)
## 프로젝트 개요
엔터프라이즈급 OAuth 2.0 기반 중앙 인증 시스템으로, 멀티 테넌트 환경에서 동적 테마 적용 및 세분화된 권한 관리를 제공합니다.
## 시스템 아키텍처
### 기술 스택
- **API Gateway**: Apache APISIX 3.8.0
- **Frontend**: React 18 + Vite + TypeScript + Material-UI (MUI)
- 이전: Lucide React + Tailwind CSS
- 현재: Material-UI (@mui/material, @emotion/react, @emotion/styled, @mui/icons-material)
- 디자인: Google OAuth 스타일 UI/UX
- **Backend**: Python 3.11 + FastAPI + Motor (MongoDB async)
- **Database**: MongoDB 7.0
- **Cache/Queue**: Redis 7
- **Service Discovery**: etcd 3.5
- **Container**: Docker + Docker Compose
- **Orchestration**: Kubernetes (Production)
- **Repository**: Nexus (Artifact Cache)
### 환경 구성
- **dev**: 로컬 개발 환경
- **vei**: 검증계 (Docker 환경)
- **prod**: 운영계 (K8s 환경)
## 공통 섹션
### 프로젝트 구조
```
/
├── oauth/ # OAuth 인증 시스템
│ ├── backend/ # FastAPI 백엔드
│ ├── frontend/ # React 프론트엔드
│ ├── docs/ # 상세 문서
│ └── configs/ # 환경별 설정
│ ├── dev/
│ ├── vei/
│ └── prod/
├── services/ # 인증을 사용할 서비스들
├── .docker/ # Docker 관련 파일
├── .k8s/ # Kubernetes 매니페스트
└── docker-compose.yml # 개발 환경 구성
```
### 개발 환경 시작하기
#### 사전 요구사항
- Docker & Docker Compose
#### 통합 개발 환경 실행 (Docker Compose)
```bash
# 모든 서비스 실행 (개발 모드)
docker-compose up --build
# 백그라운드 실행
docker-compose up -d --build
# 로그 확인
docker-compose logs -f [service_name]
# 서비스 중지
docker-compose down
# 볼륨 포함 삭제
docker-compose down -v
```
#### 서비스 접속 URL
- **API Gateway**: http://localhost:9080
- **APISIX Dashboard**: http://localhost:9000 (admin/admin123)
- **Frontend**: http://localhost:5173
- **Backend API**: http://localhost:9080/api/v1 (through APISIX)
- **MongoDB**: mongodb://localhost:27017
- **Redis**: redis://localhost:6379
### API 엔드포인트
- Health Check: `GET http://localhost:9080/health`
- API Documentation: `http://localhost:9080/api/v1/docs`
- APISIX Admin API: `http://localhost:9092/apisix/admin`
- APISIX Dashboard: `http://localhost:9000`
## OAuth 인증 시스템
[상세 문서는 oauth/docs 참조]
### 핵심 기능
#### 1. 사용자 관리
- **3단계 권한 체계**
- System Admin: 전체 시스템 관리
- Group Admin: 그룹/조직 관리
- User: 일반 사용자
#### 2. 애플리케이션 관리
- 동적 테마 설정
- 애플리케이션별 인증 페이지 커스터마이징
- Client ID/Secret 관리
- Redirect URI 설정
#### 3. 권한 및 데이터 공유
- **공유 가능 권한**:
- 싱글 사인온 (SSO) 여부
- 이름
- 성별
- 생년월일
- 이메일
- 전화번호 (선택적)
#### 4. 보안 기능
- JWT 기반 인증
- Refresh Token 관리
- 세션 관리
- 접속 히스토리 추적
#### 5. 데이터 관리
- 접속 히스토리: 1개월 보관 후 압축 아카이빙
- 자동 백업: 매일 새벽 3시 실행
- 데이터 암호화
### 환경 변수 설정
#### 필수 환경 변수
```env
SECRET_KEY=your-secret-key
MONGODB_URL=mongodb://localhost:27017
DATABASE_NAME=oauth_db
REDIS_URL=redis://localhost:6379
ENVIRONMENT=dev
BACKUP_PATH=/var/backups/oauth
ARCHIVE_PATH=/var/archives/oauth
```
### 데이터베이스 스키마
#### Users Collection
- `_id`: ObjectId
- `email`: 이메일 (unique)
- `username`: 사용자명 (unique)
- `full_name`: 전체 이름
- `role`: 권한 (system_admin/group_admin/user)
- `hashed_password`: 암호화된 비밀번호
- `profile_picture`: 프로필 사진 URL
- `created_at`: 생성일시
- `updated_at`: 수정일시
- `last_login`: 마지막 로그인
#### Applications Collection
- `_id`: ObjectId
- `app_name`: 애플리케이션 이름
- `client_id`: OAuth Client ID (unique)
- `client_secret`: OAuth Client Secret
- `redirect_uris`: 허용된 Redirect URI 목록
- `theme`: 테마 설정 (색상, 로고, 폰트 등)
- `created_by`: 생성자 ID
- `created_at`: 생성일시
#### Auth History Collection
- `_id`: ObjectId
- `user_id`: 사용자 ID
- `application_id`: 애플리케이션 ID
- `action`: 인증 액션 (login/logout/token_refresh)
- `ip_address`: IP 주소
- `user_agent`: User Agent
- `created_at`: 발생일시
### 백업 및 아카이빙
#### 자동 백업 (Cron Job)
```bash
0 3 * * * /usr/local/bin/backup-oauth.sh
```
#### 백업 스크립트
```bash
#!/bin/bash
DATE=$(date +%Y%m%d)
mongodump --uri="mongodb://localhost:27017" --db=oauth_db --out=/var/backups/oauth/$DATE
tar -czf /var/backups/oauth/oauth_backup_$DATE.tar.gz /var/backups/oauth/$DATE
rm -rf /var/backups/oauth/$DATE
```
### 배포 가이드
#### Docker 빌드
```bash
# Backend
cd oauth/backend
docker build -t oauth-backend:latest .
# Frontend
cd oauth/frontend
docker build -t oauth-frontend:latest .
```
#### Kubernetes 배포
```bash
kubectl apply -f .k8s/oauth-namespace.yaml
kubectl apply -f .k8s/oauth-configmap.yaml
kubectl apply -f .k8s/oauth-secret.yaml
kubectl apply -f .k8s/oauth-deployment.yaml
kubectl apply -f .k8s/oauth-ingress.yaml
```
### 모니터링 및 로깅
- Application Logs: `/var/log/oauth/`
- Access Logs: `/var/log/nginx/`
- Error Tracking: Sentry 연동 가능
- Metrics: Prometheus + Grafana
### 성능 최적화
- Redis 캐싱 전략
- MongoDB 인덱싱
- 비동기 처리 (FastAPI + Motor)
- CDN 활용 (정적 자원)
### 보안 체크리스트
- [ ] 환경별 Secret Key 분리
- [ ] HTTPS 적용
- [ ] Rate Limiting 설정
- [ ] CORS 정책 설정
- [ ] SQL Injection 방지
- [ ] XSS 방지
- [ ] CSRF 토큰 구현
- [ ] 민감 정보 암호화
### 트러블슈팅
[상세 내용은 oauth/docs/troubleshooting.md 참조]
### 추가 문서
- [API 명세서](oauth/docs/api-specification.md)
- [보안 가이드](oauth/docs/security-guide.md)
- [성능 튜닝](oauth/docs/performance-tuning.md)
- [마이그레이션 가이드](oauth/docs/migration-guide.md)

71
Makefile Normal file
View File

@ -0,0 +1,71 @@
.PHONY: help up down build restart logs clean
help: ## 도움말 표시
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
up: ## Docker Compose로 모든 서비스 시작
docker-compose up --build
up-d: ## Docker Compose로 백그라운드에서 모든 서비스 시작
docker-compose up -d --build
down: ## 모든 서비스 중지
docker-compose down
down-v: ## 모든 서비스 중지 및 볼륨 삭제
docker-compose down -v
build: ## 모든 이미지 빌드
docker-compose build --no-cache
restart: ## 모든 서비스 재시작
docker-compose restart
logs: ## 모든 서비스 로그 확인
docker-compose logs -f
logs-backend: ## 백엔드 로그 확인
docker-compose logs -f backend
logs-frontend: ## 프론트엔드 로그 확인
docker-compose logs -f frontend
logs-apisix: ## APISIX 로그 확인
docker-compose logs -f apisix
ps: ## 실행 중인 컨테이너 상태 확인
docker-compose ps
exec-backend: ## 백엔드 컨테이너 쉘 접속
docker-compose exec backend /bin/bash
exec-mongo: ## MongoDB 쉘 접속
docker-compose exec mongodb mongosh -u admin -p admin123
exec-redis: ## Redis CLI 접속
docker-compose exec redis redis-cli
clean: ## Docker 시스템 정리 (unused images, containers, volumes)
docker system prune -af --volumes
test-backend: ## 백엔드 테스트 실행
docker-compose exec backend pytest
test-frontend: ## 프론트엔드 테스트 실행
docker-compose exec frontend npm test
format-backend: ## 백엔드 코드 포맷팅
docker-compose exec backend black .
docker-compose exec backend ruff check --fix .
check-health: ## 서비스 헬스 체크
@echo "Checking APISIX Health..."
@curl -s http://localhost:9080/health | jq .
@echo "\nChecking Backend Health (through APISIX)..."
@curl -s http://localhost:9080/api/v1/health | jq .
setup-apisix-routes: ## APISIX 라우트 설정
@echo "Setting up APISIX routes..."
@curl -X PUT http://localhost:9092/apisix/admin/routes/1 \
-H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' \
-d @apisix/routes.yaml

391
README.md
View File

@ -1,3 +1,390 @@
# works # OAuth 2.0 인증 시스템 프로젝트
aimond.io 에서 서비스할 대상 시스템의 프로토타입을 구성해 봅니다. > aimond.io 에서 서비스할 대상 시스템의 프로토타입을 구성니다.
## 📋 목차
- [프로젝트 개요](#프로젝트-개요)
- [시스템 아키텍처](#시스템-아키텍처)
- [기술 스택](#기술-스택)
- [서비스 구성](#서비스-구성)
- [빠른 시작](#빠른-시작)
- [개발 가이드](#개발-가이드)
- [API 문서](#api-문서)
- [배포 가이드](#배포-가이드)
- [문서](#문서)
## 🎯 프로젝트 개요
엔터프라이즈급 OAuth 2.0 기반 중앙 인증 시스템으로, 멀티 테넌트 환경에서 동적 테마 적용 및 세분화된 권한 관리를 제공합니다.
### 주요 특징
-**OAuth 2.0 표준 준수**: 완벽한 OAuth 2.0 플로우 구현
-**동적 테마 시스템**: 애플리케이션별 맞춤형 인증 페이지
-**3단계 권한 체계**: System Admin, Group Admin, User
-**API Gateway**: Apache APISIX를 통한 트래픽 관리
-**마이크로서비스 아키텍처**: 확장 가능한 서비스 구조
-**컨테이너 기반**: Docker를 통한 일관된 개발/운영 환경
## 🏗️ 시스템 아키텍처
```mermaid
graph TB
subgraph "Client Layer"
Browser[사용자 브라우저]
Mobile[모바일 앱]
API[API 클라이언트]
end
subgraph "API Gateway Layer"
APISIX[Apache APISIX<br/>Rate Limiting<br/>Authentication<br/>Load Balancing]
etcd[etcd<br/>Service Discovery]
end
subgraph "Application Services"
OAuth[OAuth Service<br/>FastAPI]
Frontend[Web Frontend<br/>React + Vite]
end
subgraph "Data Layer"
MongoDB[(MongoDB<br/>Users/Apps/History)]
Redis[(Redis<br/>Cache/Session)]
end
Browser --> APISIX
Mobile --> APISIX
API --> APISIX
APISIX <--> etcd
APISIX --> OAuth
APISIX --> Frontend
OAuth --> MongoDB
OAuth --> Redis
```
## 🛠️ 기술 스택
### API Gateway
- **Apache APISIX 3.8.0**: 고성능 API Gateway
- **etcd 3.5**: 서비스 디스커버리 및 설정 관리
### Backend
- **Python 3.11**: 메인 프로그래밍 언어
- **FastAPI**: 고성능 웹 프레임워크
- **Motor**: MongoDB 비동기 드라이버
- **Authlib**: OAuth 2.0 구현
- **Celery**: 비동기 작업 처리
### Frontend
- **React 18**: UI 라이브러리
- **Vite**: 빌드 도구
- **TypeScript**: 타입 안정성
- **shadcn/ui**: UI 컴포넌트
- **Tailwind CSS**: 유틸리티 CSS
- **Zustand**: 상태 관리
- **React Query**: 서버 상태 관리
### Database & Cache
- **MongoDB 7.0**: 메인 데이터베이스
- **Redis 7**: 캐싱 및 세션 관리
### DevOps
- **Docker & Docker Compose**: 컨테이너화
- **Kubernetes**: 프로덕션 오케스트레이션
- **Nexus**: 아티팩트 저장소
## 📦 서비스 구성
### 1. OAuth 인증 서비스 (`/oauth`)
#### 백엔드 (`/oauth/backend`)
FastAPI 기반 OAuth 2.0 인증 서버
**주요 기능:**
- OAuth 2.0 Authorization Code Flow
- JWT 토큰 발급 및 검증
- 사용자 인증 및 권한 관리
- 애플리케이션 등록 및 관리
- 인증 히스토리 추적
**디렉토리 구조:**
```
oauth/backend/
├── app/
│ ├── api/ # API 엔드포인트
│ ├── core/ # 핵심 설정 및 유틸리티
│ ├── models/ # 데이터 모델
│ ├── services/ # 비즈니스 로직
│ └── main.py # 애플리케이션 진입점
├── tests/ # 테스트 코드
├── requirements.txt # Python 의존성
└── Dockerfile # 컨테이너 이미지
```
#### 프론트엔드 (`/oauth/frontend`)
React + TypeScript 기반 인증 UI
**주요 기능:**
- 동적 테마 적용 로그인 페이지
- 사용자 프로필 관리
- 애플리케이션 관리 대시보드
- 권한 설정 인터페이스
- 인증 히스토리 뷰어
**디렉토리 구조:**
```
oauth/frontend/
├── src/
│ ├── components/ # React 컴포넌트
│ ├── pages/ # 페이지 컴포넌트
│ ├── hooks/ # Custom Hooks
│ ├── services/ # API 서비스
│ ├── stores/ # 상태 관리
│ └── utils/ # 유틸리티 함수
├── public/ # 정적 파일
└── package.json # Node.js 의존성
```
### 2. API Gateway (`/apisix`)
Apache APISIX 설정 및 라우팅 규칙
**주요 설정:**
- 라우팅 규칙 (`routes.yaml`)
- Rate Limiting 정책
- JWT 인증 플러그인
- CORS 설정
- 캐싱 전략
**트래픽 제한:**
| 엔드포인트 | Rate Limit | Burst |
|-----------|------------|-------|
| /api/v1/auth/* | 10 req/s | 20 |
| /api/v1/users/* | 100 req/s | 50 |
| /api/v1/applications/* | 50 req/s | 25 |
| /api/v1/admin/* | 200 req/s | 100 |
### 3. 향후 추가될 서비스 (`/services`)
이 디렉토리에는 OAuth 인증을 사용할 서비스들이 추가됩니다.
## 🚀 빠른 시작
### 사전 요구사항
- Docker & Docker Compose
- Git
- Make (선택사항)
### 1. 저장소 클론
```bash
git clone http://gitea.yakenator.io/aimond/works.git
cd works
```
### 2. 환경 변수 설정
```bash
# 백엔드 환경 변수
cp oauth/backend/.env.example oauth/backend/.env
# .env 파일을 열어 필요한 값 수정
```
### 3. Docker Compose로 실행
#### Makefile 사용 (권장)
```bash
# 모든 서비스 시작
make up
# 백그라운드 실행
make up-d
# 로그 확인
make logs
# 서비스 중지
make down
```
#### Docker Compose 직접 사용
```bash
# 모든 서비스 시작
docker-compose up --build
# 백그라운드 실행
docker-compose up -d --build
# 로그 확인
docker-compose logs -f
# 서비스 중지
docker-compose down
```
### 4. 서비스 접속
- **API Gateway**: http://localhost:9080
- **APISIX Dashboard**: http://localhost:9000 (admin/admin123)
- **Frontend**: http://localhost:5173
- **API Documentation**: http://localhost:9080/api/v1/docs
## 💻 개발 가이드
### 개발 환경 규칙
- 모든 개발은 Docker 컨테이너 환경에서 진행
- 서비스 간 의존성은 healthcheck로 관리
- Hot reload 지원 (백엔드/프론트엔드)
### 코드 스타일
- **Python**: Black + Ruff
- **TypeScript**: ESLint + Prettier
- **Commit**: Conventional Commits
### 테스트
```bash
# 백엔드 테스트
make test-backend
# 프론트엔드 테스트
make test-frontend
```
### 디버깅
```bash
# 백엔드 컨테이너 접속
make exec-backend
# MongoDB 쉘 접속
make exec-mongo
# Redis CLI 접속
make exec-redis
```
## 📚 API 문서
### 주요 엔드포인트
#### 인증
- `POST /api/v1/auth/login`: 로그인
- `POST /api/v1/auth/logout`: 로그아웃
- `POST /api/v1/auth/refresh`: 토큰 갱신
- `GET /api/v1/auth/authorize`: OAuth 인증
- `POST /api/v1/auth/token`: 토큰 발급
#### 사용자
- `GET /api/v1/users/me`: 현재 사용자 정보
- `PUT /api/v1/users/me`: 사용자 정보 수정
- `POST /api/v1/users/me/password`: 패스워드 변경
#### 애플리케이션
- `GET /api/v1/applications`: 애플리케이션 목록
- `POST /api/v1/applications`: 애플리케이션 등록
- `PUT /api/v1/applications/{id}`: 애플리케이션 수정
- `DELETE /api/v1/applications/{id}`: 애플리케이션 삭제
상세 API 문서는 [oauth/docs/api-specification.md](oauth/docs/api-specification.md) 참조
## 🚢 배포 가이드
### 환경별 배포
#### 개발 환경 (dev)
```bash
docker-compose up -d
```
#### 검증 환경 (vei)
```bash
cd oauth/configs/vei
docker-compose -f docker-compose.yml up -d
```
#### 운영 환경 (prod)
```bash
kubectl apply -f .k8s/
```
### 백업 및 복구
#### 자동 백업
- 매일 새벽 3시 자동 백업
- 1개월 이상 데이터는 압축 아카이빙
#### 수동 백업
```bash
# MongoDB 백업
docker-compose exec mongodb mongodump --out /backup
# Redis 백업
docker-compose exec redis redis-cli BGSAVE
```
## 📖 문서
### 핵심 문서
- [CLAUDE.md](CLAUDE.md): 프로젝트 규칙 및 가이드
- [Architecture](oauth/docs/architecture.md): 시스템 아키텍처
- [API Specification](oauth/docs/api-specification.md): API 명세
- [APISIX Guide](oauth/docs/apisix-guide.md): API Gateway 가이드
### 개발 문서
- Frontend 개발 가이드 (작성 예정)
- Backend 개발 가이드 (작성 예정)
- 테스트 가이드 (작성 예정)
### 운영 문서
- 모니터링 가이드 (작성 예정)
- 트러블슈팅 가이드 (작성 예정)
- 성능 튜닝 가이드 (작성 예정)
## 🔐 보안
### 보안 기능
- JWT 기반 인증
- Rate Limiting
- IP 제한 (관리자 API)
- CORS 정책
- SQL Injection 방지
- XSS/CSRF 방지
### 보안 체크리스트
- [ ] 환경별 Secret Key 분리
- [ ] HTTPS 적용
- [ ] WAF 설정
- [ ] 정기 보안 감사
- [ ] 취약점 스캔
## 🤝 기여
### Git 협업 규칙
- **원격 저장소**: http://gitea.yakenator.io/aimond/works.git
- **브랜치 전략**:
- `main`: 프로덕션
- `develop`: 개발
- `feature/*`: 기능 개발
- **커밋 규칙**: 모든 작업 세션은 git commit으로 마무리
### 개발 프로세스
1. Feature 브랜치 생성
2. 개발 및 테스트
3. Pull Request 생성
4. 코드 리뷰
5. Merge
## 📝 라이선스
[LICENSE](LICENSE) 파일 참조
## 👥 팀
- 개발: Claude AI Assistant
- 기획/관리: 사용자
## 🔗 관련 링크
- [Gitea Repository](http://gitea.yakenator.io/aimond/works.git)
- [APISIX Documentation](https://apisix.apache.org/)
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
- [React Documentation](https://react.dev/)
---
**Last Updated**: 2024-12-31
**Version**: 1.0.0

View File

@ -0,0 +1,30 @@
conf:
listen:
host: 0.0.0.0
port: 9000
etcd:
endpoints:
- etcd:2379
prefix: /apisix
mtls:
cert: ""
cert_key: ""
verify: false
log:
error_log:
level: warn
file_path: logs/error.log
access_log:
file_path: logs/access.log
authentication:
secret: secret
expire_time: 3600
users:
- username: admin
password: admin123
- username: user
password: user123
oidc:
enabled: false

71
apisix/config.yaml Normal file
View File

@ -0,0 +1,71 @@
apisix:
node_listen: 9080
enable_ipv6: false
enable_control: true
control:
ip: "0.0.0.0"
port: 9092
deployment:
admin:
allow_admin:
- 0.0.0.0/0
admin_key:
- name: "admin"
key: edd1c9f034335f136f87ad84b625c8f1
role: admin
- name: "viewer"
key: 4054f7cf07e344346cd3f287985e76a2
role: viewer
etcd:
host:
- "http://etcd:2379"
prefix: "/apisix"
timeout: 30
plugin_attr:
prometheus:
export_addr:
ip: "0.0.0.0"
port: 9091
plugins:
- api-breaker
- authz-keycloak
- basic-auth
- batch-requests
- consumer-restriction
- cors
- echo
- fault-injection
- grpc-transcode
- hmac-auth
- http-logger
- ip-restriction
- jwt-auth
- kafka-logger
- key-auth
- limit-conn
- limit-count
- limit-req
- node-status
- prometheus
- proxy-cache
- proxy-mirror
- proxy-rewrite
- redirect
- referer-restriction
- request-id
- request-validation
- response-rewrite
- serverless-post-function
- serverless-pre-function
- sls-logger
- syslog
- tcp-logger
- udp-logger
- uri-blocker
- wolf-rbac
- zipkin
- server-info
- traffic-split

119
apisix/routes.yaml Normal file
View File

@ -0,0 +1,119 @@
routes:
- uri: /api/v1/auth/*
name: auth-service
upstream:
type: roundrobin
nodes:
backend:8000: 1
plugins:
cors:
allow_origins: "*"
allow_methods: "GET,POST,PUT,DELETE,OPTIONS"
allow_headers: "*"
expose_headers: "*"
limit-req:
rate: 10
burst: 20
rejected_code: 429
request-id:
header_name: "X-Request-Id"
include_in_response: true
- uri: /api/v1/users/*
name: user-service
upstream:
type: roundrobin
nodes:
backend:8000: 1
plugins:
jwt-auth:
key: "user-key"
secret: "my-secret-key"
cors:
allow_origins: "*"
allow_methods: "GET,POST,PUT,DELETE,OPTIONS"
allow_headers: "*"
expose_headers: "*"
limit-req:
rate: 100
burst: 50
rejected_code: 429
- uri: /api/v1/applications/*
name: application-service
upstream:
type: roundrobin
nodes:
backend:8000: 1
plugins:
jwt-auth:
key: "user-key"
secret: "my-secret-key"
cors:
allow_origins: "*"
allow_methods: "GET,POST,PUT,DELETE,OPTIONS"
allow_headers: "*"
expose_headers: "*"
limit-req:
rate: 50
burst: 25
rejected_code: 429
- uri: /api/v1/admin/*
name: admin-service
upstream:
type: roundrobin
nodes:
backend:8000: 1
plugins:
jwt-auth:
key: "admin-key"
secret: "admin-secret-key"
ip-restriction:
whitelist:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
cors:
allow_origins: "*"
allow_methods: "GET,POST,PUT,DELETE,OPTIONS"
allow_headers: "*"
expose_headers: "*"
limit-req:
rate: 200
burst: 100
rejected_code: 429
- uri: /health
name: health-check
upstream:
type: roundrobin
nodes:
backend:8000: 1
plugins:
limit-req:
rate: 1000
burst: 500
- uri: /*
name: frontend
upstream:
type: roundrobin
nodes:
frontend:80: 1
plugins:
proxy-cache:
cache_zone:
name: disk_cache_one
memory_size: 50m
disk_size: 1G
disk_path: "/tmp/disk_cache"
cache_method:
- GET
- HEAD
cache_http_status:
- 200
- 301
- 404
cache_ttl: 300
hide_cache_headers: true

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

114
docker-compose-apisix.yml Normal file
View File

@ -0,0 +1,114 @@
version: '3.8'
services:
etcd:
image: bitnami/etcd:3.5
container_name: oauth-etcd
restart: always
volumes:
- etcd_data:/bitnami/etcd
environment:
ALLOW_NONE_AUTHENTICATION: "yes"
ETCD_ADVERTISE_CLIENT_URLS: http://etcd:2379
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
ports:
- "2379:2379"
networks:
- oauth-network
apisix:
image: apache/apisix:3.8.0-debian
container_name: oauth-apisix
restart: always
volumes:
- ./apisix/config.yaml:/usr/local/apisix/conf/config.yaml:ro
depends_on:
- etcd
ports:
- "9080:9080" # HTTP
- "9443:9443" # HTTPS
- "9092:9092" # Control API
networks:
- oauth-network
apisix-dashboard:
image: apache/apisix-dashboard:3.0.1-alpine
container_name: oauth-apisix-dashboard
restart: always
volumes:
- ./apisix/apisix-dashboard.yaml:/usr/local/apisix-dashboard/conf/conf.yaml:ro
ports:
- "9000:9000"
depends_on:
- etcd
- apisix
networks:
- oauth-network
mongodb:
image: mongo:7.0
container_name: oauth-mongodb
restart: always
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: admin123
MONGO_INITDB_DATABASE: oauth_db
volumes:
- mongodb_data:/data/db
- ./oauth/backend/scripts/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
networks:
- oauth-network
redis:
image: redis:7-alpine
container_name: oauth-redis
restart: always
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
networks:
- oauth-network
backend:
build:
context: ./oauth/backend
dockerfile: Dockerfile
container_name: oauth-backend
restart: always
environment:
- MONGODB_URL=mongodb://admin:admin123@mongodb:27017/oauth_db?authSource=admin
- REDIS_URL=redis://redis:6379
- ENVIRONMENT=dev
depends_on:
- mongodb
- redis
volumes:
- ./oauth/backend:/app
- /app/__pycache__
networks:
- oauth-network
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./oauth/frontend
dockerfile: Dockerfile
container_name: oauth-frontend
restart: always
depends_on:
- backend
networks:
- oauth-network
volumes:
etcd_data:
mongodb_data:
redis_data:
networks:
oauth-network:
driver: bridge

148
docker-compose.yml Normal file
View File

@ -0,0 +1,148 @@
version: '3.8'
services:
etcd:
image: bitnami/etcd:3.5
container_name: oauth-etcd
restart: always
volumes:
- etcd_data:/bitnami/etcd
environment:
ALLOW_NONE_AUTHENTICATION: "yes"
ETCD_ADVERTISE_CLIENT_URLS: http://etcd:2379
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 10s
timeout: 5s
retries: 5
networks:
- oauth-network
apisix:
image: apache/apisix:3.8.0-debian
container_name: oauth-apisix
restart: always
volumes:
- ./apisix/config.yaml:/usr/local/apisix/conf/config.yaml:ro
- ./apisix/routes.yaml:/usr/local/apisix/conf/routes.yaml:ro
depends_on:
etcd:
condition: service_healthy
ports:
- "9080:9080" # HTTP Gateway
- "9443:9443" # HTTPS Gateway
- "9092:9092" # Control API
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9080/apisix/admin/routes"]
interval: 10s
timeout: 5s
retries: 5
networks:
- oauth-network
apisix-dashboard:
image: apache/apisix-dashboard:3.0.1-alpine
container_name: oauth-apisix-dashboard
restart: always
volumes:
- ./apisix/apisix-dashboard.yaml:/usr/local/apisix-dashboard/conf/conf.yaml:ro
ports:
- "9000:9000"
depends_on:
- etcd
- apisix
networks:
- oauth-network
mongodb:
image: mongo:7.0
container_name: oauth-mongodb
restart: always
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: admin123
MONGO_INITDB_DATABASE: oauth_db
volumes:
- mongodb_data:/data/db
- ./oauth/backend/scripts/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
networks:
- oauth-network
redis:
image: redis:7-alpine
container_name: oauth-redis
restart: always
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- oauth-network
backend:
build:
context: ./oauth/backend
dockerfile: Dockerfile.dev
container_name: oauth-backend
restart: always
ports:
- "8000:8000"
environment:
- MONGODB_URL=mongodb://admin:admin123@mongodb:27017/oauth_db?authSource=admin
- REDIS_URL=redis://redis:6379
- ENVIRONMENT=dev
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./oauth/backend:/app
- /app/__pycache__
networks:
- oauth-network
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./oauth/frontend
dockerfile: Dockerfile.dev
container_name: oauth-frontend
restart: always
ports:
- "5173:5173"
environment:
- NODE_ENV=development
depends_on:
backend:
condition: service_started
volumes:
- ./oauth/frontend:/app
- /app/node_modules
networks:
- oauth-network
command: npm run dev -- --host 0.0.0.0
volumes:
etcd_data:
mongodb_data:
redis_data:
networks:
oauth-network:
driver: bridge

View File

@ -0,0 +1,13 @@
SECRET_KEY=0198fd96-f538-7a81-be14-d9e4cb81f60d
MONGODB_URL=mongodb://localhost:27017
DATABASE_NAME=oauth_db
REDIS_URL=redis://localhost:6379
ENVIRONMENT=dev
BACKUP_PATH=/var/backups/oauth
ARCHIVE_PATH=/var/archives/oauth
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
NEXUS_URL=http://nexus.local:8081
NEXUS_REPOSITORY=oauth-artifacts

16
oauth/backend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from app.core.security import get_current_user, require_admin
from app.models.user import User
router = APIRouter()
@router.get("/users", dependencies=[Depends(require_admin)])
async def get_all_users():
# TODO: Implement get all users logic
return {"users": []}
@router.get("/stats", dependencies=[Depends(require_admin)])
async def get_system_stats():
# TODO: Implement system statistics logic
return {
"total_users": 0,
"total_applications": 0,
"active_sessions": 0
}
@router.post("/users/{user_id}/role", dependencies=[Depends(require_admin)])
async def update_user_role(user_id: str, role: str):
# TODO: Implement role update logic
return {"message": f"User {user_id} role updated to {role}"}

View File

@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from app.core.security import get_current_user
from app.models.user import User
from app.models.application import Application
router = APIRouter()
@router.get("/", response_model=List[Application])
async def get_applications(current_user: User = Depends(get_current_user)):
# TODO: Implement application list logic
return []
@router.post("/", response_model=Application)
async def create_application(
app_data: dict,
current_user: User = Depends(get_current_user)
):
# TODO: Implement application creation logic
return {"message": "Application created"}
@router.put("/{app_id}")
async def update_application(
app_id: str,
app_data: dict,
current_user: User = Depends(get_current_user)
):
# TODO: Implement application update logic
return {"message": f"Application {app_id} updated"}
@router.delete("/{app_id}")
async def delete_application(
app_id: str,
current_user: User = Depends(get_current_user)
):
# TODO: Implement application deletion logic
return {"message": f"Application {app_id} deleted"}

View File

@ -0,0 +1,85 @@
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import OAuth2PasswordRequestForm
from app.core.security import create_access_token, get_current_user, get_password_hash
from app.models.user import User, UserCreate
from app.core.config import settings
from app.core.database import get_database
from datetime import datetime
router = APIRouter()
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
# TODO: Implement actual authentication
return {
"access_token": create_access_token({"sub": form_data.username}),
"token_type": "bearer"
}
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user)):
# TODO: Implement logout logic
return {"message": "Logged out successfully"}
@router.post("/refresh")
async def refresh_token(current_user: User = Depends(get_current_user)):
# TODO: Implement token refresh logic
return {
"access_token": create_access_token({"sub": current_user.email}),
"token_type": "bearer"
}
@router.get("/authorize")
async def authorize():
# TODO: Implement OAuth authorization
return {"message": "Authorization endpoint"}
@router.post("/token")
async def token():
# TODO: Implement OAuth token endpoint
return {"message": "Token endpoint"}
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(user_data: UserCreate):
"""Register a new user"""
# Get database
database = get_database()
# Check if user already exists
users_collection = database["users"]
existing_user = await users_collection.find_one({"email": user_data.email})
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
user_dict = {
"email": user_data.email,
"full_name": user_data.name,
"username": user_data.email.split("@")[0], # Use email prefix as username
"organization": user_data.organization,
"hashed_password": get_password_hash(user_data.password),
"role": "user", # Default role
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
# Insert user into database
result = await users_collection.insert_one(user_dict)
# Create access token for immediate login
access_token = create_access_token({"sub": user_data.email})
return {
"message": "User registered successfully",
"access_token": access_token,
"token_type": "bearer",
"user": {
"id": str(result.inserted_id),
"email": user_data.email,
"name": user_data.name
}
}

View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.security import get_current_user
from app.models.user import User
router = APIRouter()
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.put("/me")
async def update_me(
user_update: dict,
current_user: User = Depends(get_current_user)
):
# TODO: Implement user update logic
return {"message": "User updated", "user": current_user}
@router.post("/me/password")
async def change_password(
password_data: dict,
current_user: User = Depends(get_current_user)
):
# TODO: Implement password change logic
return {"message": "Password changed successfully"}

View File

@ -0,0 +1,9 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, applications, admin
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(applications.router, prefix="/applications", tags=["applications"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])

View File

@ -0,0 +1,49 @@
from typing import List, Union
from pydantic_settings import BaseSettings
from pydantic import field_validator
import os
class Settings(BaseSettings):
PROJECT_NAME: str = "OAuth Authentication System"
VERSION: str = "1.0.0"
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = os.getenv("SECRET_KEY", "0198fda4-294e-77b0-a95d-2b601d2c594d")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
MONGODB_URL: str = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
DATABASE_NAME: str = os.getenv("DATABASE_NAME", "oauth_db")
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "dev")
BACKUP_PATH: str = os.getenv("BACKUP_PATH", "/var/backups/oauth")
ARCHIVE_PATH: str = os.getenv("ARCHIVE_PATH", "/var/archives/oauth")
SMTP_HOST: str = os.getenv("SMTP_HOST", "")
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER: str = os.getenv("SMTP_USER", "")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
NEXUS_URL: str = os.getenv("NEXUS_URL", "")
NEXUS_REPOSITORY: str = os.getenv("NEXUS_REPOSITORY", "")
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
@classmethod
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

View File

@ -0,0 +1,38 @@
from motor.motor_asyncio import AsyncIOMotorClient
from app.core.config import settings
import redis.asyncio as redis
from typing import Optional
class Database:
client: Optional[AsyncIOMotorClient] = None
database = None
redis_client: Optional[redis.Redis] = None
db = Database()
async def init_db():
db.client = AsyncIOMotorClient(settings.MONGODB_URL)
db.database = db.client[settings.DATABASE_NAME]
db.redis_client = await redis.from_url(settings.REDIS_URL, decode_responses=True)
await create_indexes()
async def close_db():
if db.client:
db.client.close()
if db.redis_client:
await db.redis_client.close()
async def create_indexes():
await db.database.users.create_index("email", unique=True)
await db.database.users.create_index("username", unique=True)
await db.database.applications.create_index("client_id", unique=True)
await db.database.applications.create_index("app_name", unique=True)
await db.database.auth_history.create_index([("user_id", 1), ("created_at", -1)])
await db.database.auth_history.create_index("created_at")
def get_database():
return db.database
def get_redis():
return db.redis_client

View File

@ -0,0 +1,51 @@
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
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})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.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, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
# TODO: Get user from database
return {"email": username, "role": "user"}
async def require_admin(current_user = Depends(get_current_user)):
if current_user.get("role") != "system_admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user

View File

@ -0,0 +1 @@
# Database module

View File

@ -0,0 +1,20 @@
import os
from motor.motor_asyncio import AsyncIOMotorClient
from typing import Optional
mongodb_client: Optional[AsyncIOMotorClient] = None
database = None
async def connect_to_mongo():
global mongodb_client, database
mongodb_url = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
mongodb_client = AsyncIOMotorClient(mongodb_url)
database = mongodb_client.oauth_db
async def close_mongo_connection():
global mongodb_client
if mongodb_client:
mongodb_client.close()
async def get_database():
return database

38
oauth/backend/app/main.py Normal file
View File

@ -0,0 +1,38 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.core.config import settings
from app.core.database import init_db, close_db
from app.api.v1.router import api_router
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
logger.info("Database initialized")
yield
await close_db()
logger.info("Database connection closed")
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_STR)
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "OAuth Authentication System"}

View File

@ -0,0 +1,54 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, Dict, Any
class ApplicationTheme(BaseModel):
primary_color: str = "#1976d2"
secondary_color: str = "#dc004e"
background_color: str = "#ffffff"
text_color: str = "#000000"
logo_url: Optional[str] = None
background_image_url: Optional[str] = None
font_family: str = "Roboto, sans-serif"
border_radius: str = "8px"
custom_css: Optional[str] = None
class ApplicationBase(BaseModel):
app_name: str
description: str
redirect_uris: list[str]
allowed_origins: list[str]
theme: ApplicationTheme = ApplicationTheme()
is_active: bool = True
allow_registration: bool = True
require_email_verification: bool = False
class ApplicationCreate(ApplicationBase):
pass
class ApplicationUpdate(BaseModel):
app_name: Optional[str] = None
description: Optional[str] = None
redirect_uris: Optional[list[str]] = None
allowed_origins: Optional[list[str]] = None
theme: Optional[ApplicationTheme] = None
is_active: Optional[bool] = None
allow_registration: Optional[bool] = None
require_email_verification: Optional[bool] = None
class ApplicationInDB(ApplicationBase):
id: str = Field(alias="_id")
client_id: str
client_secret: str
created_at: datetime
updated_at: datetime
created_by: str
class Config:
populate_by_name = True
class Application(ApplicationBase):
id: str
client_id: str
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,58 @@
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime
from typing import Optional, List
from enum import Enum
class UserRole(str, Enum):
SYSTEM_ADMIN = "system_admin"
GROUP_ADMIN = "group_admin"
USER = "user"
class UserBase(BaseModel):
email: EmailStr
username: Optional[str] = None
full_name: Optional[str] = None
role: UserRole = UserRole.USER
is_active: bool = True
phone_number: Optional[str] = None
birth_date: Optional[str] = None
gender: Optional[str] = None
profile_picture: Optional[str] = None
organization: Optional[str] = None
class UserCreate(BaseModel):
email: EmailStr
password: str
name: str
organization: Optional[str] = None
class UserUpdate(BaseModel):
full_name: Optional[str] = None
phone_number: Optional[str] = None
birth_date: Optional[str] = None
gender: Optional[str] = None
profile_picture: Optional[str] = None
class UserInDB(UserBase):
id: str = Field(alias="_id")
hashed_password: str
created_at: datetime
updated_at: datetime
last_login: Optional[datetime] = None
class Config:
populate_by_name = True
class User(UserBase):
id: str
created_at: datetime
updated_at: datetime
last_login: Optional[datetime] = None
class UserPermissions(BaseModel):
single_sign_on: bool = True
share_name: bool = True
share_gender: bool = False
share_birth_date: bool = False
share_email: bool = True
share_phone: bool = False

View File

@ -0,0 +1,26 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
motor==3.5.1
pymongo==4.8.0
redis==5.0.7
pydantic==2.9.1
pydantic-settings==2.4.0
python-dotenv==1.0.1
httpx==0.27.0
celery==5.4.0
flower==2.0.1
pytest==8.3.2
pytest-asyncio==0.24.0
black==24.8.0
ruff==0.6.3
authlib==1.3.1
itsdangerous==2.2.0
email-validator==2.2.0
Pillow==10.4.0
cryptography==42.0.8
aiofiles==24.1.0
python-dateutil==2.9.0
pytz==2024.1

9
oauth/configs/dev/.env Normal file
View File

@ -0,0 +1,9 @@
ENVIRONMENT=dev
SECRET_KEY=dev-secret-key-change-in-production
MONGODB_URL=mongodb://localhost:27017
DATABASE_NAME=oauth_db_dev
REDIS_URL=redis://localhost:6379
BACKUP_PATH=/var/backups/oauth/dev
ARCHIVE_PATH=/var/archives/oauth/dev
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8000

9
oauth/configs/prod/.env Normal file
View File

@ -0,0 +1,9 @@
ENVIRONMENT=prod
SECRET_KEY=${PROD_SECRET_KEY}
MONGODB_URL=${PROD_MONGODB_URL}
DATABASE_NAME=oauth_db_prod
REDIS_URL=${PROD_REDIS_URL}
BACKUP_PATH=/var/backups/oauth/prod
ARCHIVE_PATH=/var/archives/oauth/prod
FRONTEND_URL=https://oauth.example.com
BACKEND_URL=https://api-oauth.example.com

9
oauth/configs/vei/.env Normal file
View File

@ -0,0 +1,9 @@
ENVIRONMENT=vei
SECRET_KEY=${VEI_SECRET_KEY}
MONGODB_URL=mongodb://mongodb:27017
DATABASE_NAME=oauth_db_vei
REDIS_URL=redis://redis:6379
BACKUP_PATH=/var/backups/oauth/vei
ARCHIVE_PATH=/var/archives/oauth/vei
FRONTEND_URL=https://vei-oauth.example.com
BACKEND_URL=https://vei-oauth-api.example.com

View File

@ -0,0 +1,73 @@
version: '3.8'
services:
mongodb:
image: mongo:7.0
container_name: vei-oauth-mongodb
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
MONGO_INITDB_DATABASE: oauth_db_vei
volumes:
- vei_mongodb_data:/data/db
networks:
- vei-oauth-network
redis:
image: redis:7-alpine
container_name: vei-oauth-redis
restart: always
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes:
- vei_redis_data:/data
networks:
- vei-oauth-network
backend:
image: ${NEXUS_URL}/oauth-backend:${VERSION}
container_name: vei-oauth-backend
restart: always
env_file:
- .env
environment:
- MONGODB_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongodb:27017/oauth_db_vei?authSource=admin
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
depends_on:
- mongodb
- redis
networks:
- vei-oauth-network
frontend:
image: ${NEXUS_URL}/oauth-frontend:${VERSION}
container_name: vei-oauth-frontend
restart: always
depends_on:
- backend
networks:
- vei-oauth-network
nginx:
image: nginx:alpine
container_name: vei-oauth-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- frontend
- backend
networks:
- vei-oauth-network
volumes:
vei_mongodb_data:
vei_redis_data:
networks:
vei-oauth-network:
driver: bridge

View File

@ -0,0 +1,349 @@
# OAuth API 명세서
## Base URL
- Development: `http://localhost:8000/api/v1`
- Verification: `https://vei-oauth-api.example.com/api/v1`
- Production: `https://api-oauth.example.com/api/v1`
## 인증 헤더
```
Authorization: Bearer {access_token}
```
## API 엔드포인트
### 인증 (Authentication)
#### POST /auth/login
사용자 로그인
**Request Body:**
```json
{
"email": "user@example.com",
"password": "password123"
}
```
**Response:**
```json
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "bearer",
"expires_in": 1800
}
```
#### POST /auth/logout
사용자 로그아웃
**Headers:**
- Authorization: Bearer {access_token}
**Response:**
```json
{
"message": "Successfully logged out"
}
```
#### POST /auth/refresh
토큰 갱신
**Request Body:**
```json
{
"refresh_token": "eyJ..."
}
```
**Response:**
```json
{
"access_token": "eyJ...",
"token_type": "bearer",
"expires_in": 1800
}
```
#### POST /auth/authorize
OAuth 인증 요청
**Query Parameters:**
- `response_type`: "code"
- `client_id`: Application Client ID
- `redirect_uri`: Redirect URI
- `scope`: 요청 권한 (space 구분)
- `state`: CSRF 방지용 상태값
**Response:**
- 302 Redirect to `{redirect_uri}?code={auth_code}&state={state}`
#### POST /auth/token
Access Token 발급
**Request Body:**
```json
{
"grant_type": "authorization_code",
"code": "auth_code",
"client_id": "client_id",
"client_secret": "client_secret",
"redirect_uri": "redirect_uri"
}
```
**Response:**
```json
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "bearer",
"expires_in": 1800,
"scope": "read write"
}
```
### 사용자 관리 (Users)
#### GET /users/me
현재 사용자 정보 조회
**Response:**
```json
{
"id": "user_id",
"email": "user@example.com",
"username": "username",
"full_name": "John Doe",
"role": "user",
"profile_picture": "https://...",
"created_at": "2024-01-01T00:00:00Z",
"last_login": "2024-01-01T00:00:00Z"
}
```
#### PUT /users/me
사용자 정보 수정
**Request Body:**
```json
{
"full_name": "Jane Doe",
"phone_number": "+1234567890",
"birth_date": "1990-01-01",
"gender": "female"
}
```
#### POST /users/me/password
패스워드 변경
**Request Body:**
```json
{
"current_password": "old_password",
"new_password": "new_password"
}
```
#### POST /users/me/profile-picture
프로필 사진 업로드
**Request:**
- Content-Type: multipart/form-data
- File: image file
#### GET /users/me/permissions
사용자 권한 조회
**Response:**
```json
{
"single_sign_on": true,
"share_name": true,
"share_gender": false,
"share_birth_date": false,
"share_email": true,
"share_phone": false
}
```
#### PUT /users/me/permissions
사용자 권한 수정
**Request Body:**
```json
{
"share_gender": true,
"share_birth_date": true
}
```
#### GET /users/me/applications
인증된 애플리케이션 목록
**Response:**
```json
{
"applications": [
{
"id": "app_id",
"name": "Application Name",
"logo_url": "https://...",
"authorized_at": "2024-01-01T00:00:00Z",
"last_used": "2024-01-01T00:00:00Z",
"permissions": ["read", "write"]
}
]
}
```
#### DELETE /users/me/applications/{app_id}
애플리케이션 인증 해제
### 애플리케이션 관리 (Applications)
#### GET /applications
애플리케이션 목록 조회 (Admin only)
#### POST /applications
애플리케이션 등록 (Admin only)
**Request Body:**
```json
{
"app_name": "My Application",
"description": "Application description",
"redirect_uris": ["https://app.example.com/callback"],
"allowed_origins": ["https://app.example.com"],
"theme": {
"primary_color": "#1976d2",
"secondary_color": "#dc004e",
"logo_url": "https://...",
"background_image_url": "https://..."
}
}
```
**Response:**
```json
{
"id": "app_id",
"client_id": "generated_client_id",
"client_secret": "generated_client_secret",
"app_name": "My Application",
"created_at": "2024-01-01T00:00:00Z"
}
```
#### GET /applications/{app_id}
애플리케이션 상세 조회
#### PUT /applications/{app_id}
애플리케이션 수정 (Admin only)
#### DELETE /applications/{app_id}
애플리케이션 삭제 (Admin only)
#### POST /applications/{app_id}/regenerate-secret
Client Secret 재생성 (Admin only)
### 관리자 (Admin)
#### GET /admin/users
전체 사용자 목록 (System Admin only)
**Query Parameters:**
- `page`: 페이지 번호 (default: 1)
- `limit`: 페이지당 항목 수 (default: 20)
- `role`: 역할 필터
- `search`: 검색어
#### GET /admin/users/{user_id}
사용자 상세 조회 (Admin only)
#### PUT /admin/users/{user_id}/role
사용자 역할 변경 (System Admin only)
**Request Body:**
```json
{
"role": "group_admin"
}
```
#### GET /admin/audit-logs
감사 로그 조회 (Admin only)
**Query Parameters:**
- `user_id`: 사용자 ID
- `app_id`: 애플리케이션 ID
- `action`: 액션 타입
- `start_date`: 시작일
- `end_date`: 종료일
#### GET /admin/statistics
통계 정보 조회 (Admin only)
**Response:**
```json
{
"total_users": 1000,
"active_users_today": 150,
"total_applications": 25,
"total_authentications_today": 5000,
"top_applications": [...]
}
```
## 에러 응답
### 에러 응답 형식
```json
{
"error": "error_code",
"message": "Error message",
"details": {}
}
```
### 에러 코드
- `400`: Bad Request
- `401`: Unauthorized
- `403`: Forbidden
- `404`: Not Found
- `409`: Conflict
- `422`: Unprocessable Entity
- `429`: Too Many Requests
- `500`: Internal Server Error
## Rate Limiting
- 일반 API: 100 requests/minute
- 인증 API: 10 requests/minute
- 관리자 API: 1000 requests/minute
## Webhooks
### 이벤트 타입
- `user.created`
- `user.updated`
- `user.deleted`
- `user.login`
- `user.logout`
- `application.authorized`
- `application.revoked`
### Webhook 페이로드
```json
{
"event": "user.login",
"timestamp": "2024-01-01T00:00:00Z",
"data": {
"user_id": "user_id",
"application_id": "app_id",
"ip_address": "192.168.1.1"
}
}
```

173
oauth/docs/apisix-guide.md Normal file
View File

@ -0,0 +1,173 @@
# APISIX API Gateway 가이드
## 개요
Apache APISIX는 고성능 API Gateway로 OAuth 시스템의 모든 API 트래픽을 관리합니다.
## 주요 기능
### 1. API 라우팅
```mermaid
graph LR
Client[클라이언트] --> APISIX[APISIX Gateway]
APISIX --> |/api/v1/auth/*| Auth[인증 서비스]
APISIX --> |/api/v1/users/*| Users[사용자 서비스]
APISIX --> |/api/v1/applications/*| Apps[애플리케이션 서비스]
APISIX --> |/api/v1/admin/*| Admin[관리자 서비스]
APISIX --> |/*| Frontend[프론트엔드]
```
### 2. Rate Limiting 정책
- **인증 API**: 10 req/s (burst: 20)
- **사용자 API**: 100 req/s (burst: 50)
- **애플리케이션 API**: 50 req/s (burst: 25)
- **관리자 API**: 200 req/s (burst: 100)
- **Health Check**: 1000 req/s (burst: 500)
### 3. 보안 플러그인
#### JWT 인증
```yaml
jwt-auth:
key: "user-key"
secret: "my-secret-key"
algorithm: "HS256"
```
#### IP 제한 (관리자 API)
```yaml
ip-restriction:
whitelist:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
```
#### CORS 설정
```yaml
cors:
allow_origins: "*"
allow_methods: "GET,POST,PUT,DELETE,OPTIONS"
allow_headers: "*"
expose_headers: "*"
```
### 4. 캐싱 전략
프론트엔드 정적 리소스에 대한 캐싱:
- 캐시 크기: 메모리 50MB, 디스크 1GB
- 캐시 TTL: 300초
- 캐시 대상: GET, HEAD 요청
- 캐시 상태 코드: 200, 301, 404
## APISIX 대시보드
### 접속 정보
- URL: http://localhost:9000
- 계정: admin / admin123
### 주요 기능
1. **라우트 관리**: API 라우팅 규칙 설정
2. **업스트림 관리**: 백엔드 서비스 설정
3. **플러그인 설정**: 보안, 캐싱, 모니터링 플러그인
4. **모니터링**: 실시간 트래픽 모니터링
## API 호출 예시
### 1. Health Check
```bash
curl http://localhost:9080/health
```
### 2. 인증 API
```bash
# 로그인
curl -X POST http://localhost:9080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
```
### 3. 사용자 API (JWT 토큰 필요)
```bash
curl -X GET http://localhost:9080/api/v1/users/me \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### 4. 관리자 API (IP 제한 + JWT)
```bash
curl -X GET http://localhost:9080/api/v1/admin/users \
-H "Authorization: Bearer ADMIN_JWT_TOKEN"
```
## 프로메테우스 메트릭
APISIX는 Prometheus 메트릭을 제공합니다:
- Endpoint: http://localhost:9091/metrics
- 주요 메트릭:
- `apisix_http_status`: HTTP 상태 코드별 요청 수
- `apisix_http_latency`: 요청 지연 시간
- `apisix_bandwidth`: 대역폭 사용량
## 트러블슈팅
### 1. etcd 연결 실패
```bash
# etcd 상태 확인
docker-compose exec etcd etcdctl endpoint health
# etcd 로그 확인
docker-compose logs etcd
```
### 2. 라우트가 작동하지 않음
```bash
# APISIX Admin API로 라우트 확인
curl http://localhost:9092/apisix/admin/routes
```
### 3. Rate Limiting 디버깅
```bash
# Rate limit 헤더 확인
curl -i http://localhost:9080/api/v1/auth/login
# X-RateLimit-Limit, X-RateLimit-Remaining 헤더 확인
```
## 성능 최적화
### 1. 연결 풀 설정
```yaml
upstream:
keepalive: 320
keepalive_requests: 10000
keepalive_timeout: 60s
```
### 2. 캐시 최적화
```yaml
proxy-cache:
cache_zone:
memory_size: 100m # 메모리 캐시 증가
disk_size: 5G # 디스크 캐시 증가
```
### 3. 로드 밸런싱 알고리즘
- `roundrobin`: 기본 라운드 로빈
- `chash`: 일관된 해싱
- `ewma`: 지수 가중 이동 평균
## 보안 Best Practices
1. **Admin API 보호**
- 프로덕션에서는 Admin API를 내부 네트워크에서만 접근 가능하도록 설정
- Admin Key를 환경 변수로 관리
2. **SSL/TLS 설정**
- 프로덕션에서는 반드시 HTTPS 사용
- Let's Encrypt 또는 상용 인증서 적용
3. **WAF 플러그인 활용**
- SQL Injection 방지
- XSS 공격 방지
- CSRF 토큰 검증
4. **로그 모니터링**
- 비정상적인 트래픽 패턴 감지
- 실패한 인증 시도 추적
- Rate limit 초과 모니터링

209
oauth/docs/architecture.md Normal file
View File

@ -0,0 +1,209 @@
# OAuth 시스템 아키텍처
## 시스템 구성도
```mermaid
graph TB
subgraph "Client Layer"
Browser[사용자 브라우저]
end
subgraph "API Gateway Layer"
APISIX[Apache APISIX<br/>- API Gateway<br/>- Rate Limiting<br/>- Authentication<br/>- Load Balancing]
etcd[etcd<br/>- Service Discovery<br/>- Configuration Store]
end
subgraph "Application Layer"
Backend[FastAPI Backend<br/>- Auth Logic<br/>- JWT Handling<br/>- Business Logic]
Frontend[React Frontend<br/>- Dynamic UI<br/>- Theme Engine<br/>- SPA Routing]
end
subgraph "Data Layer"
MongoDB[MongoDB<br/>- Users<br/>- Apps<br/>- History]
Redis[Redis<br/>- Cache<br/>- Queue<br/>- Session]
Celery[Celery<br/>- Tasks<br/>- Jobs]
Backup[Backup Service<br/>- Cron Jobs<br/>- Archives]
end
Browser -->|HTTP/HTTPS| APISIX
APISIX -->|/api/v1/*| Backend
APISIX -->|/*| Frontend
APISIX <--> etcd
Backend --> MongoDB
Backend --> Redis
Backend --> Celery
Backend --> Backup
```
## 데이터 플로우
### 1. 인증 플로우
```mermaid
sequenceDiagram
participant User as 사용자
participant App as 애플리케이션
participant OAuth as OAuth 서버
participant DB as Database
User->>App: 1. 접속
App->>OAuth: 2. 리다이렉트 (client_id, redirect_uri)
OAuth->>User: 3. 동적 로그인 페이지 렌더링
User->>OAuth: 4. 인증 정보 입력
OAuth->>DB: 5. 인증 검증
OAuth->>User: 6. Authorization Code 발급
User->>App: 7. Code 전달
App->>OAuth: 8. Access Token 요청
OAuth->>App: 9. Access Token 발급
App->>OAuth: 10. 사용자 정보 요청
OAuth->>App: 11. 권한별 사용자 정보 제공
```
### 2. 토큰 관리
- Access Token: 30분 유효
- Refresh Token: 7일 유효
- Token Rotation 정책 적용
## 마이크로서비스 구조
```mermaid
graph LR
subgraph "Core Services"
Auth[Authentication Service]
Authz[Authorization Service]
UserMgmt[User Management Service]
AppService[Application Service]
Audit[Audit Service]
end
subgraph "Support Services"
Cache[Cache Service]
Queue[Queue Service]
Backup[Backup Service]
end
Auth --> Cache
Auth --> Queue
Authz --> Cache
UserMgmt --> Audit
AppService --> Audit
```
### Core Services
1. **Authentication Service**
- 사용자 인증
- 토큰 발급/검증
- 세션 관리
2. **Authorization Service**
- 권한 확인
- 역할 기반 접근 제어 (RBAC)
- 리소스 접근 관리
3. **User Management Service**
- 사용자 CRUD
- 프로필 관리
- 패스워드 관리
4. **Application Service**
- 애플리케이션 등록/관리
- Client Credentials 관리
- 테마 설정 관리
5. **Audit Service**
- 접속 로그
- 인증 히스토리
- 보안 이벤트 추적
## 확장성 고려사항
### Horizontal Scaling
```mermaid
graph TB
LB[Load Balancer]
subgraph "Application Instances"
App1[App Instance 1]
App2[App Instance 2]
App3[App Instance 3]
end
subgraph "Shared State"
Redis[Redis Session Store]
MongoDB[MongoDB Cluster]
end
LB --> App1
LB --> App2
LB --> App3
App1 --> Redis
App1 --> MongoDB
App2 --> Redis
App2 --> MongoDB
App3 --> Redis
App3 --> MongoDB
```
### Database Sharding
- User ID 기반 샤딩
- Application ID 기반 샤딩
- 시간 기반 파티셔닝 (히스토리)
### Caching Strategy
- User Profile 캐싱
- Application Settings 캐싱
- Token 캐싱
## 보안 아키텍처
```mermaid
graph TB
subgraph "External"
Internet[Internet]
end
subgraph "DMZ"
WAF[WAF]
CDN[CDN]
end
subgraph "Public Subnet"
ALB[Application Load Balancer]
NAT[NAT Gateway]
end
subgraph "Private Subnet"
App[Application Servers]
Cache[Cache Layer]
end
subgraph "Data Subnet"
DB[(Database)]
Backup[(Backup Storage)]
end
Internet --> WAF
WAF --> CDN
CDN --> ALB
ALB --> App
App --> Cache
App --> NAT
App --> DB
DB --> Backup
```
### Network Security
- VPC 격리
- Security Groups
- Private Subnets
### Application Security
- Rate Limiting
- DDoS Protection
- WAF Rules
### Data Security
- Encryption at Rest
- Encryption in Transit
- Key Management Service (KMS)

24
oauth/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

18
oauth/frontend/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
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,10 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

69
oauth/frontend/README.md Normal file
View File

@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
oauth/frontend/index.html Normal file
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>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
oauth/frontend/nginx.conf Normal file
View File

@ -0,0 +1,29 @@
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://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;
}
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
gzip_disable "MSIE [1-6]\.";
}

4667
oauth/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@hookform/resolvers": "^5.2.1",
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@tanstack/react-query": "^5.85.6",
"axios": "^1.11.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.8.2",
"zod": "^4.1.5",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@radix-ui/react-slot": "^1.2.3",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -0,0 +1,74 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import LoginPage from './pages/LoginPage'
import SignupPage from './pages/SignupPage'
import Dashboard from './pages/Dashboard'
import Applications from './pages/Applications'
import Profile from './pages/Profile'
import AdminPanel from './pages/AdminPanel'
import AuthCallback from './pages/AuthCallback'
import AuthorizePage from './pages/AuthorizePage'
import { AuthProvider } from './contexts/AuthContext'
import ProtectedRoute from './components/ProtectedRoute'
import './App.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/oauth/authorize" element={<AuthorizePage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/applications"
element={
<ProtectedRoute>
<Applications />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<AdminPanel />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Router>
</AuthProvider>
</QueryClientProvider>
)
}
export default App

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,31 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
interface ProtectedRouteProps {
children: React.ReactNode
requireAdmin?: boolean
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requireAdmin = false }) => {
const { user, isLoading } = useAuth()
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
if (!user) {
return <Navigate to="/login" replace />
}
if (requireAdmin && user.role !== 'system_admin') {
return <Navigate to="/dashboard" replace />
}
return <>{children}</>
}
export default ProtectedRoute

View File

@ -0,0 +1,108 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface User {
id: string
email: string
name: string
role: 'system_admin' | 'group_admin' | 'user'
avatar?: string
}
interface AuthContextType {
user: User | null
isLoading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
checkAuth: () => Promise<void>
}
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)
const checkAuth = async () => {
try {
const token = localStorage.getItem('access_token')
if (!token) {
setIsLoading(false)
return
}
// TODO: Verify token with backend
const response = await fetch('/api/v1/users/me', {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (response.ok) {
const userData = await response.json()
setUser(userData)
} else {
localStorage.removeItem('access_token')
}
} catch (error) {
console.error('Auth check failed:', error)
} finally {
setIsLoading(false)
}
}
const login = async (email: string, password: string) => {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
username: email,
password,
}),
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
localStorage.setItem('access_token', data.access_token)
await checkAuth()
window.location.href = '/dashboard'
} catch (error) {
console.error('Login failed:', error)
throw error
}
}
const logout = () => {
localStorage.removeItem('access_token')
setUser(null)
window.location.href = '/login'
}
useEffect(() => {
checkAuth()
}, [])
return (
<AuthContext.Provider value={{ user, isLoading, login, logout, checkAuth }}>
{children}
</AuthContext.Provider>
)
}

View File

@ -0,0 +1,148 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--gradient-1: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-2: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-3: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--gradient-4: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
--gradient-dark: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
}
body {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Glass effect */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.glass-dark {
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Animations */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes pulse-slow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; }
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-pulse-slow {
animation: pulse-slow 4s ease-in-out infinite;
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient-shift 8s ease infinite;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Input focus effects */
.input-modern {
@apply relative;
}
.input-modern::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: linear-gradient(90deg, #667eea, #764ba2);
transition: width 0.3s ease;
}
.input-modern:focus-within::after {
width: 100%;
}
/* Button hover effects */
.btn-glow {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.btn-glow::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn-glow:hover::before {
width: 300px;
height: 300px;
}
/* Neumorphism effects */
.neu-shadow {
box-shadow:
12px 12px 24px rgba(0, 0, 0, 0.1),
-12px -12px 24px rgba(255, 255, 255, 0.1);
}
.neu-shadow-inset {
box-shadow:
inset 6px 6px 12px rgba(0, 0, 0, 0.1),
inset -6px -6px 12px rgba(255, 255, 255, 0.1);
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,10 @@
const AdminPanel = () => {
return (
<div className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-gray-600"> .</p>
</div>
)
}
export default AdminPanel

View File

@ -0,0 +1,10 @@
const Applications = () => {
return (
<div className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-gray-600">OAuth .</p>
</div>
)
}
export default Applications

View File

@ -0,0 +1,54 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
const AuthCallback = () => {
const navigate = useNavigate()
useEffect(() => {
// Handle OAuth callback
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
if (code) {
// Exchange code for token
handleOAuthCallback(code, state)
} else {
navigate('/login')
}
}, [navigate])
const handleOAuthCallback = async (code: string, state: string | null) => {
try {
const response = await fetch('/api/v1/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state }),
})
if (response.ok) {
const data = await response.json()
localStorage.setItem('access_token', data.access_token)
navigate('/dashboard')
} else {
navigate('/login')
}
} catch (error) {
console.error('OAuth callback error:', error)
navigate('/login')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600"> ...</p>
</div>
</div>
)
}
export default AuthCallback

View File

@ -0,0 +1,470 @@
import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Box,
Container,
Typography,
Button,
Paper,
Checkbox,
List,
ListItem,
ListItemIcon,
ListItemText,
Avatar,
Link,
IconButton,
Divider,
Chip,
Alert,
Stack
} from '@mui/material'
import {
AccountCircle,
Email,
CloudQueue,
CalendarMonth,
Description,
Settings,
Security,
Info,
ExpandMore,
ExpandLess,
CheckCircle,
Language,
HelpOutline
} from '@mui/icons-material'
interface Permission {
id: string
name: string
description: string
icon: React.ElementType
required: boolean
}
const AuthorizePage = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { user } = useAuth()
const [isLoading, setIsLoading] = useState(false)
const [appInfo, setAppInfo] = useState<any>(null)
const [selectedPermissions, setSelectedPermissions] = useState<Set<string>>(new Set())
const [showDetails, setShowDetails] = useState(false)
// OAuth parameters
const clientId = searchParams.get('client_id')
const redirectUri = searchParams.get('redirect_uri')
const responseType = searchParams.get('response_type')
const scope = searchParams.get('scope')
const state = searchParams.get('state')
const permissions: Permission[] = [
{
id: 'profile',
name: '프로필 정보',
description: '이름, 이메일, 프로필 사진 등 기본 정보',
icon: AccountCircle,
required: true
},
{
id: 'email',
name: '이메일 주소',
description: '이메일 주소 확인 및 알림 전송',
icon: Email,
required: true
},
{
id: 'offline_access',
name: '오프라인 액세스',
description: '사용자가 오프라인일 때도 액세스 유지',
icon: CloudQueue,
required: false
},
{
id: 'calendar',
name: '캘린더 접근',
description: '캘린더 일정 읽기 및 수정',
icon: CalendarMonth,
required: false
},
{
id: 'files',
name: '파일 접근',
description: '파일 읽기, 쓰기 및 공유',
icon: Description,
required: false
},
{
id: 'settings',
name: '설정 관리',
description: '애플리케이션 설정 읽기 및 수정',
icon: Settings,
required: false
}
]
useEffect(() => {
// Mock user for demo
if (!user) {
// In production, redirect to login
}
if (!clientId) {
navigate('/error?type=invalid_request')
return
}
// Fetch application information
fetchAppInfo(clientId)
// Parse requested scopes and set initial permissions
if (scope) {
const requestedScopes = scope.split(' ')
const initialPermissions = new Set<string>()
permissions.forEach(perm => {
if (perm.required || requestedScopes.includes(perm.id)) {
initialPermissions.add(perm.id)
}
})
setSelectedPermissions(initialPermissions)
}
}, [user, clientId, scope, navigate])
const fetchAppInfo = async (clientId: string) => {
try {
const response = await fetch(`/api/v1/applications/${clientId}`)
if (response.ok) {
const data = await response.json()
setAppInfo(data)
}
} catch (error) {
console.error('Failed to fetch app info:', error)
// Use default app info for demo
setAppInfo({
name: 'Project Default Service Account',
developer: 'AiMond Developer',
website: 'https://aimond.io',
verified: true
})
}
}
const handleAccept = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/v1/auth/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope: Array.from(selectedPermissions).join(' '),
state: state
})
})
if (response.ok) {
const data = await response.json()
const redirectUrl = new URL(redirectUri || '/')
redirectUrl.searchParams.append('code', data.code)
if (state) {
redirectUrl.searchParams.append('state', state)
}
window.location.href = redirectUrl.toString()
}
} catch (error) {
console.error('Authorization error:', error)
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
const redirectUrl = new URL(redirectUri || '/')
redirectUrl.searchParams.append('error', 'access_denied')
if (state) {
redirectUrl.searchParams.append('state', state)
}
window.location.href = redirectUrl.toString()
}
const togglePermission = (permId: string) => {
const permission = permissions.find(p => p.id === permId)
if (permission?.required) return
const newPermissions = new Set(selectedPermissions)
if (newPermissions.has(permId)) {
newPermissions.delete(permId)
} else {
newPermissions.add(permId)
}
setSelectedPermissions(newPermissions)
}
if (!appInfo) {
return (
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography>Loading...</Typography>
</Box>
)
}
return (
<Box sx={{ minHeight: '100vh', backgroundColor: '#fff', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Box sx={{ borderBottom: '1px solid #dadce0', py: 2, px: 3 }}>
<Container maxWidth="md">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6" sx={{ fontWeight: 500, color: '#202124' }}>
AiMond
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography sx={{ fontSize: '14px', color: '#5f6368' }}>
{user?.email || 'user@example.com'}
</Typography>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#1a73e8' }}>
{(user?.email || 'U')[0].toUpperCase()}
</Avatar>
</Box>
</Box>
</Container>
</Box>
{/* Main Content */}
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', py: 4 }}>
<Container maxWidth="sm">
<Paper
elevation={0}
sx={{
border: '1px solid #dadce0',
borderRadius: '8px',
overflow: 'hidden'
}}
>
{/* App Info Header */}
<Box sx={{ p: 4, textAlign: 'center', borderBottom: '1px solid #dadce0' }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 400, color: '#202124' }}>
{appInfo.name}
</Typography>
<Typography sx={{ color: '#5f6368', fontSize: '14px', mb: 2 }}>
:
</Typography>
</Box>
{/* Permissions List */}
<Box sx={{ p: 3 }}>
<List sx={{ py: 0 }}>
{permissions.map((permission, index) => {
const Icon = permission.icon
const isSelected = selectedPermissions.has(permission.id)
const isRequired = permission.required
return (
<React.Fragment key={permission.id}>
<ListItem
sx={{
py: 2,
px: 2,
borderRadius: '8px',
cursor: isRequired ? 'default' : 'pointer',
'&:hover': {
backgroundColor: isRequired ? 'transparent' : '#f8f9fa'
}
}}
onClick={() => !isRequired && togglePermission(permission.id)}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<Checkbox
checked={isSelected}
disabled={isRequired}
sx={{
color: '#5f6368',
'&.Mui-checked': {
color: '#1a73e8'
}
}}
/>
</ListItemIcon>
<ListItemIcon sx={{ minWidth: 40 }}>
<Icon sx={{ color: '#5f6368' }} />
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontSize: '14px', fontWeight: 500, color: '#202124' }}>
{permission.name}
</Typography>
{isRequired && (
<Chip
label="필수"
size="small"
sx={{
height: 20,
fontSize: '11px',
backgroundColor: '#e8f0fe',
color: '#1967d2'
}}
/>
)}
</Box>
}
secondary={
<Typography sx={{ fontSize: '12px', color: '#5f6368' }}>
{permission.description}
</Typography>
}
/>
</ListItem>
{index < permissions.length - 1 && <Divider sx={{ my: 0.5 }} />}
</React.Fragment>
)
})}
</List>
</Box>
{/* Info Alert */}
<Box sx={{ px: 3, pb: 3 }}>
<Alert
icon={<Info />}
severity="info"
sx={{
backgroundColor: '#e8f0fe',
color: '#1967d2',
'& .MuiAlert-icon': {
color: '#1967d2'
}
}}
>
<Typography sx={{ fontSize: '12px' }}>
, .
</Typography>
</Alert>
</Box>
{/* Action Buttons */}
<Box sx={{ p: 3, borderTop: '1px solid #dadce0', display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button
onClick={handleCancel}
variant="text"
sx={{
color: '#5f6368',
textTransform: 'none',
fontSize: '14px',
fontWeight: 500
}}
>
</Button>
<Button
onClick={handleAccept}
variant="contained"
disabled={isLoading || selectedPermissions.size === 0}
sx={{
backgroundColor: '#1a73e8',
textTransform: 'none',
fontSize: '14px',
fontWeight: 500,
px: 3,
'&:hover': {
backgroundColor: '#1666c9'
}
}}
>
{isLoading ? '처리 중...' : '허용'}
</Button>
</Box>
{/* Additional Details */}
<Box sx={{ borderTop: '1px solid #dadce0' }}>
<Button
fullWidth
onClick={() => setShowDetails(!showDetails)}
sx={{
py: 2,
color: '#5f6368',
textTransform: 'none',
fontSize: '12px',
justifyContent: 'center'
}}
endIcon={showDetails ? <ExpandLess /> : <ExpandMore />}
>
{showDetails ? '상세 정보 숨기기' : '상세 정보 보기'}
</Button>
{showDetails && (
<Box sx={{ px: 3, pb: 3, pt: 1 }}>
<Stack spacing={1}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: '12px', color: '#5f6368' }}>:</Typography>
<Typography sx={{ fontSize: '12px', color: '#202124', fontWeight: 500 }}>
{appInfo.developer}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: '12px', color: '#5f6368' }}>:</Typography>
<Link href={appInfo.website} sx={{ fontSize: '12px', color: '#1a73e8' }}>
{appInfo.website}
</Link>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontSize: '12px', color: '#5f6368' }}> :</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{appInfo.verified && <CheckCircle sx={{ fontSize: 14, color: '#34a853' }} />}
<Typography sx={{ fontSize: '12px', color: appInfo.verified ? '#34a853' : '#ea4335' }}>
{appInfo.verified ? '인증됨' : '미인증'}
</Typography>
</Box>
</Box>
</Stack>
</Box>
)}
</Box>
</Paper>
{/* Privacy Notice */}
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography sx={{ fontSize: '12px', color: '#5f6368' }}>
AiMond의{' '}
<Link href="#" sx={{ color: '#1a73e8' }}> </Link> {' '}
<Link href="#" sx={{ color: '#1a73e8' }}></Link> .
</Typography>
</Box>
</Container>
</Box>
{/* Footer */}
<Box sx={{ borderTop: '1px solid #dadce0', py: 2, px: 3, backgroundColor: '#f8f9fa' }}>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontSize: '12px', color: '#5f6368' }}></Typography>
<Stack direction="row" spacing={3}>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
</Stack>
</Box>
</Container>
</Box>
</Box>
)
}
export default AuthorizePage

View File

@ -0,0 +1,296 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Box,
Container,
Grid,
Card,
CardContent,
Typography,
Button,
AppBar,
Toolbar,
IconButton,
Avatar,
Menu,
MenuItem,
Drawer,
List,
ListItem,
ListItemIcon,
ListItemText,
ListItemButton,
Divider,
Paper,
LinearProgress,
Chip,
InputBase,
Badge
} from '@mui/material'
import {
Dashboard as DashboardIcon,
Apps,
Person,
AdminPanelSettings,
Menu as MenuIcon,
Logout,
Settings,
TrendingUp,
Security,
Speed,
AccessTime,
Notifications,
Search,
VpnKey,
Timeline
} from '@mui/icons-material'
const Dashboard = () => {
const [drawerOpen, setDrawerOpen] = useState(false)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { user, logout } = useAuth()
const navigate = useNavigate()
const handleLogout = async () => {
await logout()
navigate('/login')
}
const handleProfileMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleProfileMenuClose = () => {
setAnchorEl(null)
}
const menuItems = [
{ text: '대시보드', icon: <DashboardIcon />, path: '/dashboard' },
{ text: '애플리케이션', icon: <VpnKey />, path: '/applications' },
{ text: '프로필 설정', icon: <Settings />, path: '/profile' },
{ text: '관리자 패널', icon: <AdminPanelSettings />, path: '/admin', adminOnly: true },
]
const stats = [
{ title: '활성 세션', value: '24', icon: <Person />, change: '+12%', color: '#1a73e8' },
{ title: '등록된 앱', value: '8', icon: <VpnKey />, change: '+2', color: '#34a853' },
{ title: '이번 달 로그인', value: '1,429', icon: <Timeline />, change: '+48%', color: '#fbbc04' },
{ title: '평균 응답시간', value: '132ms', icon: <AccessTime />, change: '-12%', color: '#ea4335' },
]
const recentActivities = [
{ id: 1, action: '새로운 애플리케이션 등록', app: 'E-Commerce Platform', time: '5분 전' },
{ id: 2, action: '권한 업데이트', app: 'Mobile App', time: '2시간 전' },
{ id: 3, action: '토큰 갱신', app: 'Analytics Dashboard', time: '4시간 전' },
{ id: 4, action: '새로운 사용자 추가', app: 'CRM System', time: '1일 전' },
]
return (
<Box sx={{ display: 'flex', minHeight: '100vh', backgroundColor: '#f8f9fa' }}>
{/* AppBar */}
<AppBar position="fixed" elevation={0} sx={{ backgroundColor: '#fff', borderBottom: '1px solid #dadce0' }}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
onClick={() => setDrawerOpen(true)}
sx={{ mr: 2, color: '#5f6368' }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" sx={{ flexGrow: 0, color: '#202124', fontWeight: 500, mr: 4 }}>
OAuth System
</Typography>
{/* Search Bar */}
<Box sx={{
flexGrow: 1,
maxWidth: 600,
backgroundColor: '#f1f3f4',
borderRadius: 2,
px: 2,
display: 'flex',
alignItems: 'center'
}}>
<Search sx={{ color: '#5f6368', mr: 1 }} />
<InputBase
placeholder="검색..."
sx={{ flex: 1, py: 1 }}
/>
</Box>
<Box sx={{ flexGrow: 1 }} />
<IconButton sx={{ mr: 2 }}>
<Badge badgeContent={3} color="error">
<Notifications sx={{ color: '#5f6368' }} />
</Badge>
</IconButton>
<IconButton onClick={handleProfileMenuOpen}>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#1a73e8' }}>
{user?.name?.[0] || user?.email?.[0]?.toUpperCase() || 'U'}
</Avatar>
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleProfileMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem onClick={() => { handleProfileMenuClose(); navigate('/profile'); }}>
<ListItemIcon><Person fontSize="small" /></ListItemIcon>
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon><Logout fontSize="small" /></ListItemIcon>
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
{/* Drawer */}
<Drawer
anchor="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<Box sx={{ width: 250 }}>
<Box sx={{ p: 2, borderBottom: '1px solid #dadce0' }}>
<Typography variant="h6" sx={{ fontWeight: 500, color: '#202124' }}>
OAuth System
</Typography>
</Box>
<List>
{menuItems.map((item) => {
if (item.adminOnly && user?.role !== 'system_admin') return null
return (
<ListItemButton
key={item.text}
onClick={() => {
navigate(item.path)
setDrawerOpen(false)
}}
selected={location.pathname === item.path}
sx={{
'&.Mui-selected': {
backgroundColor: '#e8f0fe',
color: '#1967d2',
'& .MuiListItemIcon-root': {
color: '#1967d2',
}
}
}}
>
<ListItemIcon sx={{ color: '#5f6368' }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} sx={{ color: 'inherit' }} />
</ListItemButton>
)
})}
</List>
<Divider />
<List>
<ListItemButton onClick={handleLogout}>
<ListItemIcon sx={{ color: '#ea4335' }}><Logout /></ListItemIcon>
<ListItemText primary="로그아웃" sx={{ color: '#ea4335' }} />
</ListItemButton>
</List>
</Box>
</Drawer>
{/* Main Content */}
<Box component="main" sx={{ flexGrow: 1, mt: 8, p: 3 }}>
<Container maxWidth="xl">
{/* Page Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 1, fontWeight: 400, color: '#202124' }}>
</Typography>
<Typography sx={{ color: '#5f6368' }}>
OAuth
</Typography>
</Box>
{/* Stats Grid */}
<Grid container spacing={3} sx={{ mb: 4 }}>
{stats.map((stat) => (
<Grid item xs={12} sm={6} md={3} key={stat.title}>
<Card elevation={0} sx={{ border: '1px solid #dadce0' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{
p: 1,
borderRadius: 1,
backgroundColor: `${stat.color}20`,
color: stat.color
}}>
{stat.icon}
</Box>
<Chip
label={stat.change}
size="small"
sx={{
backgroundColor: stat.change.startsWith('+') ? '#e6f4ea' : '#fce8e6',
color: stat.change.startsWith('+') ? '#1e8e3e' : '#d33b27',
fontWeight: 500
}}
/>
</Box>
<Typography variant="h4" sx={{ fontWeight: 500, color: '#202124', mb: 0.5 }}>
{stat.value}
</Typography>
<Typography sx={{ color: '#5f6368', fontSize: '14px' }}>
{stat.title}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* Recent Activity */}
<Paper elevation={0} sx={{ border: '1px solid #dadce0', borderRadius: 2 }}>
<Box sx={{ p: 3, borderBottom: '1px solid #dadce0' }}>
<Typography variant="h6" sx={{ fontWeight: 500, color: '#202124' }}>
</Typography>
</Box>
<List>
{recentActivities.map((activity, index) => (
<React.Fragment key={activity.id}>
<ListItem sx={{ py: 2.5 }}>
<ListItemText
primary={
<Typography sx={{ fontWeight: 500, color: '#202124' }}>
{activity.action}
</Typography>
}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
<Typography variant="body2" sx={{ color: '#5f6368' }}>
{activity.app}
</Typography>
<Typography variant="caption" sx={{ color: '#5f6368' }}>
{activity.time}
</Typography>
</Box>
}
/>
</ListItem>
{index < recentActivities.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
</Paper>
</Container>
</Box>
</Box>
)
}
export default Dashboard

View File

@ -0,0 +1,307 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Box,
Container,
TextField,
Button,
Typography,
Link,
Paper,
IconButton,
InputAdornment,
Checkbox,
FormControlLabel,
Select,
MenuItem,
Avatar,
Stack,
Divider
} from '@mui/material'
import {
Visibility,
VisibilityOff,
Language,
HelpOutline,
KeyboardArrowDown
} from '@mui/icons-material'
const LoginPage = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [language, setLanguage] = useState('ko')
const { login, user } = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (user) {
navigate('/dashboard')
}
}, [user, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await login(email, password)
if (rememberMe) {
localStorage.setItem('last_user_email', email)
}
} catch (error) {
console.error('Login error:', error)
} finally {
setIsLoading(false)
}
}
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#fff'
}}
>
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
py: 4
}}
>
{/* Logo and Title */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography
variant="h4"
sx={{
fontWeight: 300,
color: '#202124',
mb: 2,
fontFamily: 'Google Sans, Roboto, Arial, sans-serif'
}}
>
<Box component="span" sx={{ fontWeight: 500 }}>AiMond</Box>
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 400,
color: '#202124',
mb: 1
}}
>
AiMond Account로
</Typography>
</Box>
{/* Login Form */}
<Paper
elevation={0}
sx={{
border: '1px solid #dadce0',
borderRadius: '8px',
p: 5,
maxWidth: 450,
width: '100%',
mx: 'auto'
}}
>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일"
required
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
'&:hover fieldset': {
borderColor: '#1976d2',
},
},
}}
size="medium"
autoComplete="email"
/>
<Link
href="#"
underline="none"
sx={{
color: '#1a73e8',
fontSize: '14px',
fontWeight: 500,
display: 'block',
mb: 3
}}
>
?
</Link>
<TextField
fullWidth
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
required
sx={{ mb: 1 }}
size="medium"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
size="small"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<FormControlLabel
control={
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
size="small"
sx={{ color: '#5f6368' }}
/>
}
label={
<Typography sx={{ fontSize: '14px', color: '#5f6368' }}>
</Typography>
}
sx={{ mb: 4 }}
/>
<Typography
sx={{
fontSize: '14px',
color: '#5f6368',
mb: 4,
lineHeight: 1.5
}}
>
AiMond AiMond
</Typography>
{/* Service Icons */}
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mb: 4 }}>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#4285f4', fontSize: 14 }}>A</Avatar>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#ea4335', fontSize: 14 }}>M</Avatar>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#34a853', fontSize: 14 }}>D</Avatar>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#fbbc04', fontSize: 14 }}>S</Avatar>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#9333ea', fontSize: 14 }}>C</Avatar>
</Stack>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Link
href="/signup"
underline="none"
sx={{
color: '#1a73e8',
fontSize: '14px',
fontWeight: 500
}}
>
</Link>
<Button
type="submit"
variant="contained"
disabled={isLoading}
sx={{
backgroundColor: '#1a73e8',
color: 'white',
textTransform: 'none',
px: 3,
py: 1,
fontSize: '14px',
fontWeight: 500,
'&:hover': {
backgroundColor: '#1666c9',
},
}}
>
</Button>
</Box>
</form>
</Paper>
{/* Language Selection Link */}
<Box sx={{ textAlign: 'center', mt: 3 }}>
<Link
href="#"
underline="none"
sx={{
color: '#1a73e8',
fontSize: '14px',
display: 'inline-flex',
alignItems: 'center',
gap: 0.5
}}
>
</Link>
</Box>
</Box>
</Container>
{/* Footer */}
<Box
sx={{
borderTop: '1px solid #dadce0',
py: 2,
px: 3,
backgroundColor: '#f8f9fa'
}}
>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Select
value={language}
onChange={(e) => setLanguage(e.target.value)}
variant="standard"
disableUnderline
sx={{ fontSize: '12px', color: '#5f6368' }}
>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
</Select>
<Stack direction="row" spacing={3}>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
</Stack>
</Box>
</Container>
</Box>
</Box>
)
}
export default LoginPage

View File

@ -0,0 +1,10 @@
const Profile = () => {
return (
<div className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-gray-600"> .</p>
</div>
)
}
export default Profile

View File

@ -0,0 +1,415 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Container,
TextField,
Button,
Typography,
Link,
Paper,
IconButton,
InputAdornment,
Checkbox,
FormControlLabel,
LinearProgress,
Alert,
Stack
} from '@mui/material'
import {
Visibility,
VisibilityOff,
Person,
Email,
Lock,
Business,
ArrowForward
} from '@mui/icons-material'
const SignupPage = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
organization: '',
agreeTerms: false
})
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [passwordStrength, setPasswordStrength] = useState(0)
const navigate = useNavigate()
const checkPasswordStrength = (password: string) => {
let strength = 0
if (password.length >= 8) strength++
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++
if (password.match(/[0-9]/)) strength++
if (password.match(/[^a-zA-Z0-9]/)) strength++
setPasswordStrength(strength)
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target
const newValue = type === 'checkbox' ? checked : value
setFormData(prev => ({
...prev,
[name]: newValue
}))
if (name === 'password') {
checkPasswordStrength(value)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (formData.password !== formData.confirmPassword) {
alert('비밀번호가 일치하지 않습니다.')
return
}
setIsLoading(true)
try {
const response = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password,
organization: formData.organization
}),
})
if (response.ok) {
navigate('/login')
}
} catch (error) {
console.error('Signup error:', error)
} finally {
setIsLoading(false)
}
}
const getPasswordStrengthColor = () => {
switch(passwordStrength) {
case 0: return '#bdbdbd'
case 1: return '#f44336'
case 2: return '#ff9800'
case 3: return '#2196f3'
case 4: return '#4caf50'
default: return '#bdbdbd'
}
}
const getPasswordStrengthText = () => {
switch(passwordStrength) {
case 0: return ''
case 1: return '약함'
case 2: return '보통'
case 3: return '강함'
case 4: return '매우 강함'
default: return ''
}
}
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#fff'
}}
>
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
py: 4
}}
>
{/* Logo and Title */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography
variant="h4"
sx={{
fontWeight: 300,
color: '#202124',
mb: 2,
fontFamily: 'Google Sans, Roboto, Arial, sans-serif'
}}
>
<Box component="span" sx={{ fontWeight: 500 }}>AiMond</Box>
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 400,
color: '#202124',
mb: 1
}}
>
AiMond
</Typography>
<Typography sx={{ color: '#5f6368', fontSize: '16px' }}>
AiMond로
</Typography>
</Box>
{/* Signup Form */}
<Paper
elevation={0}
sx={{
border: '1px solid #dadce0',
borderRadius: '8px',
p: 5,
maxWidth: 450,
width: '100%',
mx: 'auto'
}}
>
<form onSubmit={handleSubmit}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="이름"
required
fullWidth
size="medium"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Person sx={{ color: '#5f6368' }} />
</InputAdornment>
),
}}
/>
</Box>
<TextField
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
placeholder="이메일 주소"
required
fullWidth
size="medium"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Email sx={{ color: '#5f6368' }} />
</InputAdornment>
),
}}
/>
<TextField
name="organization"
value={formData.organization}
onChange={handleInputChange}
placeholder="조직/회사 (선택)"
fullWidth
size="medium"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Business sx={{ color: '#5f6368' }} />
</InputAdornment>
),
}}
/>
<TextField
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange}
placeholder="비밀번호"
required
fullWidth
size="medium"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock sx={{ color: '#5f6368' }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
size="small"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{formData.password && (
<Box>
<LinearProgress
variant="determinate"
value={passwordStrength * 25}
sx={{
height: 6,
borderRadius: 3,
backgroundColor: '#e0e0e0',
'& .MuiLinearProgress-bar': {
backgroundColor: getPasswordStrengthColor(),
}
}}
/>
<Typography variant="caption" sx={{ color: '#5f6368', mt: 0.5 }}>
: {getPasswordStrengthText()}
</Typography>
</Box>
)}
<TextField
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="비밀번호 확인"
required
fullWidth
size="medium"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock sx={{ color: '#5f6368' }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
size="small"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography variant="caption" sx={{ color: '#5f6368', lineHeight: 1.5 }}>
, , 8
</Typography>
<FormControlLabel
control={
<Checkbox
name="agreeTerms"
checked={formData.agreeTerms}
onChange={handleInputChange}
size="small"
sx={{ color: '#5f6368' }}
required
/>
}
label={
<Typography sx={{ fontSize: '14px', color: '#5f6368' }}>
<Link href="#" sx={{ color: '#1a73e8' }}> </Link> {' '}
<Link href="#" sx={{ color: '#1a73e8' }}></Link>
</Typography>
}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 3 }}>
<Link
href="/login"
underline="none"
sx={{
color: '#1a73e8',
fontSize: '14px',
fontWeight: 500
}}
>
</Link>
<Button
type="submit"
variant="contained"
disabled={isLoading || !formData.agreeTerms}
endIcon={<ArrowForward />}
sx={{
backgroundColor: '#1a73e8',
color: 'white',
textTransform: 'none',
px: 3,
py: 1,
fontSize: '14px',
fontWeight: 500,
'&:hover': {
backgroundColor: '#1666c9',
},
}}
>
{isLoading ? '계정 생성 중...' : '다음'}
</Button>
</Box>
</Stack>
</form>
</Paper>
{/* Footer Text */}
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Typography sx={{ fontSize: '12px', color: '#5f6368' }}>
AiMond AiMond
</Typography>
</Box>
</Box>
</Container>
{/* Footer */}
<Box
sx={{
borderTop: '1px solid #dadce0',
py: 2,
px: 3,
backgroundColor: '#f8f9fa'
}}
>
<Container maxWidth="lg">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontSize: '12px', color: '#5f6368' }}></Typography>
<Stack direction="row" spacing={3}>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
<Link href="#" underline="none" sx={{ fontSize: '12px', color: '#5f6368' }}>
</Link>
</Stack>
</Box>
</Container>
</Box>
</Box>
)
}
export default SignupPage

1
oauth/frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

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

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
ref/image19.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
ref/image20.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

59
src/index.css Normal file
View File

@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

74
tailwind.config.js Normal file
View File

@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
}

25
tsconfig.json Normal file
View File

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

21
vite.config.ts Normal file
View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})