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

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