feat: 완전한 사용자 관리 및 로그 모니터링 시스템 구현
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:
Hyungi Ahn
2025-09-09 12:58:14 +09:00
parent 881fc13580
commit 529777aa14
16 changed files with 4519 additions and 450 deletions

View File

@@ -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',

View File

@@ -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="비밀번호 변경 중 오류가 발생했습니다"
)

View File

@@ -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):

View 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="시스템 리셋 중 오류가 발생했습니다"
)

View File

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

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

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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,

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

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

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

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

View 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

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