diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index c7dbd82..7ee08bf 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -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', diff --git a/backend/app/auth/auth_controller.py b/backend/app/auth/auth_controller.py index d6347e7..15c3474 100644 --- a/backend/app/auth/auth_controller.py +++ b/backend/app/auth/auth_controller.py @@ -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="비밀번호 변경 중 오류가 발생했습니다" + ) diff --git a/backend/app/auth/models.py b/backend/app/auth/models.py index a11b42a..4f98339 100644 --- a/backend/app/auth/models.py +++ b/backend/app/auth/models.py @@ -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): diff --git a/backend/app/auth/setup_controller.py b/backend/app/auth/setup_controller.py new file mode 100644 index 0000000..05ede5a --- /dev/null +++ b/backend/app/auth/setup_controller.py @@ -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="시스템 리셋 중 오류가 발생했습니다" + ) diff --git a/backend/app/main.py b/backend/app/main.py index af5cfab..a681ef8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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}") diff --git a/backend/scripts/create_system_admin.py b/backend/scripts/create_system_admin.py new file mode 100755 index 0000000..e3feb54 --- /dev/null +++ b/backend/scripts/create_system_admin.py @@ -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() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 61875d4..9985be5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 관리 시스템

- {user?.full_name || user?.username}님 환영합니다 + {user?.name || user?.username}님 환영합니다

