diff --git a/auth_head.js b/auth_head.js new file mode 100644 index 0000000..49acaff Binary files /dev/null and b/auth_head.js differ diff --git a/auth_v0.js b/auth_v0.js new file mode 100644 index 0000000..b87dd46 Binary files /dev/null and b/auth_v0.js differ diff --git a/auth_v1.js b/auth_v1.js new file mode 100644 index 0000000..b87dd46 Binary files /dev/null and b/auth_v1.js differ diff --git a/docs/DIRECTORY_STRUCTURE.md b/docs/DIRECTORY_STRUCTURE.md new file mode 100644 index 0000000..ad15fd8 --- /dev/null +++ b/docs/DIRECTORY_STRUCTURE.md @@ -0,0 +1,71 @@ +# SmartIMS 프로젝트 디렉토리 구조 (Directory Structure) + +본 문서는 SmartIMS 플랫폼의 전체 프로젝트 구조와 각 디렉토리의 역할을 설명합니다. + +--- + +## 1. 전체 구조 개요 + +```text +smartims/ +├── docs/ # 설계서, 가이드, 매뉴얼 등 프로젝트 관련 문서 +├── public/ # 파비콘, 로고 등 정적 자원 +├── server/ # Node.js (Express) 기반 백엔드 서버 +├── src/ # React (Vite) 기반 프론트엔드 어플리케이션 +├── tools/ # 개발 및 관리에 필요한 유틸리티 도구 +├── package.json # 프론트엔드 의존성 및 스크립트 +├── tailwind.config.js # Tailwind CSS 설정 +└── vite.config.ts # Vite 빌드 설정 +``` + +--- + +## 2. 프론트엔드 구조 (`src/`) + +프론트엔드는 **모듈형 아키텍처**를 따르며, 플랫폼 엔진과 독립적인 비즈니스 모듈로 구성됩니다. + +### 2.1 Core & Infrastructure +- `src/core/`: 플랫폼의 핵심 인터페이스 및 공통 타입 정의 (`types.ts`). +- `src/platform/`: 시스템의 뼈대 구현. + - `App.tsx`: 메인 엔트리 및 전체 레이아웃 구성. + - `ModuleLoader.tsx`: 등록된 모듈을 동적으로 로드하고 라우팅을 구성. + - `styles/`: 전역 스타일 및 디자인 토큰(`global.css`) 관리. +- `src/app/`: Context Provider(인증, 시스템 설정 등)와 같은 전역 상태 관리. + +### 2.2 Functional Components +- `src/modules/`: 독립적인 비즈니스 영역. 각 폴더는 하나의 서비스 모듈을 의미함. + - 예: `asset/`, `cctv/`, `license/` 등. + - 각 모듈은 `module.tsx` (또는 `index.tsx`)를 진입점으로 가짐. +- `src/widgets/`: 여러 페이지에서 재사용되는 복합 UI 블록 (예: `MainLayout`, `Sidebar`). +- `src/shared/`: 프로젝트 전역에서 사용되는 공통 UI 컴포넌트, 유틸리티, API 클라이언트. + +--- + +## 3. 백엔드 구조 (`server/`) + +백엔드는 RESTful API 제공 및 데이터베이스 관리를 담당합니다. + +- `server/index.js`: 서버 엔트리 포인트. 환경 설정, 미들웨어 등록, 라우트 연결 총괄. +- `server/routes/`: API 엔드포인트 정의. (예: `auth.js`, `system.js`, `asset.js`). +- `server/middleware/`: 요청 처리 과정에서 동작하는 공통 로직 (인증 필터, 에러 핸들러). +- `server/configs/`: 데이터베이스 연결 (`db.js`) 및 외부 서비스 설정. +- `server/utils/`: 서버 측 공통 유틸리티 (로그 관리, 파일 업로드 등). +- `server/schema.sql`: 데이터베이스 테이블 명세 및 초기 데이터. + +--- + +## 4. 기타 주요 디렉토리 + +- `docs/`: + - `MODULAR_ARCHITECTURE_GUIDE.md`: 모듈화 상세 설계 원칙. + - `DEPLOYMENT_GUIDE.md`: 서버 배포 방법. +- `tools/`: + - 라이선스 관리 도구, 데이터 마이그레이션 스크립트 등 개발 보조 도구 포함. + +--- + +## 5. 아키텍처 원칙 요약 + +1. **느슨한 결합 (Loose Coupling)**: 플랫폼은 모듈 내부 로직을 모르며, 모듈은 정의된 인터페이스를 통해 플랫폼과 통신합니다. +2. **높은 응집도 (High Cohesion)**: 특정 기능(예: 자산 관리)에 필요한 코드는 해당 모듈 폴더(`src/modules/asset/`) 내에 모여 있어야 합니다. +3. **디자인 시스템 준수**: 모든 스타일은 플랫폼이 제공하는 CSS Variables를 사용하여 일관성을 유지합니다. diff --git a/docs/ENVIRONMENT_SETUP.md b/docs/ENVIRONMENT_SETUP.md new file mode 100644 index 0000000..2706c60 --- /dev/null +++ b/docs/ENVIRONMENT_SETUP.md @@ -0,0 +1,45 @@ +# SmartIMS 환경 설정 가이드 (Environment Setup Guide) + +본 문서는 SmartIMS의 개발 환경(Windows)과 운영 환경(Synology NAS) 설정을 관리하기 위한 지침을 제공합니다. + +--- + +## 1. 환경별 주요 설정 (.env) + +`server/.env` 파일은 프로젝트의 핵심 설정을 담고 있습니다. 환경에 따라 아래 주석을 참고하여 값을 변경하세요. + +### 1.1 데이터베이스 (MariaDB) +| 변수명 | 개발 환경 (Windows) | 운영 환경 (Synology) | 설명 | +| :--- | :--- | :--- | :--- | +| `DB_HOST` | `localhost` 또는 `sokuree.com` | `localhost` | DB 서버 주소 | +| `DB_PORT` | `3307` (Docker/XAMPP 등) | `3306` (기본값) | MariaDB 접속 포트 | +| `DB_NAME` | `sokuree_platform_dev` | `sokuree_platform_prod` | 사용할 DB 이름 | + +### 1.2 시스템 및 보안 +- `PORT`: 백엔드 서버 포트 (기본값: `3005`). Synology 서비스와 충돌 시 변경 가능. +- `SESSION_SECRET`: 세션 암호화 키. 운영 환경에서는 고유한 긴 문자열로 변경 권장. +- `LICENSE_MANAGER_URL`: 라이선스 서버 주소. + +--- + +## 2. 개발 및 운영 환경 차이점 정리 + +### 2.1 스트리밍 전송 모드 (`CCTV_TRANSPORT_OVERRIDE`) +- **개발(Windows)**: `auto` 또는 `udp` 사용. (TCP 사용 시 권한 문제로 스트리밍이 끊길 수 있음) +- **운영(NAS)**: `tcp` 권한이 안정적이므로 `tcp` 설정을 권장합니다. + +### 2.2 API 프록시 (Vite) +- 개발 시에는 `vite.config.ts`의 `proxy` 설정을 통해 프론트엔드가 백엔드(`localhost:3005`)와 통신합니다. +- 운영 시에는 `npm run build`로 생성된 `dist` 폴더를 Node.js(Express)가 직접 서빙하므로 프록시 설정이 필요 없으며 동일 포트에서 동작합니다. + +### 2.3 파일 경로 (Uploads) +- 업로드된 이미지는 `server/uploads` 디렉토리에 저장됩니다. +- 운영 환경으로 이전 시 이 디렉토리의 쓰기 권한이 Node.js 실행 계정에 있는지 확인해야 합니다. + +--- + +## 3. 체크리스트: 자산 목록이 안 보일 때 +1. **서버-DB 연결 확인**: 서버 로그(`npm start`)에서 `✅ Connected to Database` 메시지가 나오는지 확인하세요. +2. **모듈 활성화 확인**: [시스템 관리] > [모듈/라이선스 관리] 메뉴에서 '자산 관리' 모듈이 활성화 상태인지 확인하세요. +3. **구독자 ID 일치**: 라이선스 키 등록 시 사용된 구독자 ID(`SKR-2024-...`)가 [기본 설정]의 구독자 ID와 일치해야 합니다. +4. **브라우저 캐시**: 프론트엔드 빌드 후 변경 사항이 반영되지 않으면 강력 새로고침(`Ctrl + F5`)을 수행하세요. diff --git a/history.txt b/history.txt new file mode 100644 index 0000000..cc1234b Binary files /dev/null and b/history.txt differ diff --git a/package.json b/package.json index 6f638ad..2d26323 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "smartims", "private": true, - "version": "0.2.0", + "version": "0.2.5", "type": "module", "scripts": { "dev": "vite", diff --git a/server/fix_admin.js b/server/fix_admin.js new file mode 100644 index 0000000..8eabdb7 --- /dev/null +++ b/server/fix_admin.js @@ -0,0 +1,36 @@ +const db = require('./db'); +const crypto = require('crypto'); + +async function fixAdmin() { + try { + console.log('Altering table schema to include supervisor role...'); + // First ensure the ENUM includes supervisor + await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'"); + + const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex'); + console.log('Updating admin user...'); + + // Update both password and role for admin + const [result] = await db.query( + 'UPDATE users SET password = ?, role = "supervisor" WHERE id = "admin"', + [hashedPass] + ); + + if (result.affectedRows > 0) { + console.log('✅ Admin user updated to Supervisor with password admin123'); + } else { + console.log('⚠️ Admin user not found. Creating new admin...'); + await db.query( + 'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)', + ['admin', hashedPass, '시스템 관리자', 'supervisor', 'IT팀', '관리자'] + ); + console.log('✅ Admin user created as Supervisor with password admin123'); + } + } catch (err) { + console.error('❌ Failed to fix admin user:', err); + } finally { + process.exit(); + } +} + +fixAdmin(); diff --git a/server/index.js b/server/index.js index e3ac82a..b790aef 100644 --- a/server/index.js +++ b/server/index.js @@ -66,12 +66,13 @@ app.use(session({ key: 'smartims_sid', secret: process.env.SESSION_SECRET || 'smartims_session_secret_key', store: sessionStore, - resave: true, // Force save to avoid session loss in some environments + resave: false, saveUninitialized: false, + rolling: false, // Do not automatic rolling (we control it in middleware) cookie: { httpOnly: true, - secure: false, // Set true if using HTTPS - maxAge: null, // Browser session by default + secure: false, // HTTPS 사용 시 true로 변경 필요 + maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정) sameSite: 'lax' } })); @@ -79,13 +80,24 @@ app.use(session({ // Dynamic Session Timeout Middleware app.use(async (req, res, next) => { if (req.session && req.session.user) { + // Skip session extension for background check requests + // These requests are prefixed by /api from the client but might be handled differently in middleware + // Checking both common forms for safety + if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) { + return next(); + } + try { const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60; req.session.cookie.maxAge = timeoutMinutes * 60 * 1000; - // Explicitly save session to ensure store sync - req.session.save(); + // Explicitly save session before moving to next middleware + req.session.save((err) => { + if (err) console.error('Session save error:', err); + next(); + }); + return; } catch (err) { console.error('Session timeout fetch error:', err); } @@ -98,7 +110,10 @@ app.use(csrfProtection); // Request Logger app.use((req, res, next) => { - console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); + const now = new Date(); + // UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성 + const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', ''); + console.log(`[${kstDate}] ${req.method} ${req.url}`); next(); }); @@ -180,14 +195,21 @@ const initTables = async () => { department VARCHAR(100), position VARCHAR(100), phone VARCHAR(255), - role ENUM('admin', 'user') DEFAULT 'user', + role ENUM('supervisor', 'admin', 'user') DEFAULT 'user', last_login TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(usersTableSQL); - console.log('✅ Users Table Created'); + + // Update existing table if needed + try { + await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'"); + } catch (e) { + // Ignore if it fails (e.g. column doesn't exist yet handled by SQL above) + } + console.log('✅ Users Table Initialized with Supervisor role'); // Default Admin const adminId = 'admin'; @@ -197,9 +219,12 @@ const initTables = async () => { const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex'); await db.query( 'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)', - [adminId, hashedPass, '시스템 관리자', 'admin', 'IT팀', '관리자'] + [adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자'] ); - console.log('✅ Default Admin Created (admin / admin123)'); + console.log('✅ Default Admin Created as Supervisor'); + } else { + // Ensure existing admin has supervisor role for this transition + await db.query('UPDATE users SET role = "supervisor" WHERE id = ?', [adminId]); } } @@ -333,8 +358,14 @@ const initTables = async () => { }; initTables(); +const packageJson = require('./package.json'); + app.get('/api/health', (req, res) => { - res.json({ status: 'ok', version: '1.2.0', timestamp: '2026-01-22 21:18' }); + res.json({ + status: 'ok', + version: packageJson.version, + timestamp: new Date().toISOString().replace('T', ' ').split('.')[0] + }); }); // Routes diff --git a/server/middleware/authMiddleware.js b/server/middleware/authMiddleware.js index 98c1436..779c396 100644 --- a/server/middleware/authMiddleware.js +++ b/server/middleware/authMiddleware.js @@ -1,3 +1,15 @@ +const ROLES = { + SUPERVISOR: 'supervisor', + ADMIN: 'admin', + USER: 'user' +}; + +const HIERARCHY = { + [ROLES.SUPERVISOR]: 100, + [ROLES.ADMIN]: 50, + [ROLES.USER]: 10 +}; + const isAuthenticated = (req, res, next) => { if (req.session && req.session.user) { return next(); @@ -5,13 +17,17 @@ const isAuthenticated = (req, res, next) => { return res.status(401).json({ success: false, message: 'Unauthorized' }); }; -const hasRole = (...roles) => { +const hasRole = (requiredRole) => { return (req, res, next) => { if (!req.session || !req.session.user) { return res.status(401).json({ success: false, message: 'Unauthorized' }); } - if (roles.includes(req.session.user.role)) { + const userRole = req.session.user.role; + const userLevel = HIERARCHY[userRole] || 0; + const requiredLevel = HIERARCHY[requiredRole] || 999; + + if (userLevel >= requiredLevel) { return next(); } @@ -19,4 +35,4 @@ const hasRole = (...roles) => { }; }; -module.exports = { isAuthenticated, hasRole }; +module.exports = { isAuthenticated, hasRole, ROLES }; diff --git a/server/middleware/csrfMiddleware.js b/server/middleware/csrfMiddleware.js index 785efeb..550e9e9 100644 --- a/server/middleware/csrfMiddleware.js +++ b/server/middleware/csrfMiddleware.js @@ -28,6 +28,7 @@ const csrfProtection = (req, res, next) => { console.error(`- Path: ${req.path}`); console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`); console.error(`- Session User: ${req.session?.user?.id || 'GUEST'}`); + console.error(`- Session MaxAge: ${req.session?.cookie?.maxAge / 1000 / 60} min`); console.error(`- Token in Session: ${tokenFromSession ? 'EXISTS (' + tokenFromSession.substring(0, 5) + '...)' : 'MISSING'}`); console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`); diff --git a/server/package.json b/server/package.json index bb05b90..af2e6c1 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.1.0", + "version": "0.2.5", "description": "", "main": "index.js", "scripts": { @@ -28,4 +28,4 @@ "devDependencies": { "nodemon": "^3.1.11" } -} +} \ No newline at end of file diff --git a/server/routes/auth.js b/server/routes/auth.js index e7be500..402da7d 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -5,54 +5,14 @@ const crypto = require('crypto'); const { isAuthenticated, hasRole } = require('../middleware/authMiddleware'); const { generateToken } = require('../middleware/csrfMiddleware'); -// --- Crypto Utilities --- -// Use a fixed key for MVP. In production, store this securely in .env -// Key must be 32 bytes for aes-256-cbc -// 'my_super_secret_key_manage_asset' is 32 chars? -// let's use a simpler approach to ensure length on startup or fallback -const SECRET_KEY = process.env.ENCRYPTION_KEY || 'smartims_secret_key_0123456789'; // 32 chars needed -// Ideally use a buffer from hex, but string is okay if 32 chars. -// Let's pad it to ensure stability if env is missing. -const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32); +const cryptoUtil = require('../utils/cryptoUtil'); -const ALGORITHM = 'aes-256-cbc'; - -function encrypt(text) { - if (!text) return text; - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - return iv.toString('hex') + ':' + encrypted; +async function encrypt(text) { + return await cryptoUtil.encrypt(text); } -function decrypt(text) { - if (!text) return text; - // Check if it looks like our encrypted format (hexIV:hexContent) - if (!text.includes(':')) { - return text; // Assume plain text if no separator - } - - try { - const textParts = text.split(':'); - const ivHex = textParts.shift(); - - // IV for AES-256-CBC must be 16 bytes (32 hex characters) - if (!ivHex || ivHex.length !== 32) { - return text; // Invalid IV length, return original - } - - const iv = Buffer.from(ivHex, 'hex'); - const encryptedText = textParts.join(':'); - - const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); - let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; - } catch (e) { - console.error('Decryption failed for:', text, e.message); - return text; // Return original if fail - } +async function decrypt(text) { + return await cryptoUtil.decrypt(text); } function hashPassword(password) { @@ -77,7 +37,7 @@ router.post('/login', async (req, res) => { delete user.password; // Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it - if (user.phone) user.phone = decrypt(user.phone); + if (user.phone) user.phone = await decrypt(user.phone); // Save user to session req.session.user = user; @@ -104,19 +64,30 @@ router.post('/login', async (req, res) => { }); // 1.5. Check Session (New) -router.get('/check', (req, res) => { - if (req.session.user) { - // Ensure CSRF token exists, if not generate one (edge case) - if (!req.session.csrfToken) { - req.session.csrfToken = generateToken(); +router.get('/check', async (req, res) => { + try { + if (req.session.user) { + // Ensure CSRF token exists, if not generate one (edge case) + if (!req.session.csrfToken) { + req.session.csrfToken = generateToken(); + } + + // Fetch session timeout from settings + const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); + const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60; + + res.json({ + isAuthenticated: true, + user: req.session.user, + csrfToken: req.session.csrfToken, + sessionTimeout: timeoutMinutes * 60 * 1000 // Convert to ms + }); + } else { + res.json({ isAuthenticated: false }); } - res.json({ - isAuthenticated: true, - user: req.session.user, - csrfToken: req.session.csrfToken - }); - } else { - res.json({ isAuthenticated: false }); + } catch (err) { + console.error('Check session error:', err); + res.status(500).json({ success: false, message: 'Server error' }); } }); @@ -132,21 +103,52 @@ router.post('/logout', (req, res) => { }); }); +// 1.7 Verify Supervisor (For sensitive settings) +router.post('/verify-supervisor', isAuthenticated, async (req, res) => { + const { password } = req.body; + if (!password) return res.status(400).json({ error: 'Password required' }); + + try { + if (req.session.user.role !== 'supervisor') { + return res.status(403).json({ error: '권한이 없습니다. 최고관리자만 접근 가능합니다.' }); + } + + const hashedPassword = hashPassword(password); + const [rows] = await db.query('SELECT 1 FROM users WHERE id = ? AND password = ?', [req.session.user.id, hashedPassword]); + + if (rows.length > 0) { + res.json({ success: true, message: 'Verification successful' }); + } else { + res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' }); + } + } catch (err) { + console.error('Verify supervisor error:', err); + res.status(500).json({ error: '인증 처리 중 오류가 발생했습니다.' }); + } +}); + // 2. List Users (Admin Only) router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => { try { - // ideally check req.user.role if we had middleware, for now assuming client logic protection + internal/local usage const [rows] = await db.query('SELECT id, name, department, position, phone, role, last_login, created_at, updated_at FROM users ORDER BY created_at DESC'); - const users = rows.map(u => ({ - ...u, - phone: decrypt(u.phone) // Decrypt phone for admin view + if (!rows || rows.length === 0) { + return res.json([]); + } + + // Use Promise.all for safe async decryption + const users = await Promise.all(rows.map(async (u) => { + const decryptedPhone = await decrypt(u.phone); + return { + ...u, + phone: decryptedPhone + }; })); res.json(users); } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); + console.error('Failed to list users:', err); + res.status(500).json({ error: '데이터를 불러오는 중 오류가 발생했습니다.' }); } }); @@ -166,7 +168,7 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => { } const hashedPassword = hashPassword(password); - const encryptedPhone = encrypt(phone); + const encryptedPhone = await encrypt(phone); const sql = ` INSERT INTO users (id, password, name, department, position, phone, role) @@ -213,7 +215,7 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => } if (phone !== undefined) { updates.push('phone = ?'); - params.push(encrypt(phone)); + params.push(await encrypt(phone)); } if (role) { updates.push('role = ?'); diff --git a/server/routes/system.js b/server/routes/system.js index 17a4fb0..0cc8290 100644 --- a/server/routes/system.js +++ b/server/routes/system.js @@ -21,16 +21,72 @@ try { console.error('❌ Error loading public key:', e); } +// Helper to check if a setting key is allowed for general get/post +// This prevents modification of sensitive keys if any +const ALLOWED_SETTING_KEYS = [ + 'subscriber_id', + 'session_timeout', + 'encryption_key', + 'asset_id_rule', + 'asset_categories', + 'asset_locations', + 'asset_statuses', + 'asset_maintenance_types' +]; + +// --- .env File Utilities --- +const envPath = path.join(__dirname, '../.env'); + +const readEnv = () => { + if (!fs.existsSync(envPath)) return {}; + const content = fs.readFileSync(envPath, 'utf8'); + const lines = content.split('\n'); + const env = {}; + lines.forEach(line => { + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); + if (match) { + env[match[1]] = match[2] ? match[2].trim() : ''; + } + }); + return env; +}; + +const writeEnv = (updates) => { + let content = fs.readFileSync(envPath, 'utf8'); + Object.entries(updates).forEach(([key, value]) => { + const regex = new RegExp(`^${key}=.*`, 'm'); + if (regex.test(content)) { + content = content.replace(regex, `${key}=${value}`); + } else { + content += `\n${key}=${value}`; + } + }); + fs.writeFileSync(envPath, content, 'utf8'); +}; + +const mysql = require('mysql2/promise'); + // 0. Server Configuration (Subscriber ID & Session Timeout) router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { try { - const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')"); + const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')"); const settings = {}; rows.forEach(r => settings[r.setting_key] = r.setting_value); + // Include .env DB settings + const env = readEnv(); + res.json({ subscriber_id: settings.subscriber_id || '', - session_timeout: parseInt(settings.session_timeout) || 60 // Default 60 min + session_timeout: parseInt(settings.session_timeout) || 60, + encryption_key: settings.encryption_key || '', + db_config: { + host: env.DB_HOST || '', + user: env.DB_USER || '', + password: env.DB_PASSWORD || '', + database: env.DB_NAME || '', + port: env.DB_PORT || '3306' + } }); } catch (err) { console.error(err); @@ -39,7 +95,7 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { }); router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { - const { subscriber_id, session_timeout } = req.body; + const { subscriber_id, session_timeout, encryption_key, db_config } = req.body; try { if (subscriber_id !== undefined) { @@ -48,7 +104,147 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => if (session_timeout !== undefined) { await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]); } - res.json({ message: 'Settings saved' }); + if (encryption_key !== undefined) { + const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key); + await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]); + } + + // Handle .env DB settings + if (db_config) { + writeEnv({ + DB_HOST: db_config.host, + DB_USER: db_config.user, + DB_PASSWORD: db_config.password, + DB_NAME: db_config.database, + DB_PORT: db_config.port + }); + } + + res.json({ message: 'Settings saved. Server may restart to apply DB changes.' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// --- Crypto & Key Rotation --- +const cryptoUtil = require('../utils/cryptoUtil'); + +// 0.2 Test DB Connection +router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => { + const { host, user, password, database, port } = req.body; + + let conn; + try { + conn = await mysql.createConnection({ + host, + user, + password, + database, + port: parseInt(port) || 3306, + connectTimeout: 5000 + }); + await conn.query('SELECT 1'); + res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } finally { + if (conn) await conn.end(); + } +}); + +// 0.3 Encryption Key Management & Rotation +router.get('/encryption/status', isAuthenticated, hasRole('admin'), async (req, res) => { + try { + const [userRows] = await db.query('SELECT COUNT(*) as count FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"'); + const currentKey = await cryptoUtil.getMasterKey(); + + res.json({ + current_key: currentKey, + affected_records: { + users: userRows[0].count + } + }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch encryption status' }); + } +}); + +router.post('/encryption/rotate', isAuthenticated, hasRole('admin'), async (req, res) => { + const { new_key } = req.body; + if (!new_key) return res.status(400).json({ error: 'New key is required' }); + + const conn = await db.getConnection(); + try { + await conn.beginTransaction(); + + const oldKey = await cryptoUtil.getMasterKey(); + + // 1. Migrate Users Table (phone) + const [users] = await conn.query('SELECT id, phone FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"'); + for (const user of users) { + const decrypted = await cryptoUtil.decrypt(user.phone, oldKey); + const reEncrypted = await cryptoUtil.encrypt(decrypted, new_key); + await conn.query('UPDATE users SET phone = ? WHERE id = ?', [reEncrypted, user.id]); + } + + // 2. Update Master Key in settings (Encrypted for DB storage) + const encryptedKeyForDb = cryptoUtil.encryptMasterKey(new_key); + await conn.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]); + + await conn.commit(); + cryptoUtil.clearCache(); // Force immediate reload of new key + + res.json({ success: true, message: 'Encryption key rotated and data migrated successfully.' }); + } catch (err) { + await conn.rollback(); + console.error('Rotation failed:', err); + res.status(500).json({ error: 'Key rotation failed: ' + err.message }); + } finally { + conn.release(); + } +}); + +// 0-1. Generic Setting Get/Set +router.get('/settings/:key', isAuthenticated, async (req, res) => { + const { key } = req.params; + if (!ALLOWED_SETTING_KEYS.includes(key)) { + return res.status(400).json({ error: 'Invalid setting key' }); + } + + try { + const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = ?", [key]); + if (rows.length === 0) return res.json({ value: null }); + res.json({ value: rows[0].setting_value }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res) => { + const { key } = req.params; + const { value } = req.body; + + if (!ALLOWED_SETTING_KEYS.includes(key)) { + return res.status(400).json({ error: 'Invalid setting key' }); + } + + try { + let stringValue = typeof value === 'string' ? value : JSON.stringify(value); + + // Special handling for encryption_key to protect it in DB + if (key === 'encryption_key') { + stringValue = cryptoUtil.encryptMasterKey(stringValue); + } + + await db.query( + `INSERT INTO system_settings (setting_key, setting_value) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, + [key, stringValue] + ); + res.json({ success: true, message: 'Setting saved' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); diff --git a/server/utils/cryptoUtil.js b/server/utils/cryptoUtil.js new file mode 100644 index 0000000..a4f68c9 --- /dev/null +++ b/server/utils/cryptoUtil.js @@ -0,0 +1,111 @@ +const crypto = require('crypto'); +const db = require('../db'); + +const ALGORITHM = 'aes-256-cbc'; +const SYSTEM_INTERNAL_KEY = 'ims_system_l2_internal_protection_key_2026'; // 고정 시스템 키 (2단계 보안용) +let cachedKey = null; + +const cryptoUtil = { + /** + * 내부 보안용 암호화 (마스터 키 보호용) + */ + _internalProcess(text, isEncrypt = true) { + if (!text) return text; + try { + const keyBuffer = crypto.scryptSync(SYSTEM_INTERNAL_KEY, 'ims_salt', 32); + if (isEncrypt) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; + } else { + if (!text.includes(':')) return text; // 평문인 경우 그대로 반환 + const [ivHex, cipherText] = text.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); + let decrypted = decipher.update(cipherText, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + } catch (e) { + return text; + } + }, + + /** + * Get key from DB or Cache + */ + async getMasterKey() { + if (cachedKey) return cachedKey; + try { + const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'encryption_key'"); + if (rows.length > 0 && rows[0].setting_value) { + const rawValue = rows[0].setting_value; + // DB에 저장된 값이 암호화된 형태(iv 포함)라면 복호화하여 사용 + if (rawValue.includes(':')) { + cachedKey = this._internalProcess(rawValue, false); + } else { + cachedKey = rawValue; + } + return cachedKey; + } + } catch (e) { + console.error('CryptoUtil: Failed to fetch key', e); + } + return process.env.ENCRYPTION_KEY || 'smartasset_secret_key_0123456789'; + }, + + /** + * 마스터 키를 DB에 저장하기 전 암호화하는 함수 + */ + encryptMasterKey(plainKey) { + return this._internalProcess(plainKey, true); + }, + + clearCache() { + cachedKey = null; + }, + + /** + * Encrypt with specific key (optional, defaults to master) + */ + async encrypt(text, customKey = null) { + if (!text) return text; + try { + const secret = customKey || await this.getMasterKey(); + const keyBuffer = crypto.scryptSync(secret, 'salt', 32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; + } catch (e) { + console.error('Encryption failed:', e.message); + return text; + } + }, + + /** + * Decrypt with specific key (optional, defaults to master) + */ + async decrypt(text, customKey = null) { + if (!text || !text.includes(':')) return text; + try { + const secret = customKey || await this.getMasterKey(); + const keyBuffer = crypto.scryptSync(secret, 'salt', 32); + const [ivHex, cipherText] = text.split(':'); + if (!ivHex || ivHex.length !== 32) return text; + + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); + let decrypted = decipher.update(cipherText, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (e) { + return text; + } + } +}; + +module.exports = cryptoUtil; diff --git a/src/modules/asset/pages/AssetListPage.css b/src/modules/asset/pages/AssetListPage.css index 6f259ab..fc476ac 100644 --- a/src/modules/asset/pages/AssetListPage.css +++ b/src/modules/asset/pages/AssetListPage.css @@ -6,24 +6,25 @@ height: 100%; } -.page-header { +.page-header-right { display: flex; - justify-content: flex-start; + flex-direction: column; align-items: flex-end; - text-align: left; - /* Explicitly set text align */ + text-align: right; + margin-bottom: 2rem; + width: 100%; } .page-title-text { - font-size: 1.5rem; + font-size: 1.75rem; font-weight: 700; - color: var(--color-text-primary); - margin-bottom: 0.25rem; + color: var(--sokuree-text-primary); + margin-bottom: 0.5rem; } .page-subtitle { - color: var(--color-text-secondary); - font-size: 0.9rem; + color: var(--sokuree-text-secondary); + font-size: 1rem; } .content-card { diff --git a/src/modules/asset/pages/AssetListPage.tsx b/src/modules/asset/pages/AssetListPage.tsx index 64aaecd..b0fea06 100644 --- a/src/modules/asset/pages/AssetListPage.tsx +++ b/src/modules/asset/pages/AssetListPage.tsx @@ -23,6 +23,7 @@ export function AssetListPage() { const [currentPage, setCurrentPage] = useState(1); const [assets, setAssets] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const itemsPerPage = 8; // Fetch Assets @@ -33,10 +34,17 @@ export function AssetListPage() { const loadAssets = async () => { try { setIsLoading(true); + setError(null); const data = await assetApi.getAll(); - setAssets(data); + if (Array.isArray(data)) { + setAssets(data); + } else { + console.error("API returned non-array data:", data); + setAssets([]); + } } catch (error: any) { console.error("Failed to fetch assets:", error); + setError("데이터를 불러오는 중 오류가 발생했습니다. 서버 연결을 확인해 주세요."); } finally { setIsLoading(false); } @@ -98,8 +106,10 @@ export function AssetListPage() { // Let's make it strict if possible, or robust for '설비' vs '설비 자산'. if (asset.category !== currentCategory) { - // Fallback for partial matches if needed, but let's try strict first based on user request. - if (!asset.category.includes(currentCategory)) return false; + // partial match fallback (e.g., '시설' in '시설 자산' or vice versa) + const assetCat = asset.category || ''; + const targetCat = currentCategory || ''; + if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false; } } @@ -240,13 +250,11 @@ export function AssetListPage() { return (
-
-
-

