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

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