- + + {/* 사용자 메뉴 */} +
+ + + {/* 드롭다운 메뉴 */} + {showUserMenu && ( +
+
+
+ {user?.name || user?.username} +
+
+ {user?.email || '이메일 없음'} +
+
+ + + + +
+ )} +
{/* 메인 콘텐츠 */} @@ -275,14 +445,14 @@ function App() {

- 관리자 전용 + {feature.badge} 전용
+ + + + + + + {/* 개발 환경에서만 상세 오류 정보 표시 */} + {process.env.NODE_ENV === 'development' && this.state.error && ( +
+ + 개발자 정보 (클릭하여 펼치기) + + +
+ 오류 메시지: +
+                                        {this.state.error.message}
+                                    
+
+ +
+ 스택 트레이스: +
+                                        {this.state.error.stack}
+                                    
+
+ + {this.state.errorInfo?.componentStack && ( +
+ 컴포넌트 스택: +
+                                            {this.state.errorInfo.componentStack}
+                                        
+
+ )} +
+ )} + +
+ 💡 도움말: 문제가 계속 발생하면 페이지를 새로고침하거나 + 브라우저 캐시를 삭제해보세요. +
+ + + ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx new file mode 100644 index 0000000..4e45d75 --- /dev/null +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -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 ( +
+ {/* 헤더 */} +
+ +
+

+ ⚙️ 계정 설정 +

+

+ 프로필 정보 및 비밀번호를 관리하세요 +

+
+
+ +
+ {/* 탭 메뉴 */} +
+ + +
+ + {/* 메시지 표시 */} + {message.text && ( +
+ {message.text} +
+ )} + + {/* 프로필 정보 탭 */} + {activeTab === 'profile' && ( +
+

+ 프로필 정보 +

+ +
+
+ {/* 사용자명 (읽기 전용) */} +
+ + +

+ 사용자명은 변경할 수 없습니다 +

+
+ + {/* 이름 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.name ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.name && ( +

+ {validationErrors.name} +

+ )} +
+ + {/* 이메일 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.email ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.email && ( +

+ {validationErrors.email} +

+ )} +
+ + {/* 부서 및 직책 */} +
+
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = '#d1d5db'} + /> +
+
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = '#d1d5db'} + /> +
+
+ + {/* 역할 (읽기 전용) */} +
+ + +

+ 역할은 시스템 관리자만 변경할 수 있습니다 +

+
+
+ + +
+
+ )} + + {/* 비밀번호 변경 탭 */} + {activeTab === 'password' && ( +
+

+ 비밀번호 변경 +

+ +
+
+ {/* 현재 비밀번호 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.currentPassword ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.currentPassword && ( +

+ {validationErrors.currentPassword} +

+ )} +
+ + {/* 새 비밀번호 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.newPassword ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.newPassword && ( +

+ {validationErrors.newPassword} +

+ )} +
+ + {/* 새 비밀번호 확인 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.confirmPassword ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.confirmPassword && ( +

+ {validationErrors.confirmPassword} +

+ )} +
+
+ + +
+ + {/* 보안 안내 */} +
+

+ 🔐 보안 안내 +

+
    +
  • 비밀번호는 8자 이상으로 설정해주세요
  • +
  • 영문, 숫자, 특수문자를 조합하여 사용하는 것을 권장합니다
  • +
  • 정기적으로 비밀번호를 변경해주세요
  • +
  • 다른 사이트와 동일한 비밀번호 사용을 피해주세요
  • +
+
+
+ )} +
+
+ ); +}; + +export default AccountSettingsPage; diff --git a/frontend/src/pages/LogMonitoringPage.jsx b/frontend/src/pages/LogMonitoringPage.jsx new file mode 100644 index 0000000..367bfaf --- /dev/null +++ b/frontend/src/pages/LogMonitoringPage.jsx @@ -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 ( +
+ {/* 헤더 */} +
+
+ +
+

+ 📈 로그 모니터링 +

+

+ 실시간 시스템 활동 및 오류 모니터링 +

+
+
+ +
+ + + {frontendErrors.length > 0 && ( + + )} +
+
+ +
+ {/* 메시지 표시 */} + {message.text && ( +
+ {message.text} +
+ )} + + {/* 통계 카드 */} +
+
+
+
👥
+

+ 전체 사용자 +

+
+
+ {stats.totalUsers} +
+
+ 활성: {stats.activeUsers}명 +
+
+ +
+
+
+

+ 오늘 로그인 +

+
+
+ {stats.todayLogins} +
+
+ 성공한 로그인 +
+
+ +
+
+
+

+ 로그인 실패 +

+
+
+ {stats.failedLogins} +
+
+ 오늘 실패 횟수 +
+
+ +
+
+
🐛
+

+ 최근 오류 +

+
+
+ {stats.recentErrors} +
+
+ 24시간 내 +
+
+
+ + {/* 콘텐츠 그리드 */} +
+ {/* 최근 활동 */} +
+
+

+ 🔐 최근 로그인 활동 +

+
+ +
+ {isLoading ? ( +
+
로딩 중...
+
+ ) : recentActivity.length === 0 ? ( +
+
최근 활동이 없습니다
+
+ ) : ( + recentActivity.map((activity, index) => ( +
+
+ {getActivityIcon(activity.login_status)} +
+
+
+ {activity.name} +
+
+ @{activity.username} • {activity.ip_address} +
+ {activity.failure_reason && ( +
+ {activity.failure_reason} +
+ )} +
+
+ {formatDateTime(activity.login_time)} +
+
+ )) + )} +
+
+ + {/* 프론트엔드 오류 */} +
+
+

+ 🐛 프론트엔드 오류 +

+
+ +
+ {frontendErrors.length === 0 ? ( +
+
최근 오류가 없습니다
+
+ ) : ( + frontendErrors.map((error, index) => ( +
+
+ {getErrorTypeIcon(error.type)} +
+
+
+ {error.type?.replace('_', ' ').toUpperCase() || 'ERROR'} +
+
+ {error.message?.substring(0, 100)} + {error.message?.length > 100 && '...'} +
+
+ {error.url && ( + {new URL(error.url).pathname} • + )} + {formatDateTime(error.timestamp)} +
+
+
+ )) + )} +
+
+
+ + {/* 자동 새로고침 안내 */} +
+

+ 📊 이 페이지는 30초마다 자동으로 새로고침됩니다 +

+
+
+
+ ); +}; + +export default LogMonitoringPage; diff --git a/frontend/src/pages/SystemLogsPage.jsx b/frontend/src/pages/SystemLogsPage.jsx new file mode 100644 index 0000000..7a107af --- /dev/null +++ b/frontend/src/pages/SystemLogsPage.jsx @@ -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 ( + + {status === 'success' ? '성공' : '실패'} + + ); + }; + + 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 ( + + {level} + + ); + }; + + 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 ( +
+ {/* 헤더 */} +
+ +
+

+ 📊 시스템 로그 +

+

+ 로그인 기록과 시스템 오류 로그를 조회하세요 +

+
+
+ +
+ {/* 탭 메뉴 */} +
+ + +
+ + {/* 필터 */} +
+
+ {activeTab === 'login' && ( +
+ + +
+ )} + + {activeTab === 'system' && ( +
+ + +
+ )} + +
+ + +
+ + +
+
+ + {/* 메시지 표시 */} + {message.text && ( +
+ {message.text} +
+ )} + + {/* 로그 테이블 */} +
+
+

+ {activeTab === 'login' ? '로그인 로그' : '시스템 로그'} + ({activeTab === 'login' ? loginLogs.length : systemLogs.length}개) +

+
+ + {isLoading ? ( +
+
로딩 중...
+
+ ) : ( +
+ {activeTab === 'login' ? ( + + + + + + + + + + + + {loginLogs.length === 0 ? ( + + + + ) : ( + loginLogs.map((log, index) => ( + + + + + + + + )) + )} + +
시간사용자상태IP 주소실패 사유
+ 로그인 로그가 없습니다 +
+ {formatDateTime(log.login_time)} + +
+ {log.name} +
+
+ @{log.username} +
+
+ {getStatusBadge(log.login_status)} + + {log.ip_address || '-'} + + {log.failure_reason || '-'} +
+ ) : ( + + + + + + + + + + + {systemLogs.length === 0 ? ( + + + + ) : ( + systemLogs.map((log, index) => ( + + + + + + + )) + )} + +
시간레벨모듈메시지
+ 시스템 로그가 없습니다 +
+ {formatDateTime(log.timestamp)} + + {getLevelBadge(log.level)} + + {log.module || '-'} + + {log.message} +
+ )} +
+ )} +
+
+
+ ); +}; + +export default SystemLogsPage; diff --git a/frontend/src/pages/SystemSetupPage.jsx b/frontend/src/pages/SystemSetupPage.jsx new file mode 100644 index 0000000..ead36ff --- /dev/null +++ b/frontend/src/pages/SystemSetupPage.jsx @@ -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 ( +
+
+ {/* 헤더 */} +
+
🚀
+

+ 시스템 초기 설정 +

+

+ TK-MP 시스템을 처음 사용하시는군요!
+ 시스템 관리자 계정을 생성해주세요. +

+
+ + {/* 폼 */} +
+ {/* 사용자명 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.username ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.username && ( +

+ {validationErrors.username} +

+ )} +
+ + {/* 이름 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.name ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.name && ( +

+ {validationErrors.name} +

+ )} +
+ + {/* 비밀번호 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.password ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.password && ( +

+ {validationErrors.password} +

+ )} +
+ + {/* 비밀번호 확인 */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.confirmPassword ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.confirmPassword && ( +

+ {validationErrors.confirmPassword} +

+ )} +
+ + {/* 이메일 (선택사항) */} +
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = validationErrors.email ? '#ef4444' : '#d1d5db'} + /> + {validationErrors.email && ( +

+ {validationErrors.email} +

+ )} +
+ + {/* 부서/직책 (선택사항) */} +
+
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = '#d1d5db'} + /> +
+
+ + e.target.style.borderColor = '#3b82f6'} + onBlur={(e) => e.target.style.borderColor = '#d1d5db'} + /> +
+
+ + {/* 에러 메시지 */} + {error && ( +
+

+ {error} +

+
+ )} + + {/* 제출 버튼 */} + +
+ + {/* 안내 메시지 */} +
+