{getPageTitle()}

-

- {currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'} -

-
+
+

{getPageTitle()}

+

+ {currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'} +

@@ -395,9 +403,18 @@ export function AssetListPage() { )) + ) : error ? ( + + +
+ ⚠️ {error} + +
+ + ) : ( - + 데이터가 없습니다. diff --git a/src/modules/asset/pages/AssetRegisterPage.css b/src/modules/asset/pages/AssetRegisterPage.css new file mode 100644 index 0000000..8b4c9d1 --- /dev/null +++ b/src/modules/asset/pages/AssetRegisterPage.css @@ -0,0 +1,43 @@ +/* AssetRegisterPage.css */ + + + +/* Page Header - Right Aligned */ +.page-header-right { + display: flex; + flex-direction: column; + align-items: flex-end; + text-align: right; + margin-bottom: 2rem; + width: 100%; + padding: 0 0.5rem; +} + +.page-title-text { + font-size: 1.75rem; + font-weight: 700; + color: var(--sokuree-text-primary); + margin-bottom: 0.5rem; +} + +.page-subtitle { + font-size: 1rem; + color: var(--sokuree-text-secondary); +} + +/* Bottom Actions Styling */ +.form-actions-footer { + display: flex; + justify-content: center; + align-items: center; + gap: 1.5rem; + margin-top: 3rem; + margin-bottom: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--sokuree-border-color); + width: 100%; +} + +.form-actions-footer .ui-btn { + min-width: 120px; +} \ No newline at end of file diff --git a/src/modules/asset/pages/AssetRegisterPage.tsx b/src/modules/asset/pages/AssetRegisterPage.tsx index 67d7159..fe10a3b 100644 --- a/src/modules/asset/pages/AssetRegisterPage.tsx +++ b/src/modules/asset/pages/AssetRegisterPage.tsx @@ -7,6 +7,7 @@ import { Select } from '../../../shared/ui/Select'; import { ArrowLeft, Save, Upload } from 'lucide-react'; import { getCategories, getLocations, getIDRule } from './AssetSettingsPage'; import { assetApi, type Asset } from '../../../shared/api/assetApi'; +import './AssetRegisterPage.css'; export function AssetRegisterPage() { const navigate = useNavigate(); @@ -51,13 +52,11 @@ export function AssetRegisterPage() { if (part.type === 'separator') return part.value; if (part.type === 'year') return year; if (part.type === 'category') return category ? category.code : 'UNKNOWN'; - if (part.type === 'sequence') return part.value; // In real app, we fetch next seq. here we just show the format pattern e.g. 001 + if (part.type === 'sequence') return part.value; return ''; }).join(''); - // Ideally we would fetch the *actual* next sequence from API here. - // For now we assume '001' as a placeholder or "Generating..." - const finalId = generatedId.replace('001', '001'); // Just keeping the placeholder visible for user confirmation + const finalId = generatedId.replace('001', '001'); setFormData(prev => ({ ...prev, id: finalId })); @@ -87,7 +86,7 @@ export function AssetRegisterPage() { }; const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + if (e) e.preventDefault(); // Validation if (!formData.categoryId || !formData.name || !formData.locationId) { @@ -96,11 +95,9 @@ export function AssetRegisterPage() { } try { - // Map IDs to Names/Codes for Backend const selectedCategory = categories.find(c => c.id === formData.categoryId); const selectedLocation = locations.find(l => l.id === formData.locationId); - // Upload Image if exists let imageUrl = ''; if (formData.image) { const uploadRes = await assetApi.uploadImage(formData.image); @@ -110,7 +107,7 @@ export function AssetRegisterPage() { const payload: Partial = { id: formData.id, name: formData.name, - category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name + category: selectedCategory ? selectedCategory.name : '미지정', model: formData.model, serialNumber: formData.serialNo, location: selectedLocation ? selectedLocation.name : '미지정', @@ -134,196 +131,208 @@ export function AssetRegisterPage() { return (
-
-
-

자산 등록

-

새로운 자산을 시스템에 등록합니다.

-
-
- - -
+
+

자산 등록

+

새로운 자산을 시스템에 등록합니다.

- -
- {/* Basic Info */} -
-

기본 정보

-
+ + +
+ {/* Basic Info Section */} +
+
+

기본 정보

+
- ({ label: c.name, value: c.id }))} + placeholder="카테고리 선택" + required + /> - + - + - + - + - + - {/* Image Upload Field */} -
- -
- {/* Preview Area - Fixed size container */} -
- {formData.imagePreview ? ( - Preview + +
+
+ {formData.imagePreview ? ( + Preview + ) : ( +
+ +
+ )} +
+ +
+ + + + {formData.image ? formData.image.name : '선택된 파일 없음'} + + + + 지원 형식: JPG, PNG, GIF (최대 5MB) + + + - ) : ( -
- -
- )} -
- - {/* Upload Controls - Moved below */} -
- - - - {formData.image ? formData.image.name : '선택된 파일 없음'} - - - - 지원 형식: JPG, PNG, GIF (최대 5MB) - - - +
+ + {/* Management Info Section */} +
+
+

