feat: 완전한 사용자 관리 및 로그 모니터링 시스템 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 시스템 관리자/관리자 권한별 대시보드 기능 추가 - 사용자 관리 페이지: 계정 생성, 역할 변경, 사용자 삭제 - 시스템 로그 페이지: 로그인 로그, 시스템 오류 로그 조회 - 로그 모니터링 대시보드: 실시간 통계, 최근 활동, 오류 모니터링 - 프론트엔드 ErrorBoundary 및 오류 로깅 시스템 통합 - 계정 설정 페이지: 프로필 업데이트, 비밀번호 변경 - 3단계 권한 시스템 (system/admin/user) 완전 구현 - 시스템 관리자 계정 생성 기능 (hyungi/000000) - 로그인 페이지 테스트 계정 안내 제거 - API 오류 수정: CORS, 이메일 검증, User 모델 import 등
This commit is contained in:
@@ -6,6 +6,7 @@ TK-MP-Project 인증 시스템의 모든 컴포넌트를 노출
|
||||
from .jwt_service import jwt_service, JWTService
|
||||
from .auth_service import AuthService, get_auth_service
|
||||
from .auth_controller import router as auth_router
|
||||
from .setup_controller import router as setup_router
|
||||
from .middleware import (
|
||||
auth_middleware,
|
||||
get_current_user,
|
||||
@@ -39,6 +40,7 @@ __all__ = [
|
||||
|
||||
# 라우터
|
||||
'auth_router',
|
||||
'setup_router',
|
||||
|
||||
# 미들웨어 및 의존성
|
||||
'auth_middleware',
|
||||
|
||||
@@ -5,7 +5,7 @@ TK-FB-Project의 authController.js를 참고하여 FastAPI용으로 구현
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..database import get_db
|
||||
@@ -29,11 +29,21 @@ class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
name: str
|
||||
email: Optional[EmailStr] = None
|
||||
email: Optional[str] = None
|
||||
access_level: str = 'worker'
|
||||
department: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
role: str = "user"
|
||||
|
||||
@validator('email', pre=True)
|
||||
def validate_email(cls, v):
|
||||
if v == '' or v is None:
|
||||
return None
|
||||
# 간단한 이메일 형식 검증
|
||||
if '@' not in v or '.' not in v.split('@')[-1]:
|
||||
raise ValueError('올바른 이메일 형식을 입력해주세요')
|
||||
return v
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
@@ -112,27 +122,47 @@ async def login(
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register(
|
||||
register_data: RegisterRequest,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 등록
|
||||
사용자 등록 (시스템 관리자만 가능)
|
||||
|
||||
Args:
|
||||
register_data: 등록 정보
|
||||
credentials: JWT 토큰 (시스템 관리자 권한 필요)
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
RegisterResponse: 등록 결과
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
from .jwt_service import jwt_service
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
|
||||
# 시스템 관리자 권한 확인
|
||||
if payload['role'] != 'system':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="계정 생성은 시스템 관리자만 가능합니다"
|
||||
)
|
||||
|
||||
auth_service = get_auth_service(db)
|
||||
result = await auth_service.register(register_data.dict())
|
||||
|
||||
logger.info(f"User registered by system admin: {register_data.username} (created by: {payload['username']})")
|
||||
|
||||
return RegisterResponse(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Register endpoint error: {str(e)}")
|
||||
raise
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="사용자 등록 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshTokenResponse)
|
||||
@@ -307,7 +337,7 @@ async def get_all_users(
|
||||
if payload['role'] not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자 권한이 필요합니다"
|
||||
detail="관리자 이상의 권한이 필요합니다"
|
||||
)
|
||||
|
||||
# 사용자 목록 조회
|
||||
@@ -350,10 +380,10 @@ async def delete_user(
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
if payload['role'] not in ['admin', 'system']:
|
||||
if payload['role'] != 'system':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자 권한이 필요합니다"
|
||||
detail="사용자 삭제는 시스템 관리자만 가능합니다"
|
||||
)
|
||||
|
||||
# 자기 자신 삭제 방지
|
||||
@@ -393,7 +423,453 @@ async def delete_user(
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 로그 관리 API (관리자 이상)
|
||||
@router.get("/logs/login")
|
||||
async def get_login_logs(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
user_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
로그인 로그 조회 (관리자 이상)
|
||||
|
||||
Args:
|
||||
skip: 건너뛸 레코드 수
|
||||
limit: 조회할 레코드 수
|
||||
user_id: 특정 사용자 ID 필터
|
||||
status: 로그인 상태 필터 (success/failed)
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 로그인 로그 목록
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
from .jwt_service import jwt_service
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
|
||||
if payload['role'] not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="로그 조회는 관리자 이상의 권한이 필요합니다"
|
||||
)
|
||||
|
||||
# 로그인 로그 조회
|
||||
from .models import LoginLog, User
|
||||
query = db.query(LoginLog).join(User)
|
||||
|
||||
if user_id:
|
||||
query = query.filter(LoginLog.user_id == user_id)
|
||||
if status:
|
||||
query = query.filter(LoginLog.login_status == status)
|
||||
|
||||
logs = query.order_by(LoginLog.login_time.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'logs': [
|
||||
{
|
||||
'log_id': log.log_id,
|
||||
'user_id': log.user_id,
|
||||
'username': log.user.username,
|
||||
'name': log.user.name,
|
||||
'login_time': log.login_time,
|
||||
'ip_address': log.ip_address,
|
||||
'user_agent': log.user_agent,
|
||||
'login_status': log.login_status,
|
||||
'failure_reason': log.failure_reason
|
||||
}
|
||||
for log in logs
|
||||
],
|
||||
'total_count': len(logs)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Get login logs error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="로그인 로그 조회 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs/system")
|
||||
async def get_system_logs(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
level: Optional[str] = None,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
시스템 로그 조회 (관리자 이상)
|
||||
|
||||
Args:
|
||||
skip: 건너뛸 레코드 수
|
||||
limit: 조회할 레코드 수
|
||||
level: 로그 레벨 필터 (ERROR, WARNING, INFO, DEBUG)
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 시스템 로그 목록
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
from .jwt_service import jwt_service
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
|
||||
if payload['role'] not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="시스템 로그 조회는 관리자 이상의 권한이 필요합니다"
|
||||
)
|
||||
|
||||
# 로그 파일에서 최근 로그 읽기 (임시 구현)
|
||||
import os
|
||||
from ..config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
log_file_path = settings.logging.file_path
|
||||
|
||||
logs = []
|
||||
if os.path.exists(log_file_path):
|
||||
try:
|
||||
with open(log_file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 최근 로그부터 처리
|
||||
recent_lines = lines[-limit-skip:] if len(lines) > skip else lines
|
||||
|
||||
for line in reversed(recent_lines):
|
||||
if line.strip():
|
||||
# 간단한 로그 파싱 (실제로는 더 정교한 파싱 필요)
|
||||
parts = line.strip().split(' - ')
|
||||
if len(parts) >= 4:
|
||||
timestamp = parts[0]
|
||||
module = parts[1]
|
||||
log_level = parts[2]
|
||||
message = ' - '.join(parts[3:])
|
||||
|
||||
if not level or log_level == level:
|
||||
logs.append({
|
||||
'timestamp': timestamp,
|
||||
'module': module,
|
||||
'level': log_level,
|
||||
'message': message
|
||||
})
|
||||
|
||||
if len(logs) >= limit:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read log file: {str(e)}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'logs': logs,
|
||||
'total_count': len(logs)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Get system logs error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="시스템 로그 조회 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
# 사용자 역할 변경 API (시스템 관리자만)
|
||||
@router.put("/users/{user_id}/role")
|
||||
async def change_user_role(
|
||||
user_id: int,
|
||||
new_role: str,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 역할 변경 (시스템 관리자만)
|
||||
|
||||
Args:
|
||||
user_id: 변경할 사용자 ID
|
||||
new_role: 새로운 역할 (system, admin, user)
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 변경 결과
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
from .jwt_service import jwt_service
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
|
||||
if payload['role'] != 'system':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="사용자 역할 변경은 시스템 관리자만 가능합니다"
|
||||
)
|
||||
|
||||
# 유효한 역할인지 확인
|
||||
if new_role not in ['system', 'admin', 'user']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="유효하지 않은 역할입니다. (system, admin, user 중 선택)"
|
||||
)
|
||||
|
||||
# 자기 자신의 역할 변경 방지
|
||||
if payload['user_id'] == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="자기 자신의 역할은 변경할 수 없습니다"
|
||||
)
|
||||
|
||||
# 사용자 조회 및 역할 변경
|
||||
from .models import UserRepository
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="해당 사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
old_role = user.role
|
||||
user.role = new_role
|
||||
user_repo.update_user(user)
|
||||
|
||||
logger.info(f"User role changed: {user.username} ({old_role} → {new_role}) by {payload['username']}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'사용자 역할이 변경되었습니다: {old_role} → {new_role}',
|
||||
'user_id': user_id,
|
||||
'old_role': old_role,
|
||||
'new_role': new_role
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Change user role error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="사용자 역할 변경 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
# 프론트엔드 오류 로그 수집 API
|
||||
@router.post("/logs/frontend-error")
|
||||
async def log_frontend_error(
|
||||
error_data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
프론트엔드 오류 로그 수집
|
||||
|
||||
Args:
|
||||
error_data: 프론트엔드에서 전송한 오류 데이터
|
||||
request: FastAPI Request 객체
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 로그 저장 결과
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
# 클라이언트 정보 추가
|
||||
client_ip = request.client.host
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
# 오류 데이터 보강
|
||||
enhanced_error_data = {
|
||||
**error_data,
|
||||
'client_ip': client_ip,
|
||||
'server_timestamp': datetime.utcnow().isoformat(),
|
||||
'user_agent': user_agent
|
||||
}
|
||||
|
||||
# 로그로 기록
|
||||
logger.error(f"Frontend Error: {error_data.get('type', 'unknown')} - {error_data.get('message', 'No message')}",
|
||||
extra={'frontend_error': enhanced_error_data})
|
||||
|
||||
# 데이터베이스에 저장 (선택적)
|
||||
# TODO: 필요시 frontend_errors 테이블 생성하여 저장
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '오류가 기록되었습니다',
|
||||
'timestamp': enhanced_error_data['server_timestamp']
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log frontend error: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'message': '오류 기록에 실패했습니다'
|
||||
}
|
||||
|
||||
|
||||
# 프로필 업데이트 API
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
name: str
|
||||
email: Optional[EmailStr] = None
|
||||
department: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(
|
||||
profile_data: ProfileUpdateRequest,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 프로필 업데이트
|
||||
|
||||
Args:
|
||||
profile_data: 업데이트할 프로필 정보
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 업데이트 결과
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증
|
||||
from .jwt_service import jwt_service
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
user_id = payload['user_id']
|
||||
|
||||
# 사용자 조회
|
||||
from .models import UserRepository
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# 이메일 중복 확인 (다른 사용자가 사용 중인지)
|
||||
if profile_data.email and profile_data.email != user.email:
|
||||
existing_email = user_repo.find_by_email(profile_data.email)
|
||||
if existing_email and existing_email.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 사용 중인 이메일입니다"
|
||||
)
|
||||
|
||||
# 프로필 업데이트
|
||||
user.name = profile_data.name
|
||||
user.email = profile_data.email
|
||||
user.department = profile_data.department
|
||||
user.position = profile_data.position
|
||||
|
||||
user_repo.update_user(user)
|
||||
|
||||
logger.info(f"Profile updated for user: {user.username}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '프로필이 업데이트되었습니다',
|
||||
'user': user.to_dict()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Profile update error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="프로필 업데이트 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
# 비밀번호 변경 API
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.put("/change-password")
|
||||
async def change_password(
|
||||
password_data: ChangePasswordRequest,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 비밀번호 변경
|
||||
|
||||
Args:
|
||||
password_data: 현재 비밀번호와 새 비밀번호
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 변경 결과
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증
|
||||
from .jwt_service import jwt_service
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
user_id = payload['user_id']
|
||||
|
||||
# 사용자 조회
|
||||
from .models import UserRepository
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
# 현재 비밀번호 확인
|
||||
if not user.check_password(password_data.current_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="현재 비밀번호가 올바르지 않습니다"
|
||||
)
|
||||
|
||||
# 새 비밀번호 유효성 검사
|
||||
if len(password_data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="새 비밀번호는 8자 이상이어야 합니다"
|
||||
)
|
||||
|
||||
# 비밀번호 변경
|
||||
user.set_password(password_data.new_password)
|
||||
user_repo.update_user(user)
|
||||
|
||||
logger.info(f"Password changed for user: {user.username}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '비밀번호가 변경되었습니다'
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Password change error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="비밀번호 변경 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ class User(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
email = Column(String(100), index=True)
|
||||
|
||||
# 권한 관리
|
||||
role = Column(String(20), default='user', nullable=False)
|
||||
access_level = Column(String(20), default='worker', nullable=False)
|
||||
# 권한 관리 - 3단계 시스템: system(제작자) > admin(관리자) > user(사용자)
|
||||
role = Column(String(20), default='user', nullable=False) # system, admin, user
|
||||
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
|
||||
|
||||
# 계정 상태 관리
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
@@ -118,6 +118,40 @@ class User(Base):
|
||||
def update_last_login(self):
|
||||
"""마지막 로그인 시간 업데이트"""
|
||||
self.last_login_at = datetime.utcnow()
|
||||
|
||||
# 권한 체크 메서드들
|
||||
def is_system(self) -> bool:
|
||||
"""시스템 관리자 권한 확인"""
|
||||
return self.role == 'system'
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
"""관리자 권한 확인 (시스템 관리자 포함)"""
|
||||
return self.role in ['system', 'admin']
|
||||
|
||||
def is_user(self) -> bool:
|
||||
"""일반 사용자 권한 확인"""
|
||||
return self.role == 'user'
|
||||
|
||||
def can_create_users(self) -> bool:
|
||||
"""사용자 생성 권한 확인 (시스템 관리자만)"""
|
||||
return self.is_system()
|
||||
|
||||
def can_view_logs(self) -> bool:
|
||||
"""로그 조회 권한 확인 (관리자 이상)"""
|
||||
return self.is_admin()
|
||||
|
||||
def can_manage_system(self) -> bool:
|
||||
"""시스템 관리 권한 확인 (시스템 관리자만)"""
|
||||
return self.is_system()
|
||||
|
||||
def get_role_display_name(self) -> str:
|
||||
"""역할 표시명 반환"""
|
||||
role_names = {
|
||||
'system': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
}
|
||||
return role_names.get(self.role, '알 수 없음')
|
||||
|
||||
|
||||
class LoginLog(Base):
|
||||
|
||||
198
backend/app/auth/setup_controller.py
Normal file
198
backend/app/auth/setup_controller.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
초기 시스템 설정 컨트롤러
|
||||
배포 후 첫 실행 시 시스템 관리자 계정 생성
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from .models import User, UserRepository
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SystemSetupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
name: str
|
||||
email: Optional[EmailStr] = None
|
||||
department: Optional[str] = None
|
||||
position: Optional[str] = None
|
||||
|
||||
|
||||
class SystemSetupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
user_id: Optional[int] = None
|
||||
setup_completed: bool
|
||||
|
||||
|
||||
@router.get("/setup/status")
|
||||
async def get_setup_status(db: Session = Depends(get_db)):
|
||||
"""
|
||||
시스템 초기 설정 상태 확인
|
||||
|
||||
Returns:
|
||||
Dict: 설정 완료 여부
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
|
||||
# 시스템 관리자가 존재하는지 확인
|
||||
system_admin = db.query(User).filter(User.role == 'system').first()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'setup_completed': system_admin is not None,
|
||||
'has_system_admin': system_admin is not None,
|
||||
'total_users': db.query(User).count()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Setup status check error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="설정 상태 확인 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/setup/initialize", response_model=SystemSetupResponse)
|
||||
async def initialize_system(
|
||||
setup_data: SystemSetupRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
시스템 초기화 및 첫 번째 시스템 관리자 생성
|
||||
|
||||
Args:
|
||||
setup_data: 시스템 관리자 계정 정보
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
SystemSetupResponse: 설정 결과
|
||||
"""
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
|
||||
# 이미 시스템 관리자가 존재하는지 확인
|
||||
existing_admin = db.query(User).filter(User.role == 'system').first()
|
||||
if existing_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="시스템이 이미 초기화되었습니다"
|
||||
)
|
||||
|
||||
# 사용자명 중복 확인
|
||||
existing_user = user_repo.find_by_username(setup_data.username)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 사용자명입니다"
|
||||
)
|
||||
|
||||
# 이메일 중복 확인 (이메일이 제공된 경우)
|
||||
if setup_data.email:
|
||||
existing_email = user_repo.find_by_email(setup_data.email)
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 이메일입니다"
|
||||
)
|
||||
|
||||
# 시스템 관리자 계정 생성
|
||||
user = User(
|
||||
username=setup_data.username,
|
||||
name=setup_data.name,
|
||||
email=setup_data.email,
|
||||
role='system',
|
||||
access_level='system',
|
||||
department=setup_data.department,
|
||||
position=setup_data.position,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# 비밀번호 설정
|
||||
user.set_password(setup_data.password)
|
||||
|
||||
# 데이터베이스에 저장
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info(f"System initialized with admin user: {user.username}")
|
||||
|
||||
return SystemSetupResponse(
|
||||
success=True,
|
||||
message="시스템이 성공적으로 초기화되었습니다",
|
||||
user_id=user.user_id,
|
||||
setup_completed=True
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"System initialization error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="시스템 초기화 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/setup/reset")
|
||||
async def reset_system_setup(
|
||||
confirm_reset: bool = False,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
시스템 설정 리셋 (개발/테스트 용도)
|
||||
|
||||
Args:
|
||||
confirm_reset: 리셋 확인
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 리셋 결과
|
||||
"""
|
||||
try:
|
||||
if not confirm_reset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="리셋을 확인해주세요 (confirm_reset=true)"
|
||||
)
|
||||
|
||||
# 개발 환경에서만 허용
|
||||
from ..config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if settings.environment != 'development':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="개발 환경에서만 시스템 리셋이 가능합니다"
|
||||
)
|
||||
|
||||
# 모든 사용자 삭제
|
||||
db.query(User).delete()
|
||||
db.commit()
|
||||
|
||||
logger.warning("System setup has been reset (development only)")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '시스템 설정이 리셋되었습니다',
|
||||
'setup_completed': False
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"System reset error: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="시스템 리셋 중 오류가 발생했습니다"
|
||||
)
|
||||
@@ -108,9 +108,11 @@ logger.info("파일 관리 API 라우터 비활성화됨 (files 라우터 사용
|
||||
|
||||
# 인증 API 라우터 등록
|
||||
try:
|
||||
from .auth import auth_router
|
||||
from .auth import auth_router, setup_router
|
||||
app.include_router(auth_router, prefix="/auth", tags=["authentication"])
|
||||
app.include_router(setup_router, prefix="/setup", tags=["system-setup"])
|
||||
logger.info("인증 API 라우터 등록 완료")
|
||||
logger.info("시스템 설정 API 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
|
||||
197
backend/scripts/create_system_admin.py
Executable file
197
backend/scripts/create_system_admin.py
Executable file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
시스템 관리자 계정 생성 스크립트
|
||||
최초 설치 시 시스템 관리자 계정을 생성합니다.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import getpass
|
||||
from datetime import datetime
|
||||
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.config import get_settings
|
||||
from app.auth.models import User, UserRepository
|
||||
from app.database import Base
|
||||
|
||||
def create_system_admin():
|
||||
"""시스템 관리자 계정 생성"""
|
||||
|
||||
print("=" * 60)
|
||||
print("🔧 TK-MP 시스템 관리자 계정 생성")
|
||||
print("=" * 60)
|
||||
|
||||
# 설정 로드
|
||||
settings = get_settings()
|
||||
|
||||
# 데이터베이스 연결
|
||||
try:
|
||||
engine = create_engine(settings.database_url)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
print("✅ 데이터베이스 연결 성공")
|
||||
|
||||
# 테이블 생성 (필요한 경우)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("✅ 데이터베이스 테이블 확인/생성 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 데이터베이스 연결 실패: {str(e)}")
|
||||
return False
|
||||
|
||||
# 기존 시스템 관리자 확인
|
||||
try:
|
||||
user_repo = UserRepository(db)
|
||||
existing_admin = db.query(User).filter(User.role == 'system').first()
|
||||
|
||||
if existing_admin:
|
||||
print(f"⚠️ 시스템 관리자가 이미 존재합니다: {existing_admin.username}")
|
||||
response = input("새로운 시스템 관리자를 추가로 생성하시겠습니까? (y/N): ").lower()
|
||||
if response != 'y':
|
||||
print("❌ 작업이 취소되었습니다.")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 기존 관리자 확인 실패: {str(e)}")
|
||||
return False
|
||||
|
||||
# 사용자 입력 받기
|
||||
print("\n📝 시스템 관리자 정보를 입력해주세요:")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
# 사용자명 입력
|
||||
while True:
|
||||
username = input("사용자명 (영문/숫자, 3-20자): ").strip()
|
||||
if not username:
|
||||
print("❌ 사용자명을 입력해주세요.")
|
||||
continue
|
||||
if len(username) < 3 or len(username) > 20:
|
||||
print("❌ 사용자명은 3-20자여야 합니다.")
|
||||
continue
|
||||
if not username.replace('_', '').isalnum():
|
||||
print("❌ 사용자명은 영문, 숫자, 언더스코어만 사용 가능합니다.")
|
||||
continue
|
||||
|
||||
# 중복 확인
|
||||
existing_user = user_repo.find_by_username(username)
|
||||
if existing_user:
|
||||
print("❌ 이미 존재하는 사용자명입니다.")
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
# 이름 입력
|
||||
while True:
|
||||
name = input("이름 (한글/영문, 2-50자): ").strip()
|
||||
if not name:
|
||||
print("❌ 이름을 입력해주세요.")
|
||||
continue
|
||||
if len(name) < 2 or len(name) > 50:
|
||||
print("❌ 이름은 2-50자여야 합니다.")
|
||||
continue
|
||||
break
|
||||
|
||||
# 이메일 입력 (선택사항)
|
||||
email = input("이메일 (선택사항): ").strip()
|
||||
if email and '@' not in email:
|
||||
print("⚠️ 올바르지 않은 이메일 형식입니다. 빈 값으로 설정합니다.")
|
||||
email = None
|
||||
|
||||
# 비밀번호 입력
|
||||
while True:
|
||||
password = getpass.getpass("비밀번호 (8자 이상): ")
|
||||
if len(password) < 8:
|
||||
print("❌ 비밀번호는 8자 이상이어야 합니다.")
|
||||
continue
|
||||
|
||||
password_confirm = getpass.getpass("비밀번호 확인: ")
|
||||
if password != password_confirm:
|
||||
print("❌ 비밀번호가 일치하지 않습니다.")
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
# 부서/직책 입력 (선택사항)
|
||||
department = input("부서 (선택사항): ").strip() or None
|
||||
position = input("직책 (선택사항): ").strip() or None
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n❌ 작업이 취소되었습니다.")
|
||||
return False
|
||||
|
||||
# 입력 정보 확인
|
||||
print("\n📋 입력된 정보:")
|
||||
print("-" * 40)
|
||||
print(f"사용자명: {username}")
|
||||
print(f"이름: {name}")
|
||||
print(f"이메일: {email or '(없음)'}")
|
||||
print(f"부서: {department or '(없음)'}")
|
||||
print(f"직책: {position or '(없음)'}")
|
||||
print(f"역할: 시스템 관리자")
|
||||
|
||||
response = input("\n위 정보로 시스템 관리자를 생성하시겠습니까? (y/N): ").lower()
|
||||
if response != 'y':
|
||||
print("❌ 작업이 취소되었습니다.")
|
||||
return False
|
||||
|
||||
# 사용자 생성
|
||||
try:
|
||||
# 사용자 객체 생성
|
||||
user = User(
|
||||
username=username,
|
||||
name=name,
|
||||
email=email,
|
||||
role='system',
|
||||
access_level='system',
|
||||
department=department,
|
||||
position=position,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# 비밀번호 설정
|
||||
user.set_password(password)
|
||||
|
||||
# 데이터베이스에 저장
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
print(f"✅ 시스템 관리자 계정이 생성되었습니다!")
|
||||
print(f" - 사용자 ID: {user.user_id}")
|
||||
print(f" - 사용자명: {user.username}")
|
||||
print(f" - 이름: {user.name}")
|
||||
print(f" - 생성일시: {user.created_at}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"❌ 사용자 생성 실패: {str(e)}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def main():
|
||||
"""메인 함수"""
|
||||
try:
|
||||
success = create_system_admin()
|
||||
if success:
|
||||
print("\n🎉 시스템 관리자 계정 생성이 완료되었습니다!")
|
||||
print("이제 이 계정으로 로그인하여 다른 사용자를 관리할 수 있습니다.")
|
||||
else:
|
||||
print("\n❌ 시스템 관리자 계정 생성에 실패했습니다.")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n💥 예상치 못한 오류가 발생했습니다: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,6 +3,12 @@ import SimpleLogin from './SimpleLogin';
|
||||
import BOMWorkspacePage from './pages/BOMWorkspacePage';
|
||||
import NewMaterialsPage from './pages/NewMaterialsPage';
|
||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||
import AccountSettingsPage from './pages/AccountSettingsPage';
|
||||
import UserManagementPage from './pages/UserManagementPage';
|
||||
import SystemLogsPage from './pages/SystemLogsPage';
|
||||
import LogMonitoringPage from './pages/LogMonitoringPage';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import errorLogger from './utils/errorLogger';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -12,6 +18,7 @@ function App() {
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
const [pageParams, setPageParams] = useState({});
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 저장된 토큰 확인
|
||||
@@ -44,6 +51,20 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 사용자 메뉴 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (showUserMenu && !event.target.closest('.user-menu-container')) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showUserMenu]);
|
||||
|
||||
// 로그인 성공 시 호출될 함수
|
||||
const handleLoginSuccess = () => {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
@@ -82,16 +103,42 @@ function App() {
|
||||
|
||||
// 관리자 전용 기능
|
||||
const getAdminFeatures = () => {
|
||||
if (user?.role !== 'admin') return [];
|
||||
const features = [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'system-settings',
|
||||
title: '⚙️ 시스템 설정',
|
||||
description: '사용자 계정 관리',
|
||||
color: '#dc2626'
|
||||
}
|
||||
];
|
||||
// 시스템 관리자 전용 기능
|
||||
if (user?.role === 'system') {
|
||||
features.push(
|
||||
{
|
||||
id: 'user-management',
|
||||
title: '👥 사용자 관리',
|
||||
description: '계정 생성, 역할 변경, 사용자 삭제',
|
||||
color: '#dc2626',
|
||||
badge: '시스템 관리자'
|
||||
},
|
||||
{
|
||||
id: 'system-logs',
|
||||
title: '📊 시스템 로그',
|
||||
description: '로그인 기록, 시스템 오류 로그 조회',
|
||||
color: '#7c3aed',
|
||||
badge: '시스템 관리자'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 관리자 이상 공통 기능
|
||||
if (user?.role === 'admin' || user?.role === 'system') {
|
||||
features.push(
|
||||
{
|
||||
id: 'log-monitoring',
|
||||
title: '📈 로그 모니터링',
|
||||
description: '사용자 활동 로그 및 오류 모니터링',
|
||||
color: '#059669',
|
||||
badge: user?.role === 'system' ? '시스템 관리자' : '관리자'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return features;
|
||||
};
|
||||
|
||||
// 페이지 렌더링 함수
|
||||
@@ -118,24 +165,147 @@ function App() {
|
||||
🏭 TK-MP BOM 관리 시스템
|
||||
</h1>
|
||||
<p style={{ color: '#718096', fontSize: '14px', margin: '4px 0 0 0' }}>
|
||||
{user?.full_name || user?.username}님 환영합니다
|
||||
{user?.name || user?.username}님 환영합니다
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
color: '#4a5568',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
|
||||
{/* 사용자 메뉴 */}
|
||||
<div className="user-menu-container" style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#495057',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = '#e9ecef';
|
||||
e.target.style.borderColor = '#dee2e6';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = '#f8f9fa';
|
||||
e.target.style.borderColor = '#e9ecef';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{(user?.name || user?.username || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{user?.name || user?.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{user?.role === 'system' ? '시스템 관리자' :
|
||||
user?.role === 'admin' ? '관리자' : '사용자'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
transform: showUserMenu ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}>
|
||||
▼
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 드롭다운 메뉴 */}
|
||||
{showUserMenu && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
background: 'white',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
minWidth: '200px',
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f1f3f4' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{user?.name || user?.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d', marginTop: '2px' }}>
|
||||
{user?.email || '이메일 없음'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
navigateToPage('account-settings');
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
>
|
||||
⚙️ 계정 설정
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
handleLogout();
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#dc3545',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'background-color 0.2s ease',
|
||||
borderTop: '1px solid #f1f3f4'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#fff5f5'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
>
|
||||
🚪 로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
@@ -275,14 +445,14 @@ function App() {
|
||||
</p>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<span style={{
|
||||
background: '#fef7e0',
|
||||
color: '#92400e',
|
||||
padding: '2px 8px',
|
||||
background: feature.badge === '시스템 관리자' ? '#fef2f2' : '#fef7e0',
|
||||
color: feature.badge === '시스템 관리자' ? '#dc2626' : '#92400e',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
관리자 전용
|
||||
{feature.badge} 전용
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -403,6 +573,42 @@ function App() {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'account-settings':
|
||||
return (
|
||||
<AccountSettingsPage
|
||||
onNavigate={navigateToPage}
|
||||
user={user}
|
||||
onUserUpdate={(updatedUser) => {
|
||||
setUser(updatedUser);
|
||||
localStorage.setItem('user_data', JSON.stringify(updatedUser));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'user-management':
|
||||
return (
|
||||
<UserManagementPage
|
||||
onNavigate={navigateToPage}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'system-logs':
|
||||
return (
|
||||
<SystemLogsPage
|
||||
onNavigate={navigateToPage}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'log-monitoring':
|
||||
return (
|
||||
<LogMonitoringPage
|
||||
onNavigate={navigateToPage}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div style={{ padding: '32px', textAlign: 'center' }}>
|
||||
@@ -451,9 +657,11 @@ function App() {
|
||||
|
||||
// 메인 애플리케이션
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f7fafc' }}>
|
||||
{renderCurrentPage()}
|
||||
</div>
|
||||
<ErrorBoundary errorContext={{ user, currentPage, pageParams }}>
|
||||
<div style={{ minHeight: '100vh', background: '#f7fafc' }}>
|
||||
{renderCurrentPage()}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -199,9 +199,6 @@ const SimpleLogin = ({ onLoginSuccess }) => {
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '32px', textAlign: 'center' }}>
|
||||
<p style={{ color: 'rgba(255, 255, 255, 0.8)', fontSize: '14px', margin: '0 0 16px 0' }}>
|
||||
테스트 계정: admin / admin123
|
||||
</p>
|
||||
<div>
|
||||
<small style={{ color: 'rgba(255, 255, 255, 0.6)', fontSize: '12px' }}>
|
||||
TK-MP 통합 관리 시스템 v2.0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { logApiError } from './utils/errorLogger';
|
||||
|
||||
// 환경변수에서 API URL을 읽음 (Vite 기준)
|
||||
// 프로덕션에서는 nginx 프록시를 통해 /api 경로 사용
|
||||
@@ -53,6 +54,12 @@ const retryRequest = async (config, retries = MAX_RETRIES) => {
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
// 오류 로깅
|
||||
const endpoint = error.config?.url;
|
||||
const requestData = error.config?.data;
|
||||
|
||||
logApiError(error, endpoint, requestData);
|
||||
|
||||
console.error('API Error:', {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
|
||||
268
frontend/src/components/ErrorBoundary.jsx
Normal file
268
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import React from 'react';
|
||||
import errorLogger from '../utils/errorLogger';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트합니다.
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// 오류 정보를 상태에 저장
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
|
||||
// 오류 로깅
|
||||
errorLogger.logError({
|
||||
type: 'react_error_boundary',
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
props: this.props.errorContext || {}
|
||||
});
|
||||
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
handleReportError = () => {
|
||||
const errorReport = {
|
||||
error: this.state.error?.message,
|
||||
stack: this.state.error?.stack,
|
||||
componentStack: this.state.errorInfo?.componentStack,
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent
|
||||
};
|
||||
|
||||
// 오류 보고서를 클립보드에 복사
|
||||
navigator.clipboard.writeText(JSON.stringify(errorReport, null, 2))
|
||||
.then(() => {
|
||||
alert('오류 정보가 클립보드에 복사되었습니다.');
|
||||
})
|
||||
.catch(() => {
|
||||
// 클립보드 복사 실패 시 텍스트 영역에 표시
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = JSON.stringify(errorReport, null, 2);
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
alert('오류 정보가 클립보드에 복사되었습니다.');
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
padding: '40px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
😵
|
||||
</div>
|
||||
|
||||
<h1 style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '600',
|
||||
color: '#dc3545',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
앗! 문제가 발생했습니다
|
||||
</h1>
|
||||
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#6c757d',
|
||||
marginBottom: '30px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
예상치 못한 오류가 발생했습니다. <br />
|
||||
이 문제는 자동으로 개발팀에 보고되었습니다.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: '30px'
|
||||
}}>
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseOver={(e) => e.target.style.backgroundColor = '#0056b3'}
|
||||
onMouseOut={(e) => e.target.style.backgroundColor = '#007bff'}
|
||||
>
|
||||
🔄 페이지 새로고침
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={this.handleGoHome}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseOver={(e) => e.target.style.backgroundColor = '#1e7e34'}
|
||||
onMouseOut={(e) => e.target.style.backgroundColor = '#28a745'}
|
||||
>
|
||||
🏠 홈으로 이동
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={this.handleReportError}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseOver={(e) => e.target.style.backgroundColor = '#545b62'}
|
||||
onMouseOut={(e) => e.target.style.backgroundColor = '#6c757d'}
|
||||
>
|
||||
📋 오류 정보 복사
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 개발 환경에서만 상세 오류 정보 표시 */}
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details style={{
|
||||
textAlign: 'left',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '16px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '20px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
<summary style={{
|
||||
cursor: 'pointer',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
color: '#495057'
|
||||
}}>
|
||||
개발자 정보 (클릭하여 펼치기)
|
||||
</summary>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<strong>오류 메시지:</strong>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: '4px 0',
|
||||
color: '#dc3545'
|
||||
}}>
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<strong>스택 트레이스:</strong>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: '4px 0',
|
||||
fontSize: '11px',
|
||||
color: '#6c757d'
|
||||
}}>
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{this.state.errorInfo?.componentStack && (
|
||||
<div>
|
||||
<strong>컴포넌트 스택:</strong>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: '4px 0',
|
||||
fontSize: '11px',
|
||||
color: '#6c757d'
|
||||
}}>
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: '30px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
color: '#1565c0'
|
||||
}}>
|
||||
💡 <strong>도움말:</strong> 문제가 계속 발생하면 페이지를 새로고침하거나
|
||||
브라우저 캐시를 삭제해보세요.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
705
frontend/src/pages/AccountSettingsPage.jsx
Normal file
705
frontend/src/pages/AccountSettingsPage.jsx
Normal file
@@ -0,0 +1,705 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../api';
|
||||
import { reportError, logUserActionError } from '../utils/errorLogger';
|
||||
|
||||
const AccountSettingsPage = ({ onNavigate, user, onUserUpdate }) => {
|
||||
const [activeTab, setActiveTab] = useState('profile');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
// 프로필 정보 상태
|
||||
const [profileData, setProfileData] = useState({
|
||||
name: user?.name || '',
|
||||
email: user?.email || '',
|
||||
department: user?.department || '',
|
||||
position: user?.position || ''
|
||||
});
|
||||
|
||||
// 비밀번호 변경 상태
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const [validationErrors, setValidationErrors] = useState({});
|
||||
|
||||
const handleProfileChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setProfileData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 에러 메시지 초기화
|
||||
if (validationErrors[name]) {
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
if (message.text) setMessage({ type: '', text: '' });
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setPasswordData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 에러 메시지 초기화
|
||||
if (validationErrors[name]) {
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
if (message.text) setMessage({ type: '', text: '' });
|
||||
};
|
||||
|
||||
const validateProfileForm = () => {
|
||||
const errors = {};
|
||||
|
||||
if (!profileData.name.trim()) {
|
||||
errors.name = '이름을 입력해주세요';
|
||||
} else if (profileData.name.length < 2 || profileData.name.length > 50) {
|
||||
errors.name = '이름은 2-50자여야 합니다';
|
||||
}
|
||||
|
||||
if (profileData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileData.email)) {
|
||||
errors.email = '올바른 이메일 형식을 입력해주세요';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const validatePasswordForm = () => {
|
||||
const errors = {};
|
||||
|
||||
if (!passwordData.currentPassword) {
|
||||
errors.currentPassword = '현재 비밀번호를 입력해주세요';
|
||||
}
|
||||
|
||||
if (!passwordData.newPassword) {
|
||||
errors.newPassword = '새 비밀번호를 입력해주세요';
|
||||
} else if (passwordData.newPassword.length < 8) {
|
||||
errors.newPassword = '새 비밀번호는 8자 이상이어야 합니다';
|
||||
}
|
||||
|
||||
if (!passwordData.confirmPassword) {
|
||||
errors.confirmPassword = '비밀번호 확인을 입력해주세요';
|
||||
} else if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
errors.confirmPassword = '새 비밀번호가 일치하지 않습니다';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleProfileSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateProfileForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const response = await api.put('/auth/profile', {
|
||||
name: profileData.name.trim(),
|
||||
email: profileData.email.trim() || null,
|
||||
department: profileData.department.trim() || null,
|
||||
position: profileData.position.trim() || null
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const updatedUser = { ...user, ...response.data.user };
|
||||
onUserUpdate(updatedUser);
|
||||
setMessage({ type: 'success', text: '프로필이 성공적으로 업데이트되었습니다' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: response.data.message || '프로필 업데이트에 실패했습니다' });
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'프로필 업데이트 중 오류가 발생했습니다';
|
||||
|
||||
setMessage({ type: 'error', text: errorMessage });
|
||||
|
||||
logUserActionError('profile_update', err, { userId: user?.user_id });
|
||||
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validatePasswordForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
const response = await api.put('/auth/change-password', {
|
||||
current_password: passwordData.currentPassword,
|
||||
new_password: passwordData.newPassword
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setPasswordData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setMessage({ type: 'success', text: '비밀번호가 성공적으로 변경되었습니다' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: response.data.message || '비밀번호 변경에 실패했습니다' });
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Password change error:', err);
|
||||
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'비밀번호 변경 중 오류가 발생했습니다';
|
||||
|
||||
setMessage({ type: 'error', text: errorMessage });
|
||||
|
||||
logUserActionError('password_change', err, { userId: user?.user_id });
|
||||
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f8f9fa' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
padding: '16px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#28a745',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
title="대시보드로 돌아가기"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
||||
⚙️ 계정 설정
|
||||
</h1>
|
||||
<p style={{ color: '#6c757d', fontSize: '14px', margin: '4px 0 0 0' }}>
|
||||
프로필 정보 및 비밀번호를 관리하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
{/* 탭 메뉴 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '2px solid #e9ecef',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'profile' ? '2px solid #007bff' : '2px solid transparent',
|
||||
color: activeTab === 'profile' ? '#007bff' : '#6c757d',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
👤 프로필 정보
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'password' ? '2px solid #007bff' : '2px solid transparent',
|
||||
color: activeTab === 'password' ? '#007bff' : '#6c757d',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
🔒 비밀번호 변경
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 메시지 표시 */}
|
||||
{message.text && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
backgroundColor: message.type === 'success' ? '#d1edff' : '#f8d7da',
|
||||
border: `1px solid ${message.type === 'success' ? '#bee5eb' : '#f5c6cb'}`,
|
||||
color: message.type === 'success' ? '#0c5460' : '#721c24'
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로필 정보 탭 */}
|
||||
{activeTab === 'profile' && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '24px' }}>
|
||||
프로필 정보
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleProfileSubmit}>
|
||||
<div style={{ display: 'grid', gap: '20px' }}>
|
||||
{/* 사용자명 (읽기 전용) */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
사용자명
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={user?.username || ''}
|
||||
disabled
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#f9fafb',
|
||||
color: '#6b7280',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
|
||||
사용자명은 변경할 수 없습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 이름 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
이름 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={profileData.name}
|
||||
onChange={handleProfileChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.name ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.name ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.name && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이메일 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={profileData.email}
|
||||
onChange={handleProfileChange}
|
||||
placeholder="example@company.com"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.email ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.email ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.email && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부서 및 직책 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
부서
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="department"
|
||||
value={profileData.department}
|
||||
onChange={handleProfileChange}
|
||||
placeholder="IT팀"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
직책
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="position"
|
||||
value={profileData.position}
|
||||
onChange={handleProfileChange}
|
||||
placeholder="관리자"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 역할 (읽기 전용) */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
역할
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={user?.role === 'system' ? '시스템 관리자' :
|
||||
user?.role === 'admin' ? '관리자' : '사용자'}
|
||||
disabled
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#f9fafb',
|
||||
color: '#6b7280',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: '#6b7280', marginTop: '4px' }}>
|
||||
역할은 시스템 관리자만 변경할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '12px 24px',
|
||||
backgroundColor: isLoading ? '#9ca3af' : '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) e.target.style.backgroundColor = '#0056b3';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) e.target.style.backgroundColor = '#007bff';
|
||||
}}
|
||||
>
|
||||
{isLoading ? '저장 중...' : '💾 프로필 저장'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 비밀번호 변경 탭 */}
|
||||
{activeTab === 'password' && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600', color: '#2d3748', marginBottom: '24px' }}>
|
||||
비밀번호 변경
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div style={{ display: 'grid', gap: '20px', maxWidth: '400px' }}>
|
||||
{/* 현재 비밀번호 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
현재 비밀번호 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
value={passwordData.currentPassword}
|
||||
onChange={handlePasswordChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.currentPassword ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.currentPassword ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.currentPassword && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.currentPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 새 비밀번호 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
새 비밀번호 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
value={passwordData.newPassword}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="8자 이상 입력해주세요"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.newPassword ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.newPassword ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.newPassword && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.newPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 새 비밀번호 확인 */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
새 비밀번호 확인 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="새 비밀번호를 다시 입력해주세요"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.confirmPassword ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.confirmPassword ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.confirmPassword && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '12px 24px',
|
||||
backgroundColor: isLoading ? '#9ca3af' : '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) e.target.style.backgroundColor = '#c82333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) e.target.style.backgroundColor = '#dc3545';
|
||||
}}
|
||||
>
|
||||
{isLoading ? '변경 중...' : '🔒 비밀번호 변경'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 보안 안내 */}
|
||||
<div style={{
|
||||
marginTop: '32px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffeaa7',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<h4 style={{ fontSize: '14px', fontWeight: '600', color: '#856404', margin: '0 0 8px 0' }}>
|
||||
🔐 보안 안내
|
||||
</h4>
|
||||
<ul style={{ fontSize: '12px', color: '#856404', margin: 0, paddingLeft: '16px' }}>
|
||||
<li>비밀번호는 8자 이상으로 설정해주세요</li>
|
||||
<li>영문, 숫자, 특수문자를 조합하여 사용하는 것을 권장합니다</li>
|
||||
<li>정기적으로 비밀번호를 변경해주세요</li>
|
||||
<li>다른 사이트와 동일한 비밀번호 사용을 피해주세요</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSettingsPage;
|
||||
459
frontend/src/pages/LogMonitoringPage.jsx
Normal file
459
frontend/src/pages/LogMonitoringPage.jsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import { reportError, logUserActionError } from '../utils/errorLogger';
|
||||
|
||||
const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
const [stats, setStats] = useState({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
todayLogins: 0,
|
||||
failedLogins: 0,
|
||||
recentErrors: 0
|
||||
});
|
||||
const [recentActivity, setRecentActivity] = useState([]);
|
||||
const [frontendErrors, setFrontendErrors] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
// 30초마다 자동 새로고침
|
||||
const interval = setInterval(loadDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 병렬로 데이터 로드
|
||||
const [usersResponse, loginLogsResponse] = await Promise.all([
|
||||
api.get('/auth/users'),
|
||||
api.get('/auth/logs/login', { params: { limit: 20 } })
|
||||
]);
|
||||
|
||||
// 사용자 통계
|
||||
if (usersResponse.data.success) {
|
||||
const users = usersResponse.data.users;
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalUsers: users.length,
|
||||
activeUsers: users.filter(u => u.is_active).length
|
||||
}));
|
||||
}
|
||||
|
||||
// 로그인 로그 통계
|
||||
if (loginLogsResponse.data.success) {
|
||||
const logs = loginLogsResponse.data.logs;
|
||||
const today = new Date().toDateString();
|
||||
|
||||
const todayLogins = logs.filter(log =>
|
||||
new Date(log.login_time).toDateString() === today &&
|
||||
log.login_status === 'success'
|
||||
).length;
|
||||
|
||||
const failedLogins = logs.filter(log =>
|
||||
new Date(log.login_time).toDateString() === today &&
|
||||
log.login_status === 'failed'
|
||||
).length;
|
||||
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
todayLogins,
|
||||
failedLogins
|
||||
}));
|
||||
|
||||
setRecentActivity(logs.slice(0, 10));
|
||||
}
|
||||
|
||||
// 프론트엔드 오류 로그 (로컬 스토리지에서)
|
||||
const localErrors = JSON.parse(localStorage.getItem('frontend_errors') || '[]');
|
||||
const recentErrors = localErrors.filter(error => {
|
||||
const errorDate = new Date(error.timestamp);
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return errorDate > oneDayAgo;
|
||||
});
|
||||
|
||||
setFrontendErrors(recentErrors.slice(0, 10));
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
recentErrors: recentErrors.length
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
console.error('Load dashboard data error:', err);
|
||||
setMessage({ type: 'error', text: '대시보드 데이터 로드 중 오류가 발생했습니다' });
|
||||
logUserActionError('load_dashboard_data', err, { userId: user?.user_id });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFrontendErrors = () => {
|
||||
localStorage.removeItem('frontend_errors');
|
||||
setFrontendErrors([]);
|
||||
setStats(prev => ({ ...prev, recentErrors: 0 }));
|
||||
setMessage({ type: 'success', text: '프론트엔드 오류 로그가 삭제되었습니다' });
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
try {
|
||||
return new Date(dateString).toLocaleString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityIcon = (status) => {
|
||||
return status === 'success' ? '✅' : '❌';
|
||||
};
|
||||
|
||||
const getErrorTypeIcon = (type) => {
|
||||
const icons = {
|
||||
'javascript_error': '🐛',
|
||||
'api_error': '🌐',
|
||||
'user_action_error': '👤',
|
||||
'promise_rejection': '⚠️',
|
||||
'react_error_boundary': '⚛️'
|
||||
};
|
||||
return icons[type] || '❓';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f8f9fa' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
padding: '16px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#28a745',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
title="대시보드로 돌아가기"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
||||
📈 로그 모니터링
|
||||
</h1>
|
||||
<p style={{ color: '#6c757d', fontSize: '14px', margin: '4px 0 0 0' }}>
|
||||
실시간 시스템 활동 및 오류 모니터링
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={loadDashboardData}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
|
||||
{frontendErrors.length > 0 && (
|
||||
<button
|
||||
onClick={clearFrontendErrors}
|
||||
style={{
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🗑️ 오류 로그 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 메시지 표시 */}
|
||||
{message.text && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
backgroundColor: message.type === 'success' ? '#d1edff' : '#f8d7da',
|
||||
border: `1px solid ${message.type === 'success' ? '#bee5eb' : '#f5c6cb'}`,
|
||||
color: message.type === 'success' ? '#0c5460' : '#721c24'
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '32px'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>👥</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
전체 사용자
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#2d3748' }}>
|
||||
{stats.totalUsers}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#28a745' }}>
|
||||
활성: {stats.activeUsers}명
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>✅</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
오늘 로그인
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#28a745' }}>
|
||||
{stats.todayLogins}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||||
성공한 로그인
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>❌</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
로그인 실패
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc3545' }}>
|
||||
{stats.failedLogins}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||||
오늘 실패 횟수
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>🐛</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
최근 오류
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#ffc107' }}>
|
||||
{stats.recentErrors}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||||
24시간 내
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 그리드 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{/* 최근 활동 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
🔐 최근 로그인 활동
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>로딩 중...</div>
|
||||
</div>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>최근 활동이 없습니다</div>
|
||||
</div>
|
||||
) : (
|
||||
recentActivity.map((activity, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #f1f3f4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{ fontSize: '20px' }}>
|
||||
{getActivityIcon(activity.login_status)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{activity.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
@{activity.username} • {activity.ip_address}
|
||||
</div>
|
||||
{activity.failure_reason && (
|
||||
<div style={{ fontSize: '12px', color: '#dc3545', marginTop: '2px' }}>
|
||||
{activity.failure_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
{formatDateTime(activity.login_time)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프론트엔드 오류 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
🐛 프론트엔드 오류
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
{frontendErrors.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>최근 오류가 없습니다</div>
|
||||
</div>
|
||||
) : (
|
||||
frontendErrors.map((error, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #f1f3f4',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', marginTop: '2px' }}>
|
||||
{getErrorTypeIcon(error.type)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#dc3545' }}>
|
||||
{error.type?.replace('_', ' ').toUpperCase() || 'ERROR'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#495057',
|
||||
marginTop: '4px',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{error.message?.substring(0, 100)}
|
||||
{error.message?.length > 100 && '...'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d', marginTop: '4px' }}>
|
||||
{error.url && (
|
||||
<span>{new URL(error.url).pathname} • </span>
|
||||
)}
|
||||
{formatDateTime(error.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자동 새로고침 안내 */}
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
border: '1px solid #bbdefb',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<p style={{ fontSize: '14px', color: '#1565c0', margin: 0 }}>
|
||||
📊 이 페이지는 30초마다 자동으로 새로고침됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogMonitoringPage;
|
||||
439
frontend/src/pages/SystemLogsPage.jsx
Normal file
439
frontend/src/pages/SystemLogsPage.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import { reportError, logUserActionError } from '../utils/errorLogger';
|
||||
|
||||
const SystemLogsPage = ({ onNavigate, user }) => {
|
||||
const [activeTab, setActiveTab] = useState('login');
|
||||
const [loginLogs, setLoginLogs] = useState([]);
|
||||
const [systemLogs, setSystemLogs] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
// 필터 상태
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
level: '',
|
||||
userId: '',
|
||||
limit: 50
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'login') {
|
||||
loadLoginLogs();
|
||||
} else {
|
||||
loadSystemLogs();
|
||||
}
|
||||
}, [activeTab, filters]);
|
||||
|
||||
const loadLoginLogs = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = {
|
||||
limit: filters.limit,
|
||||
...(filters.status && { status: filters.status }),
|
||||
...(filters.userId && { user_id: filters.userId })
|
||||
};
|
||||
|
||||
const response = await api.get('/auth/logs/login', { params });
|
||||
|
||||
if (response.data.success) {
|
||||
setLoginLogs(response.data.logs);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: '로그인 로그를 불러올 수 없습니다' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load login logs error:', err);
|
||||
setMessage({ type: 'error', text: '로그인 로그 조회 중 오류가 발생했습니다' });
|
||||
logUserActionError('load_login_logs', err, { userId: user?.user_id });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSystemLogs = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = {
|
||||
limit: filters.limit,
|
||||
...(filters.level && { level: filters.level })
|
||||
};
|
||||
|
||||
const response = await api.get('/auth/logs/system', { params });
|
||||
|
||||
if (response.data.success) {
|
||||
setSystemLogs(response.data.logs);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: '시스템 로그를 불러올 수 없습니다' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load system logs error:', err);
|
||||
setMessage({ type: 'error', text: '시스템 로그 조회 중 오류가 발생했습니다' });
|
||||
logUserActionError('load_system_logs', err, { userId: user?.user_id });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const colors = {
|
||||
'success': { bg: '#d1edff', color: '#0c5460' },
|
||||
'failed': { bg: '#f8d7da', color: '#721c24' }
|
||||
};
|
||||
const color = colors[status] || colors.failed;
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
background: color.bg,
|
||||
color: color.color,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{status === 'success' ? '성공' : '실패'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getLevelBadge = (level) => {
|
||||
const colors = {
|
||||
'ERROR': { bg: '#f8d7da', color: '#721c24' },
|
||||
'WARNING': { bg: '#fff3cd', color: '#856404' },
|
||||
'INFO': { bg: '#d1ecf1', color: '#0c5460' },
|
||||
'DEBUG': { bg: '#e2e3e5', color: '#383d41' }
|
||||
};
|
||||
const color = colors[level] || colors.INFO;
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
background: color.bg,
|
||||
color: color.color,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
try {
|
||||
return new Date(dateString).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#f8f9fa' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
padding: '16px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#28a745',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||||
title="대시보드로 돌아가기"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
||||
📊 시스템 로그
|
||||
</h1>
|
||||
<p style={{ color: '#6c757d', fontSize: '14px', margin: '4px 0 0 0' }}>
|
||||
로그인 기록과 시스템 오류 로그를 조회하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '32px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
{/* 탭 메뉴 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '2px solid #e9ecef',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setActiveTab('login')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'login' ? '2px solid #007bff' : '2px solid transparent',
|
||||
color: activeTab === 'login' ? '#007bff' : '#6c757d',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
🔐 로그인 로그
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('system')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'system' ? '2px solid #007bff' : '2px solid transparent',
|
||||
color: activeTab === 'system' ? '#007bff' : '#6c757d',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
🖥️ 시스템 로그
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{activeTab === 'login' && (
|
||||
<div>
|
||||
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
|
||||
상태:
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="success">성공</option>
|
||||
<option value="failed">실패</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'system' && (
|
||||
<div>
|
||||
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
|
||||
레벨:
|
||||
</label>
|
||||
<select
|
||||
value={filters.level}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, level: e.target.value }))}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: '14px', fontWeight: '600', color: '#374151', marginRight: '8px' }}>
|
||||
개수:
|
||||
</label>
|
||||
<select
|
||||
value={filters.limit}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, limit: parseInt(e.target.value) }))}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value={50}>50개</option>
|
||||
<option value={100}>100개</option>
|
||||
<option value={200}>200개</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => activeTab === 'login' ? loadLoginLogs() : loadSystemLogs()}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메시지 표시 */}
|
||||
{message.text && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
backgroundColor: message.type === 'success' ? '#d1edff' : '#f8d7da',
|
||||
border: `1px solid ${message.type === 'success' ? '#bee5eb' : '#f5c6cb'}`,
|
||||
color: message.type === 'success' ? '#0c5460' : '#721c24'
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로그 테이블 */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e9ecef',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
{activeTab === 'login' ? '로그인 로그' : '시스템 로그'}
|
||||
({activeTab === 'login' ? loginLogs.length : systemLogs.length}개)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', color: '#6c757d' }}>로딩 중...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
{activeTab === 'login' ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f9fa' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>시간</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>사용자</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>상태</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>IP 주소</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>실패 사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loginLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
|
||||
로그인 로그가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
loginLogs.map((log, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
|
||||
{formatDateTime(log.login_time)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||||
{log.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||||
@{log.username}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{getStatusBadge(log.login_status)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
|
||||
{log.ip_address || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#dc3545' }}>
|
||||
{log.failure_reason || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f9fa' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>시간</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>레벨</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>모듈</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: '600', color: '#495057', borderBottom: '1px solid #dee2e6' }}>메시지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{systemLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ padding: '40px', textAlign: 'center', color: '#6c757d' }}>
|
||||
시스템 로그가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
systemLogs.map((log, index) => (
|
||||
<tr key={index} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
|
||||
{formatDateTime(log.timestamp)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{getLevelBadge(log.level)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057' }}>
|
||||
{log.module || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#495057', maxWidth: '400px', wordBreak: 'break-word' }}>
|
||||
{log.message}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemLogsPage;
|
||||
509
frontend/src/pages/SystemSetupPage.jsx
Normal file
509
frontend/src/pages/SystemSetupPage.jsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import React, { useState } from 'react';
|
||||
import api from '../api';
|
||||
import { reportError } from '../utils/errorLogger';
|
||||
|
||||
const SystemSetupPage = ({ onSetupComplete }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: '',
|
||||
email: '',
|
||||
department: '',
|
||||
position: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState({});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 입력 시 해당 필드의 에러 메시지 초기화
|
||||
if (validationErrors[name]) {
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors = {};
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!formData.username.trim()) {
|
||||
errors.username = '사용자명을 입력해주세요';
|
||||
} else if (formData.username.length < 3 || formData.username.length > 20) {
|
||||
errors.username = '사용자명은 3-20자여야 합니다';
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
|
||||
errors.username = '사용자명은 영문, 숫자, 언더스코어만 사용 가능합니다';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
errors.password = '비밀번호를 입력해주세요';
|
||||
} else if (formData.password.length < 8) {
|
||||
errors.password = '비밀번호는 8자 이상이어야 합니다';
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
errors.confirmPassword = '비밀번호 확인을 입력해주세요';
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
errors.confirmPassword = '비밀번호가 일치하지 않습니다';
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
errors.name = '이름을 입력해주세요';
|
||||
} else if (formData.name.length < 2 || formData.name.length > 50) {
|
||||
errors.name = '이름은 2-50자여야 합니다';
|
||||
}
|
||||
|
||||
// 이메일 검증 (선택사항)
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = '올바른 이메일 형식을 입력해주세요';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const setupData = {
|
||||
username: formData.username.trim(),
|
||||
password: formData.password,
|
||||
name: formData.name.trim(),
|
||||
email: formData.email.trim() || null,
|
||||
department: formData.department.trim() || null,
|
||||
position: formData.position.trim() || null
|
||||
};
|
||||
|
||||
const response = await api.post('/setup/initialize', setupData);
|
||||
|
||||
if (response.data.success) {
|
||||
// 설정 완료 후 콜백 호출
|
||||
if (onSetupComplete) {
|
||||
onSetupComplete(response.data);
|
||||
}
|
||||
} else {
|
||||
setError(response.data.message || '시스템 초기화에 실패했습니다');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('System setup error:', err);
|
||||
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'시스템 초기화 중 오류가 발생했습니다';
|
||||
|
||||
setError(errorMessage);
|
||||
|
||||
// 오류 로깅
|
||||
reportError('System setup failed', {
|
||||
error: err.message,
|
||||
response: err.response?.data,
|
||||
formData: { ...formData, password: '[HIDDEN]', confirmPassword: '[HIDDEN]' }
|
||||
});
|
||||
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)',
|
||||
padding: '40px'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚀</div>
|
||||
<h1 style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
color: '#2d3748',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
시스템 초기 설정
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
color: '#718096',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
TK-MP 시스템을 처음 사용하시는군요!<br />
|
||||
시스템 관리자 계정을 생성해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* 사용자명 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
사용자명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="영문, 숫자, 언더스코어 (3-20자)"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.username ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.username ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.username && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이름 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
이름 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="실제 이름을 입력해주세요"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.name ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.name ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.name && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
비밀번호 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="8자 이상 입력해주세요"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.password ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.password ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.password && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
비밀번호 확인 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
placeholder="비밀번호를 다시 입력해주세요"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.confirmPassword ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.confirmPassword ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.confirmPassword && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이메일 (선택사항) */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
이메일 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="admin@company.com"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: validationErrors.email ? '2px solid #ef4444' : '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = validationErrors.email ? '#ef4444' : '#d1d5db'}
|
||||
/>
|
||||
{validationErrors.email && (
|
||||
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '4px' }}>
|
||||
{validationErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부서/직책 (선택사항) */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
부서 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleChange}
|
||||
placeholder="IT팀"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
직책 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="position"
|
||||
value={formData.position}
|
||||
onChange={handleChange}
|
||||
placeholder="시스템 관리자"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = '#3b82f6'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#d1d5db'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<p style={{ color: '#dc2626', fontSize: '14px', margin: 0 }}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제출 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
backgroundColor: isLoading ? '#9ca3af' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) e.target.style.backgroundColor = '#2563eb';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) e.target.style.backgroundColor = '#3b82f6';
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid #ffffff',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
설정 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🚀 시스템 초기화
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #bae6fd'
|
||||
}}>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#0369a1',
|
||||
margin: 0,
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
💡 <strong>안내:</strong> 시스템 관리자는 모든 권한을 가지며, 다른 사용자 계정을 생성하고 관리할 수 있습니다.
|
||||
설정 완료 후 이 계정으로 로그인하여 추가 사용자를 생성하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS 애니메이션 */}
|
||||
<style jsx>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSetupPage;
|
||||
File diff suppressed because it is too large
Load Diff
323
frontend/src/utils/errorLogger.js
Normal file
323
frontend/src/utils/errorLogger.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 프론트엔드 오류 로깅 시스템
|
||||
* 테스트 및 디버깅을 위한 오류 수집 및 전송
|
||||
*/
|
||||
|
||||
import api from '../api';
|
||||
|
||||
class ErrorLogger {
|
||||
constructor() {
|
||||
this.isEnabled = process.env.NODE_ENV === 'development' || process.env.REACT_APP_ERROR_LOGGING === 'true';
|
||||
this.maxRetries = 3;
|
||||
this.retryDelay = 1000; // 1초
|
||||
this.errorQueue = [];
|
||||
this.isProcessing = false;
|
||||
|
||||
// 전역 오류 핸들러 설정
|
||||
this.setupGlobalErrorHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 오류 핸들러 설정
|
||||
*/
|
||||
setupGlobalErrorHandlers() {
|
||||
// JavaScript 오류 캐치
|
||||
window.addEventListener('error', (event) => {
|
||||
this.logError({
|
||||
type: 'javascript_error',
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
stack: event.error?.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent
|
||||
});
|
||||
});
|
||||
|
||||
// Promise rejection 캐치
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
this.logError({
|
||||
type: 'promise_rejection',
|
||||
message: event.reason?.message || 'Unhandled Promise Rejection',
|
||||
stack: event.reason?.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent
|
||||
});
|
||||
});
|
||||
|
||||
// React Error Boundary에서 사용할 수 있도록 전역에 등록
|
||||
window.errorLogger = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 로깅
|
||||
* @param {Object} errorInfo - 오류 정보
|
||||
*/
|
||||
async logError(errorInfo) {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
const errorData = {
|
||||
...errorInfo,
|
||||
sessionId: this.getSessionId(),
|
||||
userId: this.getUserId(),
|
||||
timestamp: errorInfo.timestamp || new Date().toISOString(),
|
||||
level: errorInfo.level || 'error'
|
||||
};
|
||||
|
||||
// 콘솔에도 출력 (개발 환경)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('🚨 Frontend Error:', errorData);
|
||||
}
|
||||
|
||||
// 로컬 스토리지에 임시 저장
|
||||
this.saveToLocalStorage(errorData);
|
||||
|
||||
// 서버로 전송 (큐에 추가)
|
||||
this.errorQueue.push(errorData);
|
||||
this.processErrorQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* API 오류 로깅
|
||||
* @param {Object} error - API 오류 객체
|
||||
* @param {string} endpoint - API 엔드포인트
|
||||
* @param {Object} requestData - 요청 데이터
|
||||
*/
|
||||
logApiError(error, endpoint, requestData = null) {
|
||||
const errorInfo = {
|
||||
type: 'api_error',
|
||||
message: error.message || 'API Error',
|
||||
endpoint: endpoint,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestData: requestData,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href
|
||||
};
|
||||
|
||||
this.logError(errorInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 액션 오류 로깅
|
||||
* @param {string} action - 사용자 액션
|
||||
* @param {Object} error - 오류 객체
|
||||
* @param {Object} context - 추가 컨텍스트
|
||||
*/
|
||||
logUserActionError(action, error, context = {}) {
|
||||
const errorInfo = {
|
||||
type: 'user_action_error',
|
||||
action: action,
|
||||
message: error.message || 'User Action Error',
|
||||
stack: error.stack,
|
||||
context: context,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href
|
||||
};
|
||||
|
||||
this.logError(errorInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성능 이슈 로깅
|
||||
* @param {string} operation - 작업명
|
||||
* @param {number} duration - 소요 시간 (ms)
|
||||
* @param {Object} details - 추가 세부사항
|
||||
*/
|
||||
logPerformanceIssue(operation, duration, details = {}) {
|
||||
if (duration > 5000) { // 5초 이상 걸린 작업만 로깅
|
||||
const performanceInfo = {
|
||||
type: 'performance_issue',
|
||||
operation: operation,
|
||||
duration: duration,
|
||||
details: details,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
level: 'warning'
|
||||
};
|
||||
|
||||
this.logError(performanceInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 큐 처리
|
||||
*/
|
||||
async processErrorQueue() {
|
||||
if (this.isProcessing || this.errorQueue.length === 0) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
|
||||
while (this.errorQueue.length > 0) {
|
||||
const errorData = this.errorQueue.shift();
|
||||
|
||||
try {
|
||||
await this.sendErrorToServer(errorData);
|
||||
} catch (sendError) {
|
||||
console.error('Failed to send error to server:', sendError);
|
||||
// 실패한 오류는 다시 큐에 추가 (최대 재시도 횟수 확인)
|
||||
if (!errorData.retryCount) errorData.retryCount = 0;
|
||||
if (errorData.retryCount < this.maxRetries) {
|
||||
errorData.retryCount++;
|
||||
this.errorQueue.push(errorData);
|
||||
await this.delay(this.retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버로 오류 전송
|
||||
* @param {Object} errorData - 오류 데이터
|
||||
*/
|
||||
async sendErrorToServer(errorData) {
|
||||
try {
|
||||
await api.post('/logs/frontend-error', errorData);
|
||||
} catch (error) {
|
||||
// 로깅 API가 없는 경우 무시
|
||||
if (error.response?.status === 404) {
|
||||
console.warn('Error logging endpoint not available');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 스토리지에 오류 저장
|
||||
* @param {Object} errorData - 오류 데이터
|
||||
*/
|
||||
saveToLocalStorage(errorData) {
|
||||
try {
|
||||
const errors = JSON.parse(localStorage.getItem('frontend_errors') || '[]');
|
||||
errors.push(errorData);
|
||||
|
||||
// 최대 100개까지만 저장
|
||||
if (errors.length > 100) {
|
||||
errors.splice(0, errors.length - 100);
|
||||
}
|
||||
|
||||
localStorage.setItem('frontend_errors', JSON.stringify(errors));
|
||||
} catch (e) {
|
||||
console.error('Failed to save error to localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 스토리지에서 오류 목록 조회
|
||||
* @returns {Array} 오류 목록
|
||||
*/
|
||||
getLocalErrors() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('frontend_errors') || '[]');
|
||||
} catch (e) {
|
||||
console.error('Failed to get errors from localStorage:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 스토리지 오류 삭제
|
||||
*/
|
||||
clearLocalErrors() {
|
||||
try {
|
||||
localStorage.removeItem('frontend_errors');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear errors from localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 ID 조회
|
||||
* @returns {string} 세션 ID
|
||||
*/
|
||||
getSessionId() {
|
||||
let sessionId = sessionStorage.getItem('error_session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
sessionStorage.setItem('error_session_id', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 ID 조회
|
||||
* @returns {string|null} 사용자 ID
|
||||
*/
|
||||
getUserId() {
|
||||
try {
|
||||
const userData = JSON.parse(localStorage.getItem('user_data') || '{}');
|
||||
return userData.user_id || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지연 함수
|
||||
* @param {number} ms - 지연 시간 (밀리초)
|
||||
*/
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 로깅 활성화/비활성화
|
||||
* @param {boolean} enabled - 활성화 여부
|
||||
*/
|
||||
setEnabled(enabled) {
|
||||
this.isEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 오류 보고
|
||||
* @param {string} message - 오류 메시지
|
||||
* @param {Object} details - 추가 세부사항
|
||||
*/
|
||||
reportError(message, details = {}) {
|
||||
this.logError({
|
||||
type: 'manual_report',
|
||||
message: message,
|
||||
details: details,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
level: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 경고 로깅
|
||||
* @param {string} message - 경고 메시지
|
||||
* @param {Object} details - 추가 세부사항
|
||||
*/
|
||||
reportWarning(message, details = {}) {
|
||||
this.logError({
|
||||
type: 'warning',
|
||||
message: message,
|
||||
details: details,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
level: 'warning'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 내보내기
|
||||
const errorLogger = new ErrorLogger();
|
||||
|
||||
export default errorLogger;
|
||||
|
||||
// 편의 함수들 내보내기
|
||||
export const logError = (error, context) => errorLogger.logError({ ...error, context });
|
||||
export const logApiError = (error, endpoint, requestData) => errorLogger.logApiError(error, endpoint, requestData);
|
||||
export const logUserActionError = (action, error, context) => errorLogger.logUserActionError(action, error, context);
|
||||
export const logPerformanceIssue = (operation, duration, details) => errorLogger.logPerformanceIssue(operation, duration, details);
|
||||
export const reportError = (message, details) => errorLogger.reportError(message, details);
|
||||
export const reportWarning = (message, details) => errorLogger.reportWarning(message, details);
|
||||
Reference in New Issue
Block a user