+ 💡 안내: 시스템 관리자는 모든 권한을 가지며, 다른 사용자 계정을 생성하고 관리할 수 있습니다. + 설정 완료 후 이 계정으로 로그인하여 추가 사용자를 생성하세요. +

+
+
+ + {/* CSS 애니메이션 */} + +
+ ); +}; + +export default SystemSetupPage; diff --git a/frontend/src/pages/UserManagementPage.jsx b/frontend/src/pages/UserManagementPage.jsx index 1d3b6e5..f6b33f5 100644 --- a/frontend/src/pages/UserManagementPage.jsx +++ b/frontend/src/pages/UserManagementPage.jsx @@ -1,410 +1,643 @@ import React, { useState, useEffect } from 'react'; -import { useAuth } from '../contexts/AuthContext'; import api from '../api'; -import './UserManagementPage.css'; +import { reportError, logUserActionError } from '../utils/errorLogger'; -const UserManagementPage = () => { - const { user, hasPermission, isAdmin } = useAuth(); +const UserManagementPage = ({ onNavigate, user }) => { const [users, setUsers] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(''); - const [showCreateForm, setShowCreateForm] = useState(false); - const [editingUser, setEditingUser] = useState(null); - - const [formData, setFormData] = useState({ + const [message, setMessage] = useState({ type: '', text: '' }); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showRoleModal, setShowRoleModal] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + + // 새 사용자 생성 폼 데이터 + const [createFormData, setCreateFormData] = useState({ username: '', password: '', name: '', email: '', - role: 'user', - access_level: 'worker', department: '', position: '', - phone: '', - is_active: true, - permissions: [] + role: 'user' }); - // 권한 목록 정의 - const availablePermissions = [ - { id: 'bom.view', name: 'BOM 조회', category: 'BOM' }, - { id: 'bom.edit', name: 'BOM 편집', category: 'BOM' }, - { id: 'bom.delete', name: 'BOM 삭제', category: 'BOM' }, - { id: 'project.view', name: '프로젝트 조회', category: '프로젝트' }, - { id: 'project.create', name: '프로젝트 생성', category: '프로젝트' }, - { id: 'project.edit', name: '프로젝트 편집', category: '프로젝트' }, - { id: 'project.delete', name: '프로젝트 삭제', category: '프로젝트' }, - { id: 'file.upload', name: '파일 업로드', category: '파일' }, - { id: 'file.download', name: '파일 다운로드', category: '파일' }, - { id: 'file.delete', name: '파일 삭제', category: '파일' }, - { id: 'user.view', name: '사용자 조회', category: '사용자' }, - { id: 'user.create', name: '사용자 생성', category: '사용자' }, - { id: 'user.edit', name: '사용자 편집', category: '사용자' }, - { id: 'user.delete', name: '사용자 삭제', category: '사용자' }, - { id: 'system.admin', name: '시스템 관리', category: '시스템' } - ]; + const [validationErrors, setValidationErrors] = useState({}); - const roleOptions = [ - { value: 'admin', label: '관리자', description: '모든 권한' }, - { value: 'system', label: '시스템', description: '시스템 관리' }, - { value: 'leader', label: '팀장', description: '팀 관리' }, - { value: 'support', label: '지원', description: '지원 업무' }, - { value: 'user', label: '사용자', description: '일반 사용자' } - ]; + useEffect(() => { + loadUsers(); + }, []); - const accessLevelOptions = [ - { value: 'manager', label: '관리자', description: '전체 관리 권한' }, - { value: 'leader', label: '팀장', description: '팀 관리 권한' }, - { value: 'worker', label: '작업자', description: '기본 작업 권한' }, - { value: 'viewer', label: '조회자', description: '조회 전용' } - ]; - - // 사용자 목록 조회 - const fetchUsers = async () => { + const loadUsers = async () => { try { setIsLoading(true); const response = await api.get('/auth/users'); + if (response.data.success) { setUsers(response.data.users); + } else { + setMessage({ type: 'error', text: '사용자 목록을 불러올 수 없습니다' }); } - } catch (error) { - console.error('Failed to fetch users:', error); - setError('사용자 목록을 불러오는데 실패했습니다.'); + } catch (err) { + console.error('Load users error:', err); + setMessage({ type: 'error', text: '사용자 목록 조회 중 오류가 발생했습니다' }); + logUserActionError('load_users', err, { userId: user?.user_id }); } finally { setIsLoading(false); } }; - useEffect(() => { - if (hasPermission('user.view') || isAdmin()) { - fetchUsers(); - } - }, []); - - // 폼 데이터 변경 처리 - const handleFormChange = (e) => { - const { name, value, type, checked } = e.target; - setFormData(prev => ({ - ...prev, - [name]: type === 'checkbox' ? checked : value - })); - }; - - // 권한 변경 처리 - const handlePermissionChange = (permissionId, checked) => { - setFormData(prev => ({ - ...prev, - permissions: checked - ? [...prev.permissions, permissionId] - : prev.permissions.filter(p => p !== permissionId) - })); - }; - - // 사용자 생성 const handleCreateUser = async (e) => { e.preventDefault(); - try { - const response = await api.post('/auth/register', formData); - if (response.data.success) { - setShowCreateForm(false); - setFormData({ - username: '', - password: '', - name: '', - email: '', - role: 'user', - access_level: 'worker', - department: '', - position: '', - phone: '', - is_active: true, - permissions: [] - }); - await fetchUsers(); - } - } catch (error) { - console.error('Failed to create user:', error); - setError(error.response?.data?.error?.message || '사용자 생성에 실패했습니다.'); + + if (!validateCreateForm()) { + return; } - }; - // 사용자 편집 - const handleEditUser = (userData) => { - setEditingUser(userData.user_id); - setFormData({ - username: userData.username, - password: '', - name: userData.name, - email: userData.email, - role: userData.role, - access_level: userData.access_level, - department: userData.department || '', - position: userData.position || '', - phone: userData.phone || '', - is_active: userData.is_active, - permissions: userData.permissions || [] - }); - setShowCreateForm(true); - }; - - // 사용자 업데이트 - const handleUpdateUser = async (e) => { - e.preventDefault(); try { - const updateData = { ...formData }; - if (!updateData.password) { - delete updateData.password; // 비밀번호가 비어있으면 제외 - } + setIsLoading(true); + const response = await api.post('/auth/register', createFormData); - const response = await api.put(`/auth/users/${editingUser}`, updateData); if (response.data.success) { - setShowCreateForm(false); - setEditingUser(null); - setFormData({ + setMessage({ type: 'success', text: '사용자가 성공적으로 생성되었습니다' }); + setShowCreateModal(false); + setCreateFormData({ username: '', password: '', name: '', email: '', - role: 'user', - access_level: 'worker', department: '', position: '', - phone: '', - is_active: true, - permissions: [] + role: 'user' }); - await fetchUsers(); + await loadUsers(); + } else { + setMessage({ type: 'error', text: response.data.message || '사용자 생성에 실패했습니다' }); } - } catch (error) { - console.error('Failed to update user:', error); - setError(error.response?.data?.error?.message || '사용자 수정에 실패했습니다.'); + } catch (err) { + console.error('Create user error:', err); + const errorMessage = err.response?.data?.detail || '사용자 생성 중 오류가 발생했습니다'; + setMessage({ type: 'error', text: errorMessage }); + logUserActionError('create_user', err, { formData: createFormData }); + } finally { + setIsLoading(false); } }; - // 사용자 활성화/비활성화 - const handleToggleUserStatus = async (userId, currentStatus) => { + const handleChangeRole = async (newRole) => { + if (!selectedUser) return; + try { - const response = await api.put(`/auth/users/${userId}`, { - is_active: !currentStatus - }); + setIsLoading(true); + const response = await api.put(`/auth/users/${selectedUser.user_id}/role?new_role=${newRole}`); + if (response.data.success) { - await fetchUsers(); + setMessage({ type: 'success', text: `${selectedUser.name}의 역할이 변경되었습니다` }); + setShowRoleModal(false); + setSelectedUser(null); + await loadUsers(); + } else { + setMessage({ type: 'error', text: response.data.message || '역할 변경에 실패했습니다' }); } - } catch (error) { - console.error('Failed to toggle user status:', error); - setError('사용자 상태 변경에 실패했습니다.'); + } catch (err) { + console.error('Change role error:', err); + const errorMessage = err.response?.data?.detail || '역할 변경 중 오류가 발생했습니다'; + setMessage({ type: 'error', text: errorMessage }); + logUserActionError('change_role', err, { userId: selectedUser.user_id, newRole }); + } finally { + setIsLoading(false); } }; - if (!hasPermission('user.view') && !isAdmin()) { - return ( -
-

접근 권한이 없습니다

-

사용자 관리 페이지에 접근할 권한이 없습니다.

-
- ); - } + const handleDeleteUser = async (userToDelete) => { + if (!window.confirm(`정말로 ${userToDelete.name} 사용자를 삭제하시겠습니까?`)) { + return; + } + + try { + setIsLoading(true); + const response = await api.delete(`/auth/users/${userToDelete.user_id}`); + + if (response.data.success) { + setMessage({ type: 'success', text: `${userToDelete.name} 사용자가 삭제되었습니다` }); + await loadUsers(); + } else { + setMessage({ type: 'error', text: response.data.message || '사용자 삭제에 실패했습니다' }); + } + } catch (err) { + console.error('Delete user error:', err); + const errorMessage = err.response?.data?.detail || '사용자 삭제 중 오류가 발생했습니다'; + setMessage({ type: 'error', text: errorMessage }); + logUserActionError('delete_user', err, { userId: userToDelete.user_id }); + } finally { + setIsLoading(false); + } + }; + + const validateCreateForm = () => { + const errors = {}; + + if (!createFormData.username.trim()) { + errors.username = '사용자명을 입력해주세요'; + } else if (!/^[a-zA-Z0-9_]+$/.test(createFormData.username)) { + errors.username = '사용자명은 영문, 숫자, 언더스코어만 사용 가능합니다'; + } + + if (!createFormData.password) { + errors.password = '비밀번호를 입력해주세요'; + } else if (createFormData.password.length < 8) { + errors.password = '비밀번호는 8자 이상이어야 합니다'; + } + + if (!createFormData.name.trim()) { + errors.name = '이름을 입력해주세요'; + } + + if (createFormData.email && createFormData.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(createFormData.email)) { + errors.email = '올바른 이메일 형식을 입력해주세요'; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + const getRoleDisplayName = (role) => { + const roleNames = { + 'system': '시스템 관리자', + 'admin': '관리자', + 'user': '사용자' + }; + return roleNames[role] || role; + }; + + const getRoleBadgeColor = (role) => { + const colors = { + 'system': { bg: '#fef2f2', color: '#dc2626' }, + 'admin': { bg: '#fef7e0', color: '#92400e' }, + 'user': { bg: '#f0f9ff', color: '#1e40af' } + }; + return colors[role] || colors.user; + }; return ( -
-
-

👥 사용자 관리

-

시스템 사용자 계정을 관리하고 권한을 설정합니다.

- - {(hasPermission('user.create') || isAdmin()) && ( - - )} +
+

+ 👥 사용자 관리 +

+

+ 시스템 사용자 계정을 생성하고 관리하세요 +

+
+
+ +
- {error && ( -
- ⚠️ - {error} - -
- )} +
+ {/* 메시지 표시 */} + {message.text && ( +
+ {message.text} +
+ )} - {/* 사용자 생성/편집 폼 */} - {showCreateForm && ( -
-
-
-

{editingUser ? '사용자 편집' : '새 사용자 생성'}

- + {/* 사용자 목록 */} +
+
+

+ 등록된 사용자 ({users.length}명) +

+
+ + {isLoading ? ( +
+
로딩 중...
+ ) : users.length === 0 ? ( +
+
등록된 사용자가 없습니다
+
+ ) : ( +
+ + + + + + + + + + + + + {users.map((userItem) => { + const roleColor = getRoleBadgeColor(userItem.role); + return ( + + + + + + + + + ); + })} + +
사용자역할부서/직책상태가입일관리
+
+
+ {userItem.name.charAt(0).toUpperCase()} +
+
+
+ {userItem.name} +
+
+ @{userItem.username} +
+ {userItem.email && ( +
+ {userItem.email} +
+ )} +
+
+
+ + {getRoleDisplayName(userItem.role)} + + +
+ {userItem.department || '-'} +
+
+ {userItem.position || '-'} +
+
+ + {userItem.is_active ? '활성' : '비활성'} + + + {new Date(userItem.created_at).toLocaleDateString('ko-KR')} + +
+ + {userItem.user_id !== user?.user_id && ( + + )} +
+
+
+ )} +
+
-
-
-
- + {/* 사용자 생성 모달 */} + {showCreateModal && ( +
+
+

+ 새 사용자 생성 +

+ + +
+
+ setCreateFormData(prev => ({ ...prev, username: e.target.value }))} + style={{ + width: '100%', + padding: '12px', + border: validationErrors.username ? '2px solid #ef4444' : '1px solid #d1d5db', + borderRadius: '8px', + fontSize: '14px', + boxSizing: 'border-box' + }} /> + {validationErrors.username && ( +

+ {validationErrors.username} +

+ )}
-
- +
+ + setCreateFormData(prev => ({ ...prev, name: e.target.value }))} + style={{ + width: '100%', + padding: '12px', + border: validationErrors.name ? '2px solid #ef4444' : '1px solid #d1d5db', + borderRadius: '8px', + fontSize: '14px', + boxSizing: 'border-box' + }} + /> + {validationErrors.name && ( +

+ {validationErrors.name} +

+ )} +
+ +
+ setCreateFormData(prev => ({ ...prev, password: e.target.value }))} + style={{ + width: '100%', + padding: '12px', + border: validationErrors.password ? '2px solid #ef4444' : '1px solid #d1d5db', + borderRadius: '8px', + fontSize: '14px', + boxSizing: 'border-box' + }} /> + {validationErrors.password && ( +

+ {validationErrors.password} +

+ )}
-
- - -
- -
- +
+ setCreateFormData(prev => ({ ...prev, email: e.target.value }))} + style={{ + width: '100%', + padding: '12px', + border: validationErrors.email ? '2px solid #ef4444' : '1px solid #d1d5db', + borderRadius: '8px', + fontSize: '14px', + boxSizing: 'border-box' + }} /> + {validationErrors.email && ( +

+ {validationErrors.email} +

+ )}
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
-
- {/* 권한 설정 */} -
-

권한 설정

-
- {Object.entries( - availablePermissions.reduce((acc, perm) => { - if (!acc[perm.category]) acc[perm.category] = []; - acc[perm.category].push(perm); - return acc; - }, {}) - ).map(([category, perms]) => ( -
-

{category}

- {perms.map(perm => ( - - ))} -
- ))} -
-
- -
- -
)} - {/* 사용자 목록 */} -
- {isLoading ? ( -
사용자 목록을 불러오는 중...
- ) : ( -
- - - - - - - - - - - - - - - - {users.map(userData => ( - - - - - - - - - - - - ))} - -
사용자명이름이메일역할접근 레벨부서상태마지막 로그인작업
{userData.username}{userData.name}{userData.email} - - {roleOptions.find(r => r.value === userData.role)?.label} - - - - {accessLevelOptions.find(l => l.value === userData.access_level)?.label} - - {userData.department || '-'} - - {userData.is_active ? '활성' : '비활성'} - - - {userData.last_login_at - ? new Date(userData.last_login_at).toLocaleString('ko-KR') - : '없음' - } - -
- {(hasPermission('user.edit') || isAdmin()) && ( - - )} - {(hasPermission('user.edit') || isAdmin()) && ( - - )} -
-
+ {/* 역할 변경 모달 */} + {showRoleModal && selectedUser && ( +
+
+

+ 역할 변경 +

+

+ {selectedUser.name}의 역할을 변경하시겠습니까? +

+ +
+ {['user', 'admin', 'system'].map((role) => ( + + ))} +
+ +
- )} -
+
+ )}
); }; -export default UserManagementPage; +export default UserManagementPage; \ No newline at end of file diff --git a/frontend/src/utils/errorLogger.js b/frontend/src/utils/errorLogger.js new file mode 100644 index 0000000..0c49c40 --- /dev/null +++ b/frontend/src/utils/errorLogger.js @@ -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);