관리 정보

+
+ + + + + +
- - {/* Management Info */} -
-

관리 정보

+ {/* Bottom Action Buttons */} +
+ +
- - - - - - -
diff --git a/src/modules/asset/pages/AssetSettingsPage.css b/src/modules/asset/pages/AssetSettingsPage.css index f70dfe2..6aebf2e 100644 --- a/src/modules/asset/pages/AssetSettingsPage.css +++ b/src/modules/asset/pages/AssetSettingsPage.css @@ -1,45 +1,45 @@ -/* Page Header adjustments for Settings */ -.page-header { +/* Page Header - Right Aligned */ +.page-header-right { display: flex; flex-direction: column; - gap: 1rem; - padding-bottom: 0 !important; - /* Override default bottom padding */ - border-bottom: 1px solid var(--color-border); + align-items: flex-end; + text-align: right; + margin-bottom: 2rem; + width: 100%; } -.header-top { - padding-bottom: 0.5rem; -} - -/* Settings Tabs */ -.settings-tabs { - display: flex; - gap: 1.5rem; - margin-bottom: -1px; - /* Overlap border */ -} - -.settings-tab { - padding: 0.75rem 0.5rem; - font-size: 0.95rem; - font-weight: 500; - color: var(--color-text-secondary); - background: transparent; - border: none; - border-bottom: 3px solid transparent; - cursor: pointer; - transition: all 0.2s; -} - -.settings-tab:hover { - color: var(--color-brand-primary); -} - -.settings-tab.active { - color: var(--color-brand-primary); +.page-title-text { + font-size: 1.75rem; font-weight: 700; - border-bottom-color: var(--color-brand-primary); + color: var(--sokuree-text-primary); + margin-bottom: 0.5rem; +} + +.page-subtitle { + font-size: 1rem; + color: var(--sokuree-text-secondary); +} + +/* Card Header Styles within Settings */ +.card-header { + margin-bottom: 1.5rem; +} + +.card-title { + font-size: 1.1rem; + font-weight: 700; + color: var(--sokuree-text-primary); + margin-bottom: 0.25rem; +} + +.card-desc { + font-size: 0.95rem; + color: var(--sokuree-text-secondary); +} + +/* Local tabs are now hidden and moved to Global TopHeader */ +.settings-tabs { + display: none; } /* Common Layout */ @@ -66,8 +66,8 @@ /* Table Styles */ .table-wrapper { - border: 1px solid var(--color-border); - border-radius: var(--radius-md); + border: 1px solid var(--sokuree-border-color); + border-radius: var(--sokuree-radius-md); overflow: hidden; } @@ -83,13 +83,13 @@ font-weight: 600; text-align: left; padding: 0.75rem 1rem; - border-bottom: 1px solid var(--color-border); + border-bottom: 1px solid var(--sokuree-border-color); } .settings-table td { padding: 0.75rem 1rem; border-bottom: 1px solid #f1f5f9; - color: var(--color-text-primary); + color: var(--sokuree-text-primary); vertical-align: middle; } @@ -144,7 +144,7 @@ .preview-box { background-color: #f1f5f9; padding: 1rem; - border-radius: var(--radius-md); + border-radius: var(--sokuree-radius-md); display: flex; align-items: center; gap: 0.75rem; @@ -179,7 +179,7 @@ min-height: 60px; padding: 0.5rem; border: 2px dashed #e2e8f0; - border-radius: var(--radius-md); + border-radius: var(--sokuree-radius-md); background-color: #fcfcfc; } @@ -282,14 +282,16 @@ height: 32px; /* Match button height (sm size) */ padding: 0 0.5rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); + border: 1px solid var(--sokuree-border-color); + border-radius: var(--sokuree-radius-sm); font-size: 0.875rem; width: 150px; outline: none; transition: border-color 0.2s; + background-color: white; } .custom-text-input:focus { - border-color: var(--color-brand-primary); + border-color: var(--sokuree-brand-primary); + box-shadow: 0 0 0 2px rgba(var(--sokuree-brand-primary-rgb), 0.1); } \ No newline at end of file diff --git a/src/modules/asset/pages/AssetSettingsPage.tsx b/src/modules/asset/pages/AssetSettingsPage.tsx index 1c945a6..513bed0 100644 --- a/src/modules/asset/pages/AssetSettingsPage.tsx +++ b/src/modules/asset/pages/AssetSettingsPage.tsx @@ -3,7 +3,8 @@ import { useSearchParams } from 'react-router-dom'; import { Card } from '../../../shared/ui/Card'; import { Button } from '../../../shared/ui/Button'; import { Input } from '../../../shared/ui/Input'; -import { Plus, Trash2, Edit2, X, Check, GripVertical } from 'lucide-react'; +import { Plus, Trash2, Edit2, X, Check, GripVertical, Save, Loader2 } from 'lucide-react'; +import { apiClient } from '../../../shared/api/client'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; @@ -76,6 +77,7 @@ function SortableRuleItem({ id, children, className }: { id: string; children: R // --------------------- +// Types // Types interface AssetCategory { id: string; @@ -96,7 +98,7 @@ interface AssetStatus { color: string; } -type IDRuleComponentType = 'company' | 'category' | 'year' | 'sequence' | 'separator' | 'custom'; +type IDRuleComponentType = 'company' | 'category' | 'year' | 'month' | 'sequence' | 'separator' | 'custom'; interface IDRuleComponent { id: string; @@ -105,7 +107,13 @@ interface IDRuleComponent { label: string; } -// Initial Mock Data +export interface AssetMaintenanceType { + id: string; + name: string; + color: string; +} + +// Internal values for initial fallback let GLOBAL_CATEGORIES: AssetCategory[] = [ { id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' }, { id: '2', name: '공구', code: 'TOL', menuLink: 'tools' }, @@ -130,7 +138,7 @@ let GLOBAL_STATUSES: AssetStatus[] = [ ]; let GLOBAL_ID_RULE: IDRuleComponent[] = [ - { id: 'r1', type: 'company', value: 'HK', label: '회사약어' }, + { id: 'r1', type: 'company', value: 'SKR', label: '회사약어' }, { id: 'r2', type: 'separator', value: '-', label: '구분자' }, { id: 'r3', type: 'category', value: '', label: '카테고리' }, { id: 'r4', type: 'separator', value: '-', label: '구분자' }, @@ -139,12 +147,6 @@ let GLOBAL_ID_RULE: IDRuleComponent[] = [ { id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' }, ]; -export interface AssetMaintenanceType { - id: string; - name: string; - color: string; -} - let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [ { id: '1', name: '정기점검', color: 'success' }, { id: '2', name: '수리', color: 'danger' }, @@ -170,6 +172,68 @@ export function AssetSettingsPage() { const [maintenanceTypes, setMaintenanceTypes] = useState(GLOBAL_MAINTENANCE_TYPES); const [idRule, setIdRule] = useState(GLOBAL_ID_RULE); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // Initial Load + React.useEffect(() => { + const fetchAllSettings = async () => { + setIsLoading(true); + try { + const keys = ['asset_id_rule', 'asset_categories', 'asset_locations', 'asset_statuses', 'asset_maintenance_types']; + const results = await Promise.all(keys.map(k => apiClient.get(`/system/settings/${k}`))); + + results.forEach((res, idx) => { + const key = keys[idx]; + const val = res.data.value; + if (val) { + const parsed = JSON.parse(val); + if (key === 'asset_id_rule') { + setIdRule(parsed); + GLOBAL_ID_RULE = parsed; + // Update company code input from the rule if available + const comp = parsed.find((p: any) => p.type === 'company'); + if (comp) setCompanyCodeInput(comp.value); + } + if (key === 'asset_categories') { setCategories(parsed); GLOBAL_CATEGORIES = parsed; } + if (key === 'asset_locations') { setLocations(parsed); GLOBAL_LOCATIONS = parsed; } + if (key === 'asset_statuses') { setStatuses(parsed); GLOBAL_STATUSES = parsed; } + if (key === 'asset_maintenance_types') { setMaintenanceTypes(parsed); GLOBAL_MAINTENANCE_TYPES = parsed; } + } + }); + } catch (err) { + console.error('Failed to load settings:', err); + } finally { + setIsLoading(false); + } + }; + fetchAllSettings(); + }, []); + + const handleSaveSettings = async (currentTab: string) => { + setIsSaving(true); + try { + let key = ''; + let value: any = null; + + if (currentTab === 'basic') { key = 'asset_id_rule'; value = idRule; } + else if (currentTab === 'category') { key = 'asset_categories'; value = categories; } + else if (currentTab === 'location') { key = 'asset_locations'; value = locations; } + else if (currentTab === 'status') { key = 'asset_statuses'; value = statuses; } + else if (currentTab === 'maintenance') { key = 'asset_maintenance_types'; value = maintenanceTypes; } + + if (key) { + await apiClient.post(`/system/settings/${key}`, { value }); + alert('설정이 저장되었습니다.'); + } + } catch (err) { + console.error('Save failed:', err); + alert('저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }; + // Form inputs const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryCode, setNewCategoryCode] = useState(''); @@ -445,6 +509,12 @@ export function AssetSettingsPage() { setCustomRuleText(''); }; + const updateCompanyValue = (newVal: string) => { + const updated = idRule.map(part => part.type === 'company' ? { ...part, value: newVal } : part); + setIdRule(updated); + GLOBAL_ID_RULE = updated; + }; + const removeRuleComponent = (id: string) => { const updated = idRule.filter(comp => comp.id !== id); setIdRule(updated); @@ -453,10 +523,12 @@ export function AssetSettingsPage() { const getPreviewId = () => { + const now = new Date(); const mockData = { - company: 'HK', + company: 'HK', // Fallback category: 'FAC', - year: new Date().getFullYear().toString(), + year: now.getFullYear().toString(), + month: String(now.getMonth() + 1).padStart(2, '0'), sequence: '001', separator: '-' }; @@ -466,22 +538,33 @@ export function AssetSettingsPage() { if (part.type === 'company') return part.value; if (part.type === 'category') return mockData.category; if (part.type === 'year') return mockData.year; + if (part.type === 'month') return mockData.month; if (part.type === 'sequence') return part.value; if (part.type === 'separator') return part.value; return ''; }).join(''); }; + if (isLoading) return
+ + 설정을 불러오는 중입니다... +
; + return ( -
-
+
+
+

자산 설정

+

자산 관리 모듈의 기준 정보를 설정합니다.

+
+ +
{activeTab === 'basic' && ( - +

자산 관리번호 생성 규칙

자산 등록 시 자동 생성될 관리번호의 포맷을 조합합니다.

@@ -518,40 +601,57 @@ export function AssetSettingsPage() {

규칙 요소 추가

-
- setCompanyCodeInput(e.target.value.toUpperCase())} - className="custom-text-input" - maxLength={5} - style={{ width: '100px' }} - /> - -
+ { + const val = e.target.value.toUpperCase(); + setCompanyCodeInput(val); + updateCompanyValue(val); + }} + className="custom-text-input" + maxLength={5} + style={{ width: '80px' }} + /> + + + - -
- setCustomRuleText(e.target.value)} - className="custom-text-input" - /> - -
+ + + setCustomRuleText(e.target.value)} + className="custom-text-input" + style={{ marginLeft: '0.5rem', width: '130px' }} + /> +
+ + {/* Bottom Action */} +
+ +
)} {activeTab === 'category' && ( - +

카테고리 관리

자산 유형을 분류하는 카테고리를 관리합니다. (약어는 자산번호 생성 시 사용됩니다)

@@ -666,12 +766,25 @@ export function AssetSettingsPage() {
+ + {/* Bottom Action */} +
+ +
)} {activeTab === 'location' && ( - +

설치 위치 / 보관 장소

자산이 위치할 수 있는 장소를 관리합니다.

@@ -735,12 +848,25 @@ export function AssetSettingsPage() {
+ + {/* Bottom Action */} +
+ +
)} {activeTab === 'status' && ( - +

자산 상태 관리

자산의 상태(운용, 파손, 수리 등)를 정의하고 관리합니다.

@@ -834,12 +960,25 @@ export function AssetSettingsPage() {
+ + {/* Bottom Action */} +
+ +
)} {activeTab === 'maintenance' && ( - +

유지보수 구분 관리

유지보수 이력 등록 시 사용할 작업 구분을 관리합니다.

@@ -923,6 +1062,19 @@ export function AssetSettingsPage() {
+ + {/* Bottom Action */} +
+ +
)} diff --git a/src/pages/auth/LoginPage.css b/src/pages/auth/LoginPage.css index 1a651d9..348fee0 100644 --- a/src/pages/auth/LoginPage.css +++ b/src/pages/auth/LoginPage.css @@ -4,8 +4,8 @@ justify-content: center; height: 100vh; width: 100vw; - background-color: var(--color-bg-base); - background: linear-gradient(135deg, var(--color-bg-sidebar) 0%, var(--color-brand-primary) 100%); + background-color: var(--sokuree-bg-main); + background: linear-gradient(135deg, var(--sokuree-bg-sidebar) 0%, var(--sokuree-brand-primary) 100%); } .login-card { @@ -14,8 +14,8 @@ background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); padding: 3rem; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); + border-radius: var(--sokuree-radius-lg); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); } .login-header { @@ -26,22 +26,22 @@ .brand-logo { display: inline-flex; padding: 1rem; - background-color: var(--color-bg-sidebar); - color: var(--color-text-inverse); - border-radius: var(--radius-md); + background-color: var(--sokuree-bg-sidebar); + color: var(--sokuree-text-inverse); + border-radius: var(--sokuree-radius-md); margin-bottom: 1.5rem; - box-shadow: var(--shadow-md); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } .login-header h1 { font-size: 1.75rem; font-weight: 700; - color: var(--color-text-primary); + color: var(--sokuree-text-primary); margin-bottom: 0.5rem; } .login-header p { - color: var(--color-text-secondary); + color: var(--sokuree-text-secondary); font-size: 0.875rem; } @@ -60,7 +60,7 @@ .form-group label { font-size: 0.875rem; font-weight: 500; - color: var(--color-text-primary); + color: var(--sokuree-text-primary); } .input-wrapper { @@ -72,33 +72,33 @@ .input-icon { position: absolute; left: 1rem; - color: var(--color-text-secondary); + color: var(--sokuree-text-secondary); pointer-events: none; } .form-group input { width: 100%; padding: 0.75rem 1rem 0.75rem 2.75rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); + border: 1px solid var(--sokuree-border-color); + border-radius: var(--sokuree-radius-md); font-size: 0.9rem; transition: all 0.2s; - background-color: var(--color-bg-surface); + background-color: var(--sokuree-bg-card); } .form-group input:focus { outline: none; - border-color: var(--color-brand-primary); - box-shadow: 0 0 0 3px rgba(82, 109, 130, 0.1); + border-color: var(--sokuree-brand-primary); + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1); } .login-btn { margin-top: 1rem; padding: 0.875rem; - background-color: var(--color-bg-sidebar); + background-color: var(--sokuree-bg-sidebar); color: white; font-weight: 600; - border-radius: var(--radius-md); + border-radius: var(--sokuree-radius-md); font-size: 1rem; letter-spacing: 0.025em; transition: all 0.2s; @@ -107,7 +107,7 @@ .login-btn:hover { background-color: #1e293b; transform: translateY(-1px); - box-shadow: var(--shadow-md); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } .error-message { @@ -116,12 +116,12 @@ text-align: center; background-color: rgba(239, 68, 68, 0.1); padding: 0.75rem; - border-radius: var(--radius-sm); + border-radius: var(--sokuree-radius-sm); } .login-footer { margin-top: 2rem; text-align: center; font-size: 0.75rem; - color: var(--color-text-secondary); + color: var(--sokuree-text-secondary); } \ No newline at end of file diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 88fc3d0..dc75932 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -13,6 +13,13 @@ export function LoginPage() { const location = useLocation(); const from = location.state?.from?.pathname || '/asset/dashboard'; + const isExpired = new URLSearchParams(location.search).get('expired') === 'true'; + + useState(() => { + if (isExpired) { + setError('세션이 만료되었습니다. 보안을 위해 다시 로그인해주세요.'); + } + }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/platform/App.tsx b/src/platform/App.tsx index 36bcc36..18c3fe2 100644 --- a/src/platform/App.tsx +++ b/src/platform/App.tsx @@ -14,6 +14,7 @@ import ModuleLoader from './ModuleLoader'; // Platform / System Pages import { UserManagementPage } from './pages/UserManagementPage'; import { BasicSettingsPage } from './pages/BasicSettingsPage'; +import { VersionPage } from './pages/VersionPage'; import { LicensePage } from '../system/pages/LicensePage'; import './styles/global.css'; @@ -45,6 +46,7 @@ export const App = () => { } /> } /> } /> + } /> } /> diff --git a/src/platform/pages/BasicSettingsPage.tsx b/src/platform/pages/BasicSettingsPage.tsx index fbdc26b..f2a8e8c 100644 --- a/src/platform/pages/BasicSettingsPage.tsx +++ b/src/platform/pages/BasicSettingsPage.tsx @@ -3,23 +3,75 @@ import { Card } from '../../shared/ui/Card'; import { Button } from '../../shared/ui/Button'; import { Input } from '../../shared/ui/Input'; import { apiClient } from '../../shared/api/client'; -import { Save, Clock, Info } from 'lucide-react'; +import { useAuth } from '../../shared/auth/AuthContext'; +import { Save, Clock, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock } from 'lucide-react'; + +interface SystemSettings { + session_timeout: number; + encryption_key: string; + subscriber_id: string; + db_config: { + host: string; + user: string; + password?: string; + database: string; + port: string; + }; +} export function BasicSettingsPage() { - const [settings, setSettings] = useState({ - session_timeout: 60 + const { user: currentUser } = useAuth(); + const [settings, setSettings] = useState({ + session_timeout: 60, + encryption_key: '', + subscriber_id: '', + db_config: { + host: '', + user: '', + password: '', + database: '', + port: '3306' + } }); + + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [isDbVerified, setIsDbVerified] = useState(false); + const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({ + security: null, + encryption: null, + database: null + }); + + const [testing, setTesting] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [rotationStatus, setRotationStatus] = useState<{ currentKey: string; affectedCount: number } | null>(null); + const [rotating, setRotating] = useState(false); + + // Supervisor Protection States + const [isDbConfigUnlocked, setIsDbConfigUnlocked] = useState(false); + const [isEncryptionUnlocked, setIsEncryptionUnlocked] = useState(false); + const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | null>(null); + + const [showVerifyModal, setShowVerifyModal] = useState(false); + const [verifyPassword, setVerifyPassword] = useState(''); + const [verifying, setVerifying] = useState(false); + const [verifyError, setVerifyError] = useState(''); useEffect(() => { fetchSettings(); + fetchEncryptionStatus(); }, []); const fetchSettings = async () => { try { const res = await apiClient.get('/system/settings'); - setSettings(res.data); + const data = res.data; + if (!data.db_config) { + data.db_config = { host: '', user: '', password: '', database: '', port: '3306' }; + } + setSettings(data); + setIsDbVerified(true); } catch (error) { console.error('Failed to fetch settings', error); } finally { @@ -27,95 +79,416 @@ export function BasicSettingsPage() { } }; - const handleSave = async () => { - setSaving(true); + const fetchEncryptionStatus = async () => { try { - await apiClient.post('/system/settings', settings); - alert('설정이 저장되었습니다.'); + const res = await apiClient.get('/system/encryption/status'); + setRotationStatus({ + currentKey: res.data.current_key, + affectedCount: Object.values(res.data.affected_records as Record).reduce((a, b) => a + b, 0) + }); } catch (error) { - console.error('Save failed', error); - alert('저장 중 오류가 발생했습니다.'); + console.error('Failed to fetch encryption status', error); + } + }; + + const generateNewKey = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()'; + let key = 'smartims_'; + for (let i = 0; i < 24; i++) { + key += chars.charAt(Math.floor(Math.random() * chars.length)); + } + setSettings({ ...settings, encryption_key: key }); + }; + + const handleRotateKey = async () => { + if (!settings.encryption_key) { + alert('새로운 암호화 키를 입력하거나 생성해 주세요.'); + return; + } + + const confirmMsg = `암호화 키 변경 및 데이터 마이그레이션을 시작합니다.\n- 대상 레코드: 약 ${rotationStatus?.affectedCount || 0}건\n- 소요 시간: 데이터 양에 따라 수 초가 걸릴 수 있습니다.\n\n주의: 절차 진행 중에는 서버 연결이 일시적으로 끊길 수 있으며, 중간에 브라우저를 닫지 마십시오.\n진행하시겠습니까?`; + + if (!confirm(confirmMsg)) return; + + setRotating(true); + setSaveResults(prev => ({ ...prev, encryption: null })); + + try { + await apiClient.post('/system/encryption/rotate', { new_key: settings.encryption_key }); + setSaveResults(prev => ({ + ...prev, + encryption: { success: true, message: '암호화 키 변경 및 데이터 마이그레이션 완료!' } + })); + fetchSettings(); + fetchEncryptionStatus(); + setTimeout(() => setSaveResults(prev => ({ ...prev, encryption: null })), 5000); + } catch (error: any) { + setSaveResults(prev => ({ + ...prev, + encryption: { success: false, message: error.response?.data?.error || '재암호화 작업 중 오류가 발생했습니다.' } + })); + } finally { + setRotating(false); + } + }; + + const handleTestConnection = async () => { + setTesting(true); + setTestResult(null); + try { + const res = await apiClient.post('/system/test-db', settings.db_config); + setTestResult({ success: true, message: res.data.message }); + setIsDbVerified(true); + } catch (error: any) { + setTestResult({ success: false, message: error.response?.data?.error || '접속 테스트에 실패했습니다.' }); + setIsDbVerified(false); + } finally { + setTesting(false); + } + }; + + const handleOpenVerify = (target: 'database' | 'encryption') => { + if (currentUser?.role !== 'supervisor') { + alert('해당 권한이 없습니다. 최고관리자(Supervisor)만 접근 가능합니다.'); + return; + } + setVerifyingTarget(target); + setShowVerifyModal(true); + }; + + const handleVerifySupervisor = async () => { + setVerifying(true); + setVerifyError(''); + try { + const res = await apiClient.post('/verify-supervisor', { password: verifyPassword }); + if (res.data.success) { + if (verifyingTarget === 'database') setIsDbConfigUnlocked(true); + else if (verifyingTarget === 'encryption') setIsEncryptionUnlocked(true); + + setShowVerifyModal(false); + setVerifyPassword(''); + setVerifyingTarget(null); + } + } catch (error: any) { + setVerifyError(error.response?.data?.message || '인증에 실패했습니다. 비밀번호를 확인해주세요.'); + } finally { + setVerifying(false); + } + }; + + const handleSaveSection = async (section: 'security' | 'encryption' | 'database') => { + if (section === 'database' && !isDbVerified) { + alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.'); + return; + } + + let payload: any = {}; + const successMessage = section === 'database' ? 'DB 설정이 저장되었습니다.' : '설정이 성공적으로 저장되었습니다.'; + + if (section === 'security') payload = { session_timeout: settings.session_timeout }; + else if (section === 'encryption') payload = { encryption_key: settings.encryption_key }; + else if (section === 'database') { + if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return; + payload = { db_config: settings.db_config }; + } + + setSaving(true); + setSaveResults(prev => ({ ...prev, [section]: null })); + + try { + await apiClient.post('/system/settings', payload); + setSaveResults(prev => ({ ...prev, [section]: { success: true, message: successMessage } })); + setTimeout(() => setSaveResults(prev => ({ ...prev, [section]: null })), 3000); + if (section !== 'database') fetchSettings(); + } catch (error: any) { + setSaveResults(prev => ({ + ...prev, + [section]: { success: false, message: error.response?.data?.error || '저장 중 오류 발생' } + })); } finally { setSaving(false); } }; - if (loading) return
로딩 중...
; + if (loading) return
데이터를 불러오는 중...
; return ( -
-
-

시스템 관리 - 기본 설정

-

플랫폼의 기본 운영 환경을 설정합니다.

+
+
+
+

시스템 관리 - 기본 설정

+

플랫폼의 보안 및 인프라 환경을 섹션별로 관리합니다.

+
-
- - - -

- - 보안 설정 -

-
-
- -
-
- { - const newHours = parseInt(e.target.value) || 0; - const currentTotal = settings.session_timeout || 10; - const currentMinutes = currentTotal % 60; - setSettings({ ...settings, session_timeout: (newHours * 60) + currentMinutes }); - }} - placeholder="0" - /> - 시간 -
-
- { - const newMinutes = parseInt(e.target.value) || 0; - const currentTotal = settings.session_timeout || 10; - const currentHours = Math.floor(currentTotal / 60); - setSettings({ ...settings, session_timeout: (currentHours * 60) + newMinutes }); - }} - placeholder="10" - /> - -
- 동안 활동이 없으면 자동으로 로그아웃됩니다. +
+ {/* Section 1: Security & Session */} + +
+

+ + 보안 및 세션 +

+
+
+ +
+
+ { + const h = parseInt(e.target.value) || 0; + setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) }); + }} + /> + 시간
-

- ( 총 {settings.session_timeout || 10}분 / 최소 5분 ~ 최대 24시간 ) -

+
+ { + const m = parseInt(e.target.value) || 0; + setSettings({ ...settings, session_timeout: (Math.floor(settings.session_timeout / 60) * 60) + m }); + }} + /> + +
+
+
+
+
+ {saveResults.security && ( +
+ {saveResults.security.success ? : } + {saveResults.security.message} +
+ )} +
+
+ +
-
- -
+ {/* Section 2: Encryption Master Key & Rotation (Supervisor Protected) */} + +
+

+ + 데이터 암호화 마스터 키 관리 +

+ {!isEncryptionUnlocked && ( + + )} + {isEncryptionUnlocked && ( +
+ 검증 완료: 최고관리자 모드 +
+ )} +
+ + {isEncryptionUnlocked ? ( +
+
+
+
+ 현재 활성 키 + {rotationStatus?.currentKey || '-'} +
+
+ 영향 레코드 +
+ {rotationStatus?.affectedCount || 0} 건 + +
+
+
+
+ setSettings({ ...settings, encryption_key: e.target.value })} + placeholder="새로운 키 입력" + className="font-mono flex-1" + /> + +
+
+
+
+ {saveResults.encryption && ( +
+ {saveResults.encryption.success ? : } + {saveResults.encryption.message} +
+ )} +
+
+ + +
+
+
+ ) : ( +
+ +

보안을 위해 암호화 설정은 최고관리자 인증 후 접근 가능합니다.

+
+ )} +
+ + {/* Section 3: Database Infrastructure (Supervisor Protected) */} + +
+

+ + 데이터베이스 인프라 구성 +

+ {!isDbConfigUnlocked && ( + + )} + {isDbConfigUnlocked && ( +
+ 검증 완료: 최고관리자 모드 +
+ )} +
+ + {isDbConfigUnlocked ? ( +
+
+
+ +
+

물리적 인프라 수동 관리 모드

+

+ 이 섹션의 설정을 잘못 변경하면 플랫폼 전체 접속이 불가능해질 수 있습니다.
+ 반드시 접속 테스트 성공을 확인한 후에 저장하십시오. +

+
+
+ +
+
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, host: e.target.value } }); setIsDbVerified(false); }} /> +
+
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, user: e.target.value } }); setIsDbVerified(false); }} /> +
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} /> +
+
+
+
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, database: e.target.value } }); setIsDbVerified(false); }} /> +
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, port: e.target.value } }); setIsDbVerified(false); }} /> +
+
+
+ +
+
+ {testResult && ( + + {testResult.success ? : } {testResult.message} + + )} + {!testResult && 변경 시 반드시 접속 테스트를 수행하십시오.} +
+ +
+
+
+
+ {testResult && !testResult.success && ⚠️ 연결 실패 시 변경된 정보를 저장할 수 없습니다.} + {saveResults.database && ( +
+ {saveResults.database.message} +
+ )} +
+
+ + +
+
+
+ ) : ( +
+ +

보안을 위해 인프라 설정은 최고관리자 인증 후 접근 가능합니다.

+
+ )} +
+ + {/* Verification Modal */} + {showVerifyModal && ( +
+ +
+

+ 최고관리자 권한 확인 ({verifyingTarget === 'encryption' ? '암호화 키' : '인프라 설정'}) +

+

민감 설정 접근을 위해 본인 확인이 필요합니다.

+
+
+
+ {verifyingTarget === 'encryption' + ? "※ 경고: 암호화 키 변경은 시스템 내 모든 민감 데이터를 다시 암호화하는 중대한 작업입니다. 성공 시 기존 데이터를 새 키로 대체하며, 실패 시 데이터 유실의 위험이 있습니다." + : "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."} +
+
+ + setVerifyPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + onKeyDown={e => e.key === 'Enter' && handleVerifySupervisor()} + autoFocus + /> + {verifyError &&

{verifyError}

} +
+
+
+ + +
+
+
+ )}
); } diff --git a/src/platform/pages/UserManagementPage.tsx b/src/platform/pages/UserManagementPage.tsx index 59b334a..ab493cf 100644 --- a/src/platform/pages/UserManagementPage.tsx +++ b/src/platform/pages/UserManagementPage.tsx @@ -3,9 +3,10 @@ import { Card } from '../../shared/ui/Card'; import { Button } from '../../shared/ui/Button'; import { Input } from '../../shared/ui/Input'; import { apiClient } from '../../shared/api/client'; -import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon } from 'lucide-react'; +import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw } from 'lucide-react'; import type { User } from '../../shared/auth/AuthContext'; -import './UserManagementPage.css'; // Will create CSS separately or inline styles initially. Let's assume global css or create specific. +import { useAuth } from '../../shared/auth/AuthContext'; +import './UserManagementPage.css'; interface UserFormData { id: string; @@ -14,10 +15,11 @@ interface UserFormData { department: string; position: string; phone: string; - role: 'admin' | 'user'; + role: 'supervisor' | 'admin' | 'user'; } export function UserManagementPage() { + const { user: currentUser } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); @@ -68,7 +70,7 @@ export function UserManagementPage() { const handleOpenEdit = (user: User) => { setFormData({ id: user.id, - password: '', // Password empty by default on edit + password: '', name: user.name, department: user.department || '', position: user.position || '', @@ -92,28 +94,20 @@ export function UserManagementPage() { const formatPhoneNumber = (value: string) => { const cleaned = value.replace(/\D/g, ''); - if (cleaned.length <= 3) { - return cleaned; - } else if (cleaned.length <= 7) { - return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`; - } else { - return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`; - } + if (cleaned.length <= 3) return cleaned; + else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`; + else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - try { if (isEditing) { - // Update const payload: any = { ...formData }; - if (!payload.password) delete payload.password; // Don't send empty password - + if (!payload.password) delete payload.password; await apiClient.put(`/users/${formData.id}`, payload); alert('수정되었습니다.'); } else { - // Create if (!formData.password) return alert('비밀번호를 입력하세요.'); await apiClient.post('/users', formData); alert('등록되었습니다.'); @@ -121,9 +115,30 @@ export function UserManagementPage() { setIsModalOpen(false); fetchUsers(); } catch (error: any) { - console.error('Submit failed', error); - const errorMsg = error.response?.data?.error || error.message || '저장 중 오류가 발생했습니다.'; - alert(`오류: ${errorMsg}`); + alert(`오류: ${error.response?.data?.error || error.message}`); + } + }; + + const getRoleBadge = (role: string) => { + switch (role) { + case 'supervisor': + return ( + + 최고관리자 + + ); + case 'admin': + return ( + + 관리자 + + ); + default: + return ( + + 사용자 + + ); } }; @@ -137,10 +152,10 @@ export function UserManagementPage() {
- +
- - +
+ @@ -150,39 +165,44 @@ export function UserManagementPage() { - - {users.map((user) => ( - - + {loading ? ( + + - + + ) : users.map((user) => ( + - - + + + ))} - {users.length === 0 && !loading && ( + {!loading && users.length === 0 && ( @@ -194,46 +214,44 @@ export function UserManagementPage() { {/* Modal */} {isModalOpen && ( -
- +
+

{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}

-
- + setFormData({ ...formData, id: e.target.value })} - disabled={isEditing} // ID cannot be changed on edit - placeholder="로그인 아이디" + disabled={isEditing} + placeholder="로그인 아이디 입력" required - autoComplete="off" />
-
- + setFormData({ ...formData, name: e.target.value })} @@ -241,28 +259,41 @@ export function UserManagementPage() { />
- + + {currentUser?.role !== 'supervisor' && ( +

* 최고관리자 권한 부여는 최고관리자만 가능합니다.

+ )}
- + setFormData({ ...formData, department: e.target.value })} />
- + setFormData({ ...formData, position: e.target.value })} @@ -271,18 +302,18 @@ export function UserManagementPage() {
- + setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })} - placeholder="예: 010-1234-5678" + placeholder="010-0000-0000" maxLength={13} />
- +
diff --git a/src/platform/pages/VersionPage.tsx b/src/platform/pages/VersionPage.tsx new file mode 100644 index 0000000..8ff83fd --- /dev/null +++ b/src/platform/pages/VersionPage.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect } from 'react'; +import { Card } from '../../shared/ui/Card'; +import { apiClient } from '../../shared/api/client'; +import { Info, Cpu, Database, Server, Hash, Calendar } from 'lucide-react'; + +interface VersionInfo { + status: string; + version: string; + timestamp: string; +} + +export function VersionPage() { + const [healthIcon, setHealthInfo] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchVersion = async () => { + try { + const res = await apiClient.get('/health'); + setHealthInfo(res.data); + } catch (err) { + console.error('Failed to fetch version info', err); + } finally { + setLoading(false); + } + }; + fetchVersion(); + }, []); + + // Frontend version (aligned with package.json) + const frontendVersion = '0.2.5'; + const buildDate = '2026-01-24'; + + return ( +
+
+

시스템 관리 - 버전 정보

+

플랫폼 및 서버의 현재 릴리즈 버전을 확인합니다.

+
+ +
+ {/* Frontend Platform Version */} + +
+ +
+
+
+ +
+
+

Frontend Platform

+

Smart IMS 클라이언트

+
+
+ +
+
+ 현재 버전 + v{frontendVersion} +
+
+ 빌드 일자 + {buildDate} +
+
+ 아키텍처 + React Agentic Architecture +
+
+
+ + {/* Backend Server Version */} + +
+ +
+
+
+ +
+
+

Backend Core

+

IMS 서비스 엔진

+
+
+ +
+
+ API 버전 + + {loading ? 'Checking...' : healthIcon?.version ? `v${healthIcon.version}` : 'N/A'} + +
+
+ 서버 타임스탬프 + + {loading ? '...' : healthIcon?.timestamp || 'Unknown'} + +
+
+ 엔진 상태 + + {loading ? '...' : healthIcon?.status === 'ok' ? 'Running' : 'Offline'} + +
+
+
+
+ {/* Release History Section */} +
+

+ + 업데이트 히스토리 +

+ +
+ {[ + { + version: '0.2.5', + date: '2026-01-24', + title: '플랫폼 보안 모듈 및 관리자 권한 체계 강화', + changes: [ + '최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 적용', + '데이터베이스 및 암호화 마스터 키 자가 관리 엔진 구축', + '사용자 관리 UI 디자인 및 권한 계층 로직 일원화', + '시스템 버전 관리 통합 (v0.2.5)' + ], + type: 'feature' + }, + { + version: '0.2.1', + date: '2026-01-22', + title: 'IMS 서비스 코어 성능 최적화', + changes: [ + 'API 서버 헬스체크 및 실시간 상태 모니터링 연동', + '보안 세션 타임아웃 유동적 처리 미들웨어 도입', + '자산 관리 모듈 초기 베타 릴리즈' + ], + type: 'fix' + } + ].map((entry, idx) => ( + +
+
+ + {entry.type} + + v{entry.version} +
+
+
+

{entry.title}

+
+
{entry.date}
+
+
    + {entry.changes.map((change, i) => ( +
  • +
    + {change} +
  • + ))} +
+
+ ))} +
+
+ + {/* Bottom Integrity Banner */} + +
+

+ Platform Integrity +

+

+ Smart IMS 플랫폼은 데이터 보안과 시스템 안정성을 최우선으로 설계되었습니다. + 모든 모듈은 Sokuree 아키텍처 표준을 준수하며, 암호화 키 관리 시스템(L2 Protection)에 의해 보호되고 있습니다. +

+
+
+ +
+
+
+ ); +} + +// Internal Local Components if not available in shared/ui +function ShieldCheck({ size, className }: { size?: number, className?: string }) { + return ( + + + + + ); +} + +function Box({ size, className }: { size?: number, className?: string }) { + return ( + + + + + + ); +} diff --git a/src/platform/styles/global.css b/src/platform/styles/global.css index 87a993a..33f35f8 100644 --- a/src/platform/styles/global.css +++ b/src/platform/styles/global.css @@ -60,6 +60,7 @@ body { a { text-decoration: none; color: inherit; + cursor: pointer; } button { diff --git a/src/shared/api/assetApi.ts b/src/shared/api/assetApi.ts index 852750b..2aefcbe 100644 --- a/src/shared/api/assetApi.ts +++ b/src/shared/api/assetApi.ts @@ -69,8 +69,14 @@ export interface Manual { export const assetApi = { getAll: async (): Promise => { - const response = await apiClient.get('/assets'); - return response.data.map(mapDBToAsset); + try { + const response = await apiClient.get('/assets'); + if (!response.data || !Array.isArray(response.data)) return []; + return response.data.map(mapDBToAsset); + } catch (err) { + console.error('API Error in getAll:', err); + throw err; + } }, getById: async (id: string): Promise => { diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index efc62f4..88e9a33 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -25,3 +25,18 @@ apiClient.interceptors.request.use((config) => { } return config; }); +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && (error.response.status === 401 || error.response.status === 403)) { + // Avoid infinite loops if we are already on login page or checking session + const currentPath = window.location.pathname; + if (currentPath !== '/login' && !error.config.url.includes('/check')) { + console.warn('Session expired or security error. Redirecting to login.'); + // Brute force redirect for simplicity in MVP + window.location.href = '/login?expired=true'; + } + } + return Promise.reject(error); + } +); diff --git a/src/shared/auth/AuthContext.tsx b/src/shared/auth/AuthContext.tsx index 690f7c4..d5d292f 100644 --- a/src/shared/auth/AuthContext.tsx +++ b/src/shared/auth/AuthContext.tsx @@ -4,7 +4,7 @@ import { apiClient, setCsrfToken } from '../api/client'; export interface User { id: string; name: string; - role: 'admin' | 'user'; + role: 'supervisor' | 'admin' | 'user'; department?: string; position?: string; phone?: string; @@ -25,25 +25,82 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); - // Check for existing session on mount + // Check for existing session on mount and periodically useEffect(() => { + let lastActivity = Date.now(); + let timeoutMs = 3600000; // Default 1 hour + const checkSession = async () => { try { const response = await apiClient.get('/check'); if (response.data.isAuthenticated) { setUser(response.data.user); - if (response.data.csrfToken) { - setCsrfToken(response.data.csrfToken); - } + if (response.data.csrfToken) setCsrfToken(response.data.csrfToken); + if (response.data.sessionTimeout) timeoutMs = response.data.sessionTimeout; + } else { + // Safety: only redirect if we were previously logged in + setUser(prev => { + if (prev) { + setCsrfToken(null); + window.location.href = '/login?expired=true'; + return null; + } + return prev; + }); } } catch (error) { - console.error('Session check failed:', error); + setUser(prev => { + if (prev) { + setCsrfToken(null); + window.location.href = '/login?expired=true'; + return null; + } + return prev; + }); } finally { setIsLoading(false); } }; + + const updateActivity = () => { + lastActivity = Date.now(); + }; + + // Activity Listeners + window.addEventListener('mousemove', updateActivity); + window.addEventListener('keydown', updateActivity); + window.addEventListener('scroll', updateActivity); + window.addEventListener('click', updateActivity); + checkSession(); - }, []); + + // Check activity status every 30 seconds + const activityInterval = setInterval(() => { + // Functional check to avoid stale user closure + setUser(current => { + if (current) { + const idleTime = Date.now() - lastActivity; + if (idleTime >= timeoutMs) { + console.log('Idle timeout reached. Checking session...'); + checkSession(); + } + } + return current; + }); + }, 30000); + + // Fallback polling every 5 minutes (for secondary tabs etc) + const pollInterval = setInterval(checkSession, 300000); + + return () => { + window.removeEventListener('mousemove', updateActivity); + window.removeEventListener('keydown', updateActivity); + window.removeEventListener('scroll', updateActivity); + window.removeEventListener('click', updateActivity); + clearInterval(activityInterval); + clearInterval(pollInterval); + }; + }, []); // Removed [user] to prevent infinite loop const login = async (id: string, password: string) => { try { diff --git a/src/widgets/layout/MainLayout.css b/src/widgets/layout/MainLayout.css index 0166124..27634a5 100644 --- a/src/widgets/layout/MainLayout.css +++ b/src/widgets/layout/MainLayout.css @@ -103,6 +103,10 @@ transition: var(--transition-base); font-weight: 500; font-size: 0.9rem; + text-decoration: none; + cursor: pointer; + position: relative; + z-index: 5; } .nav-item:hover { @@ -181,26 +185,39 @@ background-color: var(--sokuree-bg-main); } +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + /* Top Header - White Theme */ .top-header { height: 64px; background-color: var(--sokuree-bg-card); - /* White Header */ border-bottom: 1px solid var(--sokuree-border-color); display: flex; align-items: flex-end; - /* Tabs at bottom */ - padding: 0 2rem; + padding: 0 2.5rem; color: var(--sokuree-text-primary); + justify-content: flex-end; + /* Align to right */ } .header-tabs { display: flex; - gap: 0.5rem; + gap: 1.5rem; + /* Better gap between tabs */ } .tab-item { - padding: 0.75rem 1.5rem; + padding: 0.75rem 0.5rem; font-weight: 500; color: var(--sokuree-text-secondary); border-bottom: 3px solid transparent; @@ -211,20 +228,17 @@ position: relative; top: 1px; text-decoration: none; + white-space: nowrap; } .tab-item:hover { color: var(--sokuree-brand-primary); - background-color: rgba(0, 0, 0, 0.02); - border-top-left-radius: 4px; - border-top-right-radius: 4px; } .tab-item.active { color: var(--sokuree-brand-primary); font-weight: 700; border-bottom-color: var(--sokuree-brand-primary); - /* Blue underline */ } .header-actions { diff --git a/src/widgets/layout/MainLayout.tsx b/src/widgets/layout/MainLayout.tsx index 84aa2b7..8e73e66 100644 --- a/src/widgets/layout/MainLayout.tsx +++ b/src/widgets/layout/MainLayout.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; import { useAuth } from '../../shared/auth/AuthContext'; import { useSystem } from '../../shared/context/SystemContext'; -import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield } from 'lucide-react'; +import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info } from 'lucide-react'; import type { IModuleDefinition } from '../../core/types'; import './MainLayout.css'; @@ -16,6 +16,15 @@ export function MainLayout({ modulesList }: MainLayoutProps) { const { modules } = useSystem(); const [expandedModules, setExpandedModules] = useState(['asset-management']); + const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false; + + const checkRole = (requiredRoles: string[]) => { + if (!user) return false; + // Supervisor possesses all rights + if (user.role === 'supervisor') return true; + return requiredRoles.includes(user.role); + }; + const toggleModule = (moduleId: string) => { setExpandedModules(prev => prev.includes(moduleId) @@ -38,7 +47,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
@@ -75,8 +88,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) { const moduleKey = mod.moduleName.split('-')[0]; if (!isModuleActive(moduleKey)) return null; - // Check roles - if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null; + // Check roles with hierarchy + if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null; const hasSubMenu = mod.routes.filter(r => r.label).length > 1; const isExpanded = expandedModules.includes(mod.moduleName); @@ -188,7 +201,9 @@ export function MainLayout({ modulesList }: MainLayoutProps) { const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath)); if (!activeModule) return null; - const topRoutes = activeModule.routes.filter(r => r.position === 'top'); + const topRoutes = location.pathname.includes('/settings') + ? [] + : activeModule.routes.filter(r => r.position === 'top'); return topRoutes.map(route => ( - {/* Keeping this just in case, but ideally should be managed via route config now? - Actually settings tabs are usually sub-pages of settings, not top-level module nav. - Leaving for safety. - */} - + {/* Asset Settings Specific Tabs */} + {location.pathname.startsWith('/asset/settings') && ( +
+ {[ + { id: 'basic', label: '기본 설정' }, + { id: 'category', label: '카테고리 관리' }, + { id: 'location', label: '설치 위치' }, + { id: 'status', label: '자산 상태' }, + { id: 'maintenance', label: '정비 구분' } + ].map(tab => ( + + {tab.label} + + ))} +
)}
diff --git a/temp_auth.js b/temp_auth.js new file mode 100644 index 0000000..49acaff Binary files /dev/null and b/temp_auth.js differ
아이디 / 권한 이름관리
-
{user.id}
- - {user.role === 'admin' ? : } - {user.role === 'admin' ? '관리자' : '사용자'} - +
+
+ + 사용자 데이터를 불러오는 중... +
{user.name}
-
{user.department || '-'}
-
{user.position}
+
{user.id}
+
{getRoleBadge(user.role)}
{user.phone || '-'} - {user.last_login ? new Date(user.last_login).toLocaleString() : '접속 기록 없음'} + {user.name} +
{user.department || '-'}
+
{user.position}
+
{user.phone || '-'} + {user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'} -
- -
등록된 사용자가 없습니다.