feat: 플랫폼 보안 강화, 권한 계층 시스템 도입 및 버전 관리 통합 (v0.2.5)

- 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용
- 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축
- 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화
- 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5)
- 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화
This commit is contained in:
choibk 2026-01-24 17:17:33 +09:00
parent 7573c18832
commit 8b2589b6fa
34 changed files with 2042 additions and 570 deletions

BIN
auth_head.js Normal file

Binary file not shown.

BIN
auth_v0.js Normal file

Binary file not shown.

BIN
auth_v1.js Normal file

Binary file not shown.

View File

@ -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를 사용하여 일관성을 유지합니다.

45
docs/ENVIRONMENT_SETUP.md Normal file
View File

@ -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`)을 수행하세요.

BIN
history.txt Normal file

Binary file not shown.

View File

@ -1,7 +1,7 @@
{ {
"name": "smartims", "name": "smartims",
"private": true, "private": true,
"version": "0.2.0", "version": "0.2.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

36
server/fix_admin.js Normal file
View File

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

View File

@ -66,12 +66,13 @@ app.use(session({
key: 'smartims_sid', key: 'smartims_sid',
secret: process.env.SESSION_SECRET || 'smartims_session_secret_key', secret: process.env.SESSION_SECRET || 'smartims_session_secret_key',
store: sessionStore, store: sessionStore,
resave: true, // Force save to avoid session loss in some environments resave: false,
saveUninitialized: false, saveUninitialized: false,
rolling: false, // Do not automatic rolling (we control it in middleware)
cookie: { cookie: {
httpOnly: true, httpOnly: true,
secure: false, // Set true if using HTTPS secure: false, // HTTPS 사용 시 true로 변경 필요
maxAge: null, // Browser session by default maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정)
sameSite: 'lax' sameSite: 'lax'
} }
})); }));
@ -79,13 +80,24 @@ app.use(session({
// Dynamic Session Timeout Middleware // Dynamic Session Timeout Middleware
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
if (req.session && req.session.user) { 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 { try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); 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; const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000; req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
// Explicitly save session to ensure store sync // Explicitly save session before moving to next middleware
req.session.save(); req.session.save((err) => {
if (err) console.error('Session save error:', err);
next();
});
return;
} catch (err) { } catch (err) {
console.error('Session timeout fetch error:', err); console.error('Session timeout fetch error:', err);
} }
@ -98,7 +110,10 @@ app.use(csrfProtection);
// Request Logger // Request Logger
app.use((req, res, next) => { 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(); next();
}); });
@ -180,14 +195,21 @@ const initTables = async () => {
department VARCHAR(100), department VARCHAR(100),
position VARCHAR(100), position VARCHAR(100),
phone VARCHAR(255), phone VARCHAR(255),
role ENUM('admin', 'user') DEFAULT 'user', role ENUM('supervisor', 'admin', 'user') DEFAULT 'user',
last_login TIMESTAMP NULL, last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`; `;
await db.query(usersTableSQL); 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 // Default Admin
const adminId = 'admin'; const adminId = 'admin';
@ -197,9 +219,12 @@ const initTables = async () => {
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex'); const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
await db.query( await db.query(
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)', '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(); initTables();
const packageJson = require('./package.json');
app.get('/api/health', (req, res) => { 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 // Routes

View File

@ -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) => { const isAuthenticated = (req, res, next) => {
if (req.session && req.session.user) { if (req.session && req.session.user) {
return next(); return next();
@ -5,13 +17,17 @@ const isAuthenticated = (req, res, next) => {
return res.status(401).json({ success: false, message: 'Unauthorized' }); return res.status(401).json({ success: false, message: 'Unauthorized' });
}; };
const hasRole = (...roles) => { const hasRole = (requiredRole) => {
return (req, res, next) => { return (req, res, next) => {
if (!req.session || !req.session.user) { if (!req.session || !req.session.user) {
return res.status(401).json({ success: false, message: 'Unauthorized' }); 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(); return next();
} }
@ -19,4 +35,4 @@ const hasRole = (...roles) => {
}; };
}; };
module.exports = { isAuthenticated, hasRole }; module.exports = { isAuthenticated, hasRole, ROLES };

View File

@ -28,6 +28,7 @@ const csrfProtection = (req, res, next) => {
console.error(`- Path: ${req.path}`); console.error(`- Path: ${req.path}`);
console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`); console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`);
console.error(`- Session User: ${req.session?.user?.id || 'GUEST'}`); 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 Session: ${tokenFromSession ? 'EXISTS (' + tokenFromSession.substring(0, 5) + '...)' : 'MISSING'}`);
console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`); console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`);

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.1.0", "version": "0.2.5",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -5,54 +5,14 @@ const crypto = require('crypto');
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware'); const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
const { generateToken } = require('../middleware/csrfMiddleware'); const { generateToken } = require('../middleware/csrfMiddleware');
// --- Crypto Utilities --- const cryptoUtil = require('../utils/cryptoUtil');
// 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 ALGORITHM = 'aes-256-cbc'; async function encrypt(text) {
return await cryptoUtil.encrypt(text);
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;
} }
function decrypt(text) { async function decrypt(text) {
if (!text) return text; return await cryptoUtil.decrypt(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
}
} }
function hashPassword(password) { function hashPassword(password) {
@ -77,7 +37,7 @@ router.post('/login', async (req, res) => {
delete user.password; delete user.password;
// Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it // 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 // Save user to session
req.session.user = user; req.session.user = user;
@ -104,19 +64,30 @@ router.post('/login', async (req, res) => {
}); });
// 1.5. Check Session (New) // 1.5. Check Session (New)
router.get('/check', (req, res) => { router.get('/check', async (req, res) => {
if (req.session.user) { try {
// Ensure CSRF token exists, if not generate one (edge case) if (req.session.user) {
if (!req.session.csrfToken) { // Ensure CSRF token exists, if not generate one (edge case)
req.session.csrfToken = generateToken(); 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({ } catch (err) {
isAuthenticated: true, console.error('Check session error:', err);
user: req.session.user, res.status(500).json({ success: false, message: 'Server error' });
csrfToken: req.session.csrfToken
});
} else {
res.json({ isAuthenticated: false });
} }
}); });
@ -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) // 2. List Users (Admin Only)
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
try { 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 [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 => ({ if (!rows || rows.length === 0) {
...u, return res.json([]);
phone: decrypt(u.phone) // Decrypt phone for admin view }
// 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); res.json(users);
} catch (err) { } catch (err) {
console.error(err); console.error('Failed to list users:', err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: '데이터를 불러오는 중 오류가 발생했습니다.' });
} }
}); });
@ -166,7 +168,7 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
} }
const hashedPassword = hashPassword(password); const hashedPassword = hashPassword(password);
const encryptedPhone = encrypt(phone); const encryptedPhone = await encrypt(phone);
const sql = ` const sql = `
INSERT INTO users (id, password, name, department, position, phone, role) 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) { if (phone !== undefined) {
updates.push('phone = ?'); updates.push('phone = ?');
params.push(encrypt(phone)); params.push(await encrypt(phone));
} }
if (role) { if (role) {
updates.push('role = ?'); updates.push('role = ?');

View File

@ -21,16 +21,72 @@ try {
console.error('❌ Error loading public key:', e); 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) // 0. Server Configuration (Subscriber ID & Session Timeout)
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
try { 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 = {}; const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value); rows.forEach(r => settings[r.setting_key] = r.setting_value);
// Include .env DB settings
const env = readEnv();
res.json({ res.json({
subscriber_id: settings.subscriber_id || '', 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) { } catch (err) {
console.error(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) => { 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 { try {
if (subscriber_id !== undefined) { if (subscriber_id !== undefined) {
@ -48,7 +104,147 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
if (session_timeout !== undefined) { 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()]); 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) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });

111
server/utils/cryptoUtil.js Normal file
View File

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

View File

@ -6,24 +6,25 @@
height: 100%; height: 100%;
} }
.page-header { .page-header-right {
display: flex; display: flex;
justify-content: flex-start; flex-direction: column;
align-items: flex-end; align-items: flex-end;
text-align: left; text-align: right;
/* Explicitly set text align */ margin-bottom: 2rem;
width: 100%;
} }
.page-title-text { .page-title-text {
font-size: 1.5rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
margin-bottom: 0.25rem; margin-bottom: 0.5rem;
} }
.page-subtitle { .page-subtitle {
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
font-size: 0.9rem; font-size: 1rem;
} }
.content-card { .content-card {

View File

@ -23,6 +23,7 @@ export function AssetListPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const itemsPerPage = 8; const itemsPerPage = 8;
// Fetch Assets // Fetch Assets
@ -33,10 +34,17 @@ export function AssetListPage() {
const loadAssets = async () => { const loadAssets = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null);
const data = await assetApi.getAll(); 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) { } catch (error: any) {
console.error("Failed to fetch assets:", error); console.error("Failed to fetch assets:", error);
setError("데이터를 불러오는 중 오류가 발생했습니다. 서버 연결을 확인해 주세요.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -98,8 +106,10 @@ export function AssetListPage() {
// Let's make it strict if possible, or robust for '설비' vs '설비 자산'. // Let's make it strict if possible, or robust for '설비' vs '설비 자산'.
if (asset.category !== currentCategory) { if (asset.category !== currentCategory) {
// Fallback for partial matches if needed, but let's try strict first based on user request. // partial match fallback (e.g., '시설' in '시설 자산' or vice versa)
if (!asset.category.includes(currentCategory)) return false; const assetCat = asset.category || '';
const targetCat = currentCategory || '';
if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false;
} }
} }
@ -240,13 +250,11 @@ export function AssetListPage() {
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header" style={{ justifyContent: 'flex-start', textAlign: 'left' }}> <div className="page-header-right">
<div> <h1 className="page-title-text">{getPageTitle()}</h1>
<h1 className="page-title-text">{getPageTitle()}</h1> <p className="page-subtitle">
<p className="page-subtitle"> {currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'} </p>
</p>
</div>
</div> </div>
<Card className="content-card"> <Card className="content-card">
@ -395,9 +403,18 @@ export function AssetListPage() {
</td> </td>
</tr> </tr>
)) ))
) : error ? (
<tr>
<td colSpan={10} className="empty-state text-red-500">
<div className="flex flex-col items-center gap-2">
<span> {error}</span>
<Button size="sm" variant="secondary" onClick={loadAssets}> </Button>
</div>
</td>
</tr>
) : ( ) : (
<tr> <tr>
<td colSpan={9} className="empty-state"> <td colSpan={10} className="empty-state">
. .
</td> </td>
</tr> </tr>

View File

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

View File

@ -7,6 +7,7 @@ import { Select } from '../../../shared/ui/Select';
import { ArrowLeft, Save, Upload } from 'lucide-react'; import { ArrowLeft, Save, Upload } from 'lucide-react';
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage'; import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
import { assetApi, type Asset } from '../../../shared/api/assetApi'; import { assetApi, type Asset } from '../../../shared/api/assetApi';
import './AssetRegisterPage.css';
export function AssetRegisterPage() { export function AssetRegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -51,13 +52,11 @@ export function AssetRegisterPage() {
if (part.type === 'separator') return part.value; if (part.type === 'separator') return part.value;
if (part.type === 'year') return year; if (part.type === 'year') return year;
if (part.type === 'category') return category ? category.code : 'UNKNOWN'; 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 ''; return '';
}).join(''); }).join('');
// Ideally we would fetch the *actual* next sequence from API here. const finalId = generatedId.replace('001', '001');
// For now we assume '001' as a placeholder or "Generating..."
const finalId = generatedId.replace('001', '001'); // Just keeping the placeholder visible for user confirmation
setFormData(prev => ({ ...prev, id: finalId })); setFormData(prev => ({ ...prev, id: finalId }));
@ -87,7 +86,7 @@ export function AssetRegisterPage() {
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); if (e) e.preventDefault();
// Validation // Validation
if (!formData.categoryId || !formData.name || !formData.locationId) { if (!formData.categoryId || !formData.name || !formData.locationId) {
@ -96,11 +95,9 @@ export function AssetRegisterPage() {
} }
try { try {
// Map IDs to Names/Codes for Backend
const selectedCategory = categories.find(c => c.id === formData.categoryId); const selectedCategory = categories.find(c => c.id === formData.categoryId);
const selectedLocation = locations.find(l => l.id === formData.locationId); const selectedLocation = locations.find(l => l.id === formData.locationId);
// Upload Image if exists
let imageUrl = ''; let imageUrl = '';
if (formData.image) { if (formData.image) {
const uploadRes = await assetApi.uploadImage(formData.image); const uploadRes = await assetApi.uploadImage(formData.image);
@ -110,7 +107,7 @@ export function AssetRegisterPage() {
const payload: Partial<Asset> = { const payload: Partial<Asset> = {
id: formData.id, id: formData.id,
name: formData.name, name: formData.name,
category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name category: selectedCategory ? selectedCategory.name : '미지정',
model: formData.model, model: formData.model,
serialNumber: formData.serialNo, serialNumber: formData.serialNo,
location: selectedLocation ? selectedLocation.name : '미지정', location: selectedLocation ? selectedLocation.name : '미지정',
@ -134,196 +131,208 @@ export function AssetRegisterPage() {
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header"> <div className="page-header-right">
<div> <h1 className="page-title-text"> </h1>
<h1 className="page-title-text"> </h1> <p className="page-subtitle"> .</p>
<p className="page-subtitle"> .</p>
</div>
<div className="header-actions-row">
<Button variant="secondary" onClick={() => navigate(-1)} icon={<ArrowLeft size={16} />}></Button>
<Button onClick={handleSubmit} icon={<Save size={16} />}></Button>
</div>
</div> </div>
<Card className="w-full h-full shadow-sm border border-slate-200"> <Card className="w-full shadow-sm border border-slate-200 mb-8">
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6"> <form onSubmit={handleSubmit} className="p-2 sm:p-4 lg:p-6">
{/* Basic Info */} <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
<div className="col-span-full border-b border-slate-100 pb-2 mb-2"> {/* Basic Info Section */}
<h3 className="text-lg font-semibold text-slate-800"> </h3> <div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2">
</div> <div className="w-1 h-6 bg-blue-500 rounded-full"></div>
<h3 className="text-xl font-bold text-slate-800"> </h3>
</div>
<Select <Select
label="카테고리 *" label="카테고리 *"
name="categoryId" name="categoryId"
value={formData.categoryId} value={formData.categoryId}
onChange={handleChange} onChange={handleChange}
options={categories.map(c => ({ label: c.name, value: c.id }))} options={categories.map(c => ({ label: c.name, value: c.id }))}
placeholder="카테고리 선택" placeholder="카테고리 선택"
required required
/> />
<Input <Input
label="자산 관리 번호 (자동 생성)" label="자산 관리 번호 (자동 생성)"
name="id" name="id"
value={formData.id} value={formData.id}
disabled disabled
placeholder="카테고리 선택 시 자동 생성됨" placeholder="카테고리 선택 시 자동 생성됨"
className="bg-slate-50 font-mono text-slate-600" className="bg-slate-50 font-mono text-slate-600"
/> />
<Input <Input
label="자산명 *" label="자산명 *"
name="name" name="name"
value={formData.name} value={formData.name}
onChange={handleChange} onChange={handleChange}
placeholder="예: CNC 머시닝 센터" placeholder="예: CNC 머시닝 센터"
required required
/> />
<Input <Input
label="제작사" label="제작사"
name="manufacturer" name="manufacturer"
value={formData.manufacturer} value={formData.manufacturer}
onChange={handleChange} onChange={handleChange}
/> />
<Input <Input
label="모델명" label="모델명"
name="model" name="model"
value={formData.model} value={formData.model}
onChange={handleChange} onChange={handleChange}
/> />
<Input <Input
label="시리얼 번호 / 규격" label="시리얼 번호 / 규격"
name="serialNo" name="serialNo"
value={formData.serialNo} value={formData.serialNo}
onChange={handleChange} onChange={handleChange}
/> />
{/* Image Upload Field */} {/* Image Upload Field */}
<div className="ui-field-container col-span-full"> <div className="ui-field-container col-span-full">
<label className="ui-label"> </label> <label className="ui-label"> </label>
<div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}> <div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}>
{/* Preview Area - Fixed size container */} <div
<div className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden" style={{
style={{ width: '400px',
width: '400px', height: '350px',
height: '350px', display: 'flex',
display: 'flex', alignItems: 'center',
alignItems: 'center', justifyContent: 'center'
justifyContent: 'center' }}
}} >
> {formData.imagePreview ? (
{formData.imagePreview ? ( <img
<img src={formData.imagePreview}
src={formData.imagePreview} alt="Preview"
alt="Preview" className="w-full h-full object-contain"
className="w-full h-full object-contain" />
) : (
<div
className="text-slate-300"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%'
}}
>
<Upload size={32} strokeWidth={1.5} />
</div>
)}
</div>
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
</label>
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
{formData.image ? formData.image.name : '선택된 파일 없음'}
</span>
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
형식: JPG, PNG, GIF ( 5MB)
</span>
<input
id="image-upload"
type="file"
style={{ display: 'none' }}
accept="image/*"
onChange={handleImageChange}
/> />
) : ( </div>
<div
className="text-slate-300"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%'
}}
>
<Upload size={32} strokeWidth={1.5} />
</div>
)}
</div>
{/* Upload Controls - Moved below */}
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
</label>
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
{formData.image ? formData.image.name : '선택된 파일 없음'}
</span>
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
형식: JPG, PNG, GIF ( 5MB)
</span>
<input
id="image-upload"
type="file"
style={{ display: 'none' }}
accept="image/*"
onChange={handleImageChange}
/>
</div> </div>
</div> </div>
{/* Management Info Section */}
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
<h3 className="text-xl font-bold text-slate-800"> </h3>
</div>
<Select
label="설치 위치 / 보관 장소 *"
name="locationId"
value={formData.locationId}
onChange={handleChange}
options={locations.map(l => ({ label: l.name, value: l.id }))}
placeholder="위치 선택"
required
/>
<Input
label="관리자 / 부서"
name="manager"
value={formData.manager}
onChange={handleChange}
/>
<Select
label="초기 상태"
name="status"
value={formData.status}
onChange={handleChange}
options={[
{ label: '정상 가동 (Active)', value: 'active' },
{ label: '대기 (Idle)', value: 'idle' },
{ label: '설치 중 (Installing)', value: 'installing' },
{ label: '점검 중 (Maintenance)', value: 'maintain' }
]}
/>
<div className="col-span-1"></div>
{/* Purchasing Info Section */}
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
<h3 className="text-xl font-bold text-slate-800"> </h3>
</div>
<Input
label="도입일"
name="purchaseDate"
type="date"
value={formData.purchaseDate}
onChange={handleChange}
/>
<Input
label="구입 가격 (KRW)"
name="purchasePrice"
type="number"
value={formData.purchasePrice}
onChange={handleChange}
placeholder="0"
/>
</div> </div>
{/* Bottom Action Buttons */}
{/* Management Info */} <div className="form-actions-footer">
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4"> <Button
<h3 className="text-lg font-semibold text-slate-800"> </h3> variant="secondary"
onClick={() => navigate(-1)}
icon={<ArrowLeft size={16} />}
>
</Button>
<Button
onClick={handleSubmit}
icon={<Save size={16} />}
>
</Button>
</div> </div>
<Select
label="설치 위치 / 보관 장소 *"
name="locationId"
value={formData.locationId}
onChange={handleChange}
options={locations.map(l => ({ label: l.name, value: l.id }))}
placeholder="위치 선택"
required
/>
<Input
label="관리자 / 부서"
name="manager"
value={formData.manager}
onChange={handleChange}
/>
<Select
label="초기 상태"
name="status"
value={formData.status}
onChange={handleChange}
options={[
{ label: '정상 가동 (Active)', value: 'active' },
{ label: '대기 (Idle)', value: 'idle' },
{ label: '설치 중 (Installing)', value: 'installing' },
{ label: '점검 중 (Maintenance)', value: 'maintain' }
]}
/>
<div className="col-span-1"></div>
{/* Purchasing Info */}
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4">
<h3 className="text-lg font-semibold text-slate-800"> </h3>
</div>
<Input
label="도입일"
name="purchaseDate"
type="date"
value={formData.purchaseDate}
onChange={handleChange}
/>
<Input
label="구입 가격 (KRW)"
name="purchasePrice"
type="number"
value={formData.purchasePrice}
onChange={handleChange}
placeholder="0"
/>
</form> </form>
</Card> </Card>
</div> </div>

View File

@ -1,45 +1,45 @@
/* Page Header adjustments for Settings */ /* Page Header - Right Aligned */
.page-header { .page-header-right {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; align-items: flex-end;
padding-bottom: 0 !important; text-align: right;
/* Override default bottom padding */ margin-bottom: 2rem;
border-bottom: 1px solid var(--color-border); width: 100%;
} }
.header-top { .page-title-text {
padding-bottom: 0.5rem; font-size: 1.75rem;
}
/* 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);
font-weight: 700; 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 */ /* Common Layout */
@ -66,8 +66,8 @@
/* Table Styles */ /* Table Styles */
.table-wrapper { .table-wrapper {
border: 1px solid var(--color-border); border: 1px solid var(--sokuree-border-color);
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
overflow: hidden; overflow: hidden;
} }
@ -83,13 +83,13 @@
font-weight: 600; font-weight: 600;
text-align: left; text-align: left;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--sokuree-border-color);
} }
.settings-table td { .settings-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
vertical-align: middle; vertical-align: middle;
} }
@ -144,7 +144,7 @@
.preview-box { .preview-box {
background-color: #f1f5f9; background-color: #f1f5f9;
padding: 1rem; padding: 1rem;
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@ -179,7 +179,7 @@
min-height: 60px; min-height: 60px;
padding: 0.5rem; padding: 0.5rem;
border: 2px dashed #e2e8f0; border: 2px dashed #e2e8f0;
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
background-color: #fcfcfc; background-color: #fcfcfc;
} }
@ -282,14 +282,16 @@
height: 32px; height: 32px;
/* Match button height (sm size) */ /* Match button height (sm size) */
padding: 0 0.5rem; padding: 0 0.5rem;
border: 1px solid var(--color-border); border: 1px solid var(--sokuree-border-color);
border-radius: var(--radius-sm); border-radius: var(--sokuree-radius-sm);
font-size: 0.875rem; font-size: 0.875rem;
width: 150px; width: 150px;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
background-color: white;
} }
.custom-text-input:focus { .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);
} }

View File

@ -3,7 +3,8 @@ import { useSearchParams } from 'react-router-dom';
import { Card } from '../../../shared/ui/Card'; import { Card } from '../../../shared/ui/Card';
import { Button } from '../../../shared/ui/Button'; import { Button } from '../../../shared/ui/Button';
import { Input } from '../../../shared/ui/Input'; 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 { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; 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 // Types
interface AssetCategory { interface AssetCategory {
id: string; id: string;
@ -96,7 +98,7 @@ interface AssetStatus {
color: string; color: string;
} }
type IDRuleComponentType = 'company' | 'category' | 'year' | 'sequence' | 'separator' | 'custom'; type IDRuleComponentType = 'company' | 'category' | 'year' | 'month' | 'sequence' | 'separator' | 'custom';
interface IDRuleComponent { interface IDRuleComponent {
id: string; id: string;
@ -105,7 +107,13 @@ interface IDRuleComponent {
label: string; label: string;
} }
// Initial Mock Data export interface AssetMaintenanceType {
id: string;
name: string;
color: string;
}
// Internal values for initial fallback
let GLOBAL_CATEGORIES: AssetCategory[] = [ let GLOBAL_CATEGORIES: AssetCategory[] = [
{ id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' }, { id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' },
{ id: '2', name: '공구', code: 'TOL', menuLink: 'tools' }, { id: '2', name: '공구', code: 'TOL', menuLink: 'tools' },
@ -130,7 +138,7 @@ let GLOBAL_STATUSES: AssetStatus[] = [
]; ];
let GLOBAL_ID_RULE: IDRuleComponent[] = [ 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: 'r2', type: 'separator', value: '-', label: '구분자' },
{ id: 'r3', type: 'category', value: '', label: '카테고리' }, { id: 'r3', type: 'category', value: '', label: '카테고리' },
{ id: 'r4', type: 'separator', 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자리)' }, { id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' },
]; ];
export interface AssetMaintenanceType {
id: string;
name: string;
color: string;
}
let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [ let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [
{ id: '1', name: '정기점검', color: 'success' }, { id: '1', name: '정기점검', color: 'success' },
{ id: '2', name: '수리', color: 'danger' }, { id: '2', name: '수리', color: 'danger' },
@ -170,6 +172,68 @@ export function AssetSettingsPage() {
const [maintenanceTypes, setMaintenanceTypes] = useState<AssetMaintenanceType[]>(GLOBAL_MAINTENANCE_TYPES); const [maintenanceTypes, setMaintenanceTypes] = useState<AssetMaintenanceType[]>(GLOBAL_MAINTENANCE_TYPES);
const [idRule, setIdRule] = useState<IDRuleComponent[]>(GLOBAL_ID_RULE); const [idRule, setIdRule] = useState<IDRuleComponent[]>(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 // Form inputs
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [newCategoryCode, setNewCategoryCode] = useState(''); const [newCategoryCode, setNewCategoryCode] = useState('');
@ -445,6 +509,12 @@ export function AssetSettingsPage() {
setCustomRuleText(''); 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 removeRuleComponent = (id: string) => {
const updated = idRule.filter(comp => comp.id !== id); const updated = idRule.filter(comp => comp.id !== id);
setIdRule(updated); setIdRule(updated);
@ -453,10 +523,12 @@ export function AssetSettingsPage() {
const getPreviewId = () => { const getPreviewId = () => {
const now = new Date();
const mockData = { const mockData = {
company: 'HK', company: 'HK', // Fallback
category: 'FAC', category: 'FAC',
year: new Date().getFullYear().toString(), year: now.getFullYear().toString(),
month: String(now.getMonth() + 1).padStart(2, '0'),
sequence: '001', sequence: '001',
separator: '-' separator: '-'
}; };
@ -466,22 +538,33 @@ export function AssetSettingsPage() {
if (part.type === 'company') return part.value; if (part.type === 'company') return part.value;
if (part.type === 'category') return mockData.category; if (part.type === 'category') return mockData.category;
if (part.type === 'year') return mockData.year; if (part.type === 'year') return mockData.year;
if (part.type === 'month') return mockData.month;
if (part.type === 'sequence') return part.value; if (part.type === 'sequence') return part.value;
if (part.type === 'separator') return part.value; if (part.type === 'separator') return part.value;
return ''; return '';
}).join(''); }).join('');
}; };
if (isLoading) return <div className="p-12 text-center text-slate-500 flex flex-col items-center gap-4">
<Loader2 size={32} className="animate-spin" />
...
</div>;
return ( return (
<div className="page-container"> <div className="page-container h-full flex flex-col">
<div className="settings-content mt-4"> <div className="page-header-right">
<h1 className="page-title-text"> </h1>
<p className="page-subtitle"> .</p>
</div>
<div className="settings-content mt-4 flex-1">
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{activeTab === 'basic' && ( {activeTab === 'basic' && (
<Card className="settings-card max-w-5xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> </h2> <h2 className="card-title"> </h2>
<p className="card-desc"> .</p> <p className="card-desc"> .</p>
@ -518,40 +601,57 @@ export function AssetSettingsPage() {
<div className="rule-tools"> <div className="rule-tools">
<h4 className="tools-title"> </h4> <h4 className="tools-title"> </h4>
<div className="tools-grid"> <div className="tools-grid">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: 'auto' }}> <input
<input type="text"
type="text" placeholder="약어 (예: SKR)"
placeholder="약어 (예: HK)" value={companyCodeInput}
value={companyCodeInput} onChange={e => {
onChange={e => setCompanyCodeInput(e.target.value.toUpperCase())} const val = e.target.value.toUpperCase();
className="custom-text-input" setCompanyCodeInput(val);
maxLength={5} updateCompanyValue(val);
style={{ width: '100px' }} }}
/> className="custom-text-input"
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, `회사약어 (${companyCodeInput})`)}> ({companyCodeInput})</Button> maxLength={5}
</div> style={{ width: '80px' }}
/>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, '회사약어')}> ({companyCodeInput})</Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('category', '', '카테고리')}></Button> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('category', '', '카테고리')}></Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('year', 'YYYY', '등록년도')}></Button> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('year', 'YYYY', '등록년도')}></Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('month', 'MM', '월')}> (MM)</Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('sequence', '001', '일련번호')}></Button> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('sequence', '001', '일련번호')}></Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자 (-)')}> (-)</Button> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자')}> (-)</Button>
<div className="custom-tool">
<input <input
type="text" type="text"
placeholder="사용자 정의 텍스트" placeholder="사용자 정의 텍스트"
value={customRuleText} value={customRuleText}
onChange={e => setCustomRuleText(e.target.value)} onChange={e => setCustomRuleText(e.target.value)}
className="custom-text-input" className="custom-text-input"
/> style={{ marginLeft: '0.5rem', width: '130px' }}
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}></Button> />
</div> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}></Button>
</div> </div>
</div> </div>
{/* Bottom Action */}
<div className="mt-12 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('basic')}
className="w-24"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}
{activeTab === 'category' && ( {activeTab === 'category' && (
<Card className="settings-card max-w-4xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> </h2> <h2 className="card-title"> </h2>
<p className="card-desc"> . ( )</p> <p className="card-desc"> . ( )</p>
@ -666,12 +766,25 @@ export function AssetSettingsPage() {
</SortableContext> </SortableContext>
</table> </table>
</div> </div>
{/* Bottom Action */}
<div className="mt-8 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('category')}
className="w-32"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}
{activeTab === 'location' && ( {activeTab === 'location' && (
<Card className="settings-card max-w-3xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> / </h2> <h2 className="card-title"> / </h2>
<p className="card-desc"> .</p> <p className="card-desc"> .</p>
@ -735,12 +848,25 @@ export function AssetSettingsPage() {
</SortableContext> </SortableContext>
</table> </table>
</div> </div>
{/* Bottom Action */}
<div className="mt-8 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('location')}
className="w-32"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}
{activeTab === 'status' && ( {activeTab === 'status' && (
<Card className="settings-card max-w-5xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> </h2> <h2 className="card-title"> </h2>
<p className="card-desc"> (, , ) .</p> <p className="card-desc"> (, , ) .</p>
@ -834,12 +960,25 @@ export function AssetSettingsPage() {
</SortableContext> </SortableContext>
</table> </table>
</div> </div>
{/* Bottom Action */}
<div className="mt-8 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('status')}
className="w-32"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}
{activeTab === 'maintenance' && ( {activeTab === 'maintenance' && (
<Card className="settings-card max-w-4xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> </h2> <h2 className="card-title"> </h2>
<p className="card-desc"> .</p> <p className="card-desc"> .</p>
@ -923,6 +1062,19 @@ export function AssetSettingsPage() {
</SortableContext> </SortableContext>
</table> </table>
</div> </div>
{/* Bottom Action */}
<div className="mt-8 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('maintenance')}
className="w-32"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}

View File

@ -4,8 +4,8 @@
justify-content: center; justify-content: center;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
background-color: var(--color-bg-base); background-color: var(--sokuree-bg-main);
background: linear-gradient(135deg, var(--color-bg-sidebar) 0%, var(--color-brand-primary) 100%); background: linear-gradient(135deg, var(--sokuree-bg-sidebar) 0%, var(--sokuree-brand-primary) 100%);
} }
.login-card { .login-card {
@ -14,8 +14,8 @@
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
padding: 3rem; padding: 3rem;
border-radius: var(--radius-lg); border-radius: var(--sokuree-radius-lg);
box-shadow: var(--shadow-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 { .login-header {
@ -26,22 +26,22 @@
.brand-logo { .brand-logo {
display: inline-flex; display: inline-flex;
padding: 1rem; padding: 1rem;
background-color: var(--color-bg-sidebar); background-color: var(--sokuree-bg-sidebar);
color: var(--color-text-inverse); color: var(--sokuree-text-inverse);
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: var(--shadow-md); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
} }
.login-header h1 { .login-header h1 {
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.login-header p { .login-header p {
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -60,7 +60,7 @@
.form-group label { .form-group label {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
} }
.input-wrapper { .input-wrapper {
@ -72,33 +72,33 @@
.input-icon { .input-icon {
position: absolute; position: absolute;
left: 1rem; left: 1rem;
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
pointer-events: none; pointer-events: none;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 0.75rem 1rem 0.75rem 2.75rem; padding: 0.75rem 1rem 0.75rem 2.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--sokuree-border-color);
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
font-size: 0.9rem; font-size: 0.9rem;
transition: all 0.2s; transition: all 0.2s;
background-color: var(--color-bg-surface); background-color: var(--sokuree-bg-card);
} }
.form-group input:focus { .form-group input:focus {
outline: none; outline: none;
border-color: var(--color-brand-primary); border-color: var(--sokuree-brand-primary);
box-shadow: 0 0 0 3px rgba(82, 109, 130, 0.1); box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
} }
.login-btn { .login-btn {
margin-top: 1rem; margin-top: 1rem;
padding: 0.875rem; padding: 0.875rem;
background-color: var(--color-bg-sidebar); background-color: var(--sokuree-bg-sidebar);
color: white; color: white;
font-weight: 600; font-weight: 600;
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
font-size: 1rem; font-size: 1rem;
letter-spacing: 0.025em; letter-spacing: 0.025em;
transition: all 0.2s; transition: all 0.2s;
@ -107,7 +107,7 @@
.login-btn:hover { .login-btn:hover {
background-color: #1e293b; background-color: #1e293b;
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-md); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
} }
.error-message { .error-message {
@ -116,12 +116,12 @@
text-align: center; text-align: center;
background-color: rgba(239, 68, 68, 0.1); background-color: rgba(239, 68, 68, 0.1);
padding: 0.75rem; padding: 0.75rem;
border-radius: var(--radius-sm); border-radius: var(--sokuree-radius-sm);
} }
.login-footer { .login-footer {
margin-top: 2rem; margin-top: 2rem;
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
} }

View File

@ -13,6 +13,13 @@ export function LoginPage() {
const location = useLocation(); const location = useLocation();
const from = location.state?.from?.pathname || '/asset/dashboard'; 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();

View File

@ -14,6 +14,7 @@ import ModuleLoader from './ModuleLoader';
// Platform / System Pages // Platform / System Pages
import { UserManagementPage } from './pages/UserManagementPage'; import { UserManagementPage } from './pages/UserManagementPage';
import { BasicSettingsPage } from './pages/BasicSettingsPage'; import { BasicSettingsPage } from './pages/BasicSettingsPage';
import { VersionPage } from './pages/VersionPage';
import { LicensePage } from '../system/pages/LicensePage'; import { LicensePage } from '../system/pages/LicensePage';
import './styles/global.css'; import './styles/global.css';
@ -45,6 +46,7 @@ export const App = () => {
<Route path="/admin/users" element={<UserManagementPage />} /> <Route path="/admin/users" element={<UserManagementPage />} />
<Route path="/admin/settings" element={<BasicSettingsPage />} /> <Route path="/admin/settings" element={<BasicSettingsPage />} />
<Route path="/admin/license" element={<LicensePage />} /> <Route path="/admin/license" element={<LicensePage />} />
<Route path="/admin/version" element={<VersionPage />} />
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} /> <Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
</Route> </Route>

View File

@ -3,23 +3,75 @@ import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button'; import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input'; import { Input } from '../../shared/ui/Input';
import { apiClient } from '../../shared/api/client'; 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() { export function BasicSettingsPage() {
const [settings, setSettings] = useState({ const { user: currentUser } = useAuth();
session_timeout: 60 const [settings, setSettings] = useState<SystemSettings>({
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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); 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(() => { useEffect(() => {
fetchSettings(); fetchSettings();
fetchEncryptionStatus();
}, []); }, []);
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
const res = await apiClient.get('/system/settings'); 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) { } catch (error) {
console.error('Failed to fetch settings', error); console.error('Failed to fetch settings', error);
} finally { } finally {
@ -27,95 +79,416 @@ export function BasicSettingsPage() {
} }
}; };
const handleSave = async () => { const fetchEncryptionStatus = async () => {
setSaving(true);
try { try {
await apiClient.post('/system/settings', settings); const res = await apiClient.get('/system/encryption/status');
alert('설정이 저장되었습니다.'); setRotationStatus({
currentKey: res.data.current_key,
affectedCount: Object.values(res.data.affected_records as Record<string, number>).reduce((a, b) => a + b, 0)
});
} catch (error) { } catch (error) {
console.error('Save failed', error); console.error('Failed to fetch encryption status', error);
alert('저장 중 오류가 발생했습니다.'); }
};
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 { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (loading) return <div className="p-6"> ...</div>; if (loading) return <div className="p-12 text-center text-slate-500"> ...</div>;
return ( return (
<div className="page-container p-6 max-w-4xl mx-auto"> <div className="page-container p-6 max-w-5xl mx-auto">
<div className="mb-6"> <div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-slate-900"> - </h1> <div>
<p className="text-slate-500 mt-1"> .</p> <h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
</div> </div>
<div className="space-y-6"> <div className="space-y-8">
{/* Section 1: Security & Session */}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<Card className="p-6"> <div className="p-6 border-b border-slate-50 bg-slate-50/50">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"> <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Clock size={20} className="text-amber-500" /> <Clock size={20} className="text-blue-600" />
</h2> </h2>
<div className="space-y-4"> </div>
<div> <div className="p-6">
<label className="block text-sm font-medium text-slate-700 mb-1"> </label> <label className="block text-sm font-medium text-slate-700 mb-3"> </label>
<div className="flex items-center gap-2 flex-wrap text-sm text-slate-600"> <div className="flex items-center gap-3 text-sm text-slate-600">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Input <Input
type="number" type="number"
min="0" className="!w-20 text-center"
max="24" value={Math.floor((settings.session_timeout || 60) / 60)}
className="!w-auto !mb-0" onChange={e => {
style={{ width: '70px', textAlign: 'center' }} const h = parseInt(e.target.value) || 0;
value={Math.floor((settings.session_timeout || 10) / 60)} setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) });
onChange={e => { }}
const newHours = parseInt(e.target.value) || 0; />
const currentTotal = settings.session_timeout || 10; <span className="font-medium text-slate-900 mx-1"></span>
const currentMinutes = currentTotal % 60;
setSettings({ ...settings, session_timeout: (newHours * 60) + currentMinutes });
}}
placeholder="0"
/>
<span className="mr-2 whitespace-nowrap text-slate-700 font-medium"></span>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
min="0"
max="59"
className="!w-auto !mb-0"
style={{ width: '70px', textAlign: 'center' }}
value={(settings.session_timeout || 10) % 60}
onChange={e => {
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"
/>
<span className="whitespace-nowrap text-slate-700 font-medium"></span>
</div>
<span className="text-slate-500 whitespace-nowrap ml-1"> .</span>
</div> </div>
<p className="text-xs text-slate-400 mt-1 pl-1"> <div className="flex items-center gap-1">
( {settings.session_timeout || 10} / 5 ~ 24 ) <Input
</p> type="number"
className="!w-20 text-center"
value={(settings.session_timeout || 60) % 60}
onChange={e => {
const m = parseInt(e.target.value) || 0;
setSettings({ ...settings, session_timeout: (Math.floor(settings.session_timeout / 60) * 60) + m });
}}
/>
<span className="font-medium text-slate-900 mx-1"></span>
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
<div className="flex-1">
{saveResults.security && (
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.security.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.security.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{saveResults.security.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={fetchSettings}></Button>
<Button size="sm" onClick={() => handleSaveSection('security')} disabled={saving} icon={<Save size={14} />}> </Button>
</div> </div>
</div> </div>
</Card> </Card>
<div className="flex justify-end"> {/* Section 2: Encryption Master Key & Rotation (Supervisor Protected) */}
<Button <Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
onClick={handleSave} <div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
disabled={saving} <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
icon={<Save size={18} />} <Key size={20} className="text-red-600" />
>
{saving ? '저장 중...' : '설정 저장'} </h2>
</Button> {!isEncryptionUnlocked && (
</div> <Button size="sm" variant="secondary" onClick={() => handleOpenVerify('encryption')} icon={<Lock size={14} />}>
/
</Button>
)}
{isEncryptionUnlocked && (
<div className="flex items-center gap-2 text-red-700 bg-red-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-red-200">
<Unlock size={14} /> 완료: 최고관리자
</div>
)}
</div>
{isEncryptionUnlocked ? (
<div className="animate-in fade-in duration-500">
<div className="p-6 space-y-6">
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500 font-bold block mb-1"> </span>
<code className="bg-white p-2 rounded border border-slate-200 block truncate font-mono">{rotationStatus?.currentKey || '-'}</code>
</div>
<div>
<span className="text-slate-500 font-bold block mb-1"> </span>
<div className="text-blue-600 font-bold flex items-center gap-2 mt-1">
<Database size={16} /> {rotationStatus?.affectedCount || 0}
<Button variant="secondary" size="sm" className="ml-2 h-7 text-[10px]" onClick={fetchEncryptionStatus} icon={<RefreshCcw size={12} />}></Button>
</div>
</div>
</div>
<div className="flex gap-2 max-w-xl">
<Input
value={settings.encryption_key}
onChange={e => setSettings({ ...settings, encryption_key: e.target.value })}
placeholder="새로운 키 입력"
className="font-mono flex-1"
/>
<Button variant="secondary" onClick={generateNewKey} icon={<RefreshCcw size={14} />}> </Button>
</div>
</div>
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
<div className="flex-1">
{saveResults.encryption && (
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.encryption.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.encryption.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{saveResults.encryption.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIsEncryptionUnlocked(false)}></Button>
<Button
className="bg-red-600 hover:bg-red-700"
size="sm"
onClick={handleRotateKey}
disabled={rotating || !settings.encryption_key}
icon={<RefreshCcw size={14} className={rotating ? 'animate-spin' : ''} />}
>
{rotating ? '재암호화 진행 중...' : '변경 및 마이그레이션 저장'}
</Button>
</div>
</div>
</div>
) : (
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
<Lock className="mx-auto mb-3 opacity-20" size={48} />
<p className="text-sm font-medium"> .</p>
</div>
)}
</Card>
{/* Section 3: Database Infrastructure (Supervisor Protected) */}
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<ShieldAlert size={20} className="text-indigo-600" />
</h2>
{!isDbConfigUnlocked && (
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('database')} icon={<Lock size={14} />}>
/
</Button>
)}
{isDbConfigUnlocked && (
<div className="flex items-center gap-2 text-indigo-700 bg-indigo-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-indigo-200">
<Unlock size={14} /> 완료: 최고관리자
</div>
)}
</div>
{isDbConfigUnlocked ? (
<div className="animate-in fade-in duration-500">
<div className="p-6 space-y-6">
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg flex gap-4 items-start">
<AlertCircle className="text-indigo-600 shrink-0" size={20} />
<div>
<p className="text-sm font-bold text-indigo-900"> </p>
<p className="text-xs text-indigo-700 leading-relaxed mt-1">
.<br />
.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">DB </label>
<Input value={settings.db_config.host} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, host: e.target.value } }); setIsDbVerified(false); }} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> </label>
<Input value={settings.db_config.user} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, user: e.target.value } }); setIsDbVerified(false); }} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"></label>
<Input type="password" value={settings.db_config.password} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} />
</div>
</div>
</div>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> (Database)</label>
<Input value={settings.db_config.database} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, database: e.target.value } }); setIsDbVerified(false); }} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> (Port)</label>
<Input type="number" value={settings.db_config.port} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, port: e.target.value } }); setIsDbVerified(false); }} />
</div>
</div>
</div>
<div className="p-4 bg-slate-50 border border-slate-100 rounded-lg flex justify-between items-center">
<div className="text-sm font-medium">
{testResult && (
<span className={`flex items-center gap-1 ${testResult.success ? 'text-green-700' : 'text-red-700'}`}>
{testResult.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />} {testResult.message}
</span>
)}
{!testResult && <span className="text-slate-500 italic"> .</span>}
</div>
<Button variant="secondary" size="sm" onClick={handleTestConnection} disabled={testing} icon={<Server size={14} />}>
{testing ? '검증 중...' : '연결 테스트'}
</Button>
</div>
</div>
<div className="px-6 py-4 bg-slate-100/30 border-t border-slate-100 flex items-center justify-between">
<div className="flex-1">
{testResult && !testResult.success && <span className="text-xs text-red-600 font-bold"> .</span>}
{saveResults.database && (
<div className={`text-sm font-medium ${saveResults.database.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.database.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIsDbConfigUnlocked(false)}></Button>
<Button size="sm" className="bg-indigo-600" onClick={() => handleSaveSection('database')} disabled={saving || !isDbVerified} icon={<Save size={14} />}> </Button>
</div>
</div>
</div>
) : (
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
<Lock className="mx-auto mb-3 opacity-20" size={48} />
<p className="text-sm font-medium"> .</p>
</div>
)}
</Card>
</div> </div>
{/* Verification Modal */}
{showVerifyModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-300">
<Card className="w-full max-w-md shadow-2xl border-indigo-200 overflow-hidden">
<div className={`p-6 text-white ${verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'}`}>
<h3 className="text-lg font-bold flex items-center gap-2">
<ShieldAlert size={20} /> ({verifyingTarget === 'encryption' ? '암호화 키' : '인프라 설정'})
</h3>
<p className="text-white/80 text-sm mt-1"> .</p>
</div>
<div className="p-6 space-y-4">
<div className="p-3 bg-red-50 border border-red-100 rounded text-xs text-red-800 leading-relaxed font-bold">
{verifyingTarget === 'encryption'
? "※ 경고: 암호화 키 변경은 시스템 내 모든 민감 데이터를 다시 암호화하는 중대한 작업입니다. 성공 시 기존 데이터를 새 키로 대체하며, 실패 시 데이터 유실의 위험이 있습니다."
: "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2"> </label>
<Input
type="password"
value={verifyPassword}
onChange={e => setVerifyPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
onKeyDown={e => e.key === 'Enter' && handleVerifySupervisor()}
autoFocus
/>
{verifyError && <p className="text-xs text-red-600 mt-2 font-bold">{verifyError}</p>}
</div>
</div>
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowVerifyModal(false); setVerifyError(''); setVerifyPassword(''); setVerifyingTarget(null); }}></Button>
<Button className={verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'} onClick={handleVerifySupervisor} disabled={verifying}>
{verifying ? '인증 중...' : '인증 및 조회'}
</Button>
</div>
</Card>
</div>
)}
</div> </div>
); );
} }

View File

@ -3,9 +3,10 @@ import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button'; import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input'; import { Input } from '../../shared/ui/Input';
import { apiClient } from '../../shared/api/client'; 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 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 { interface UserFormData {
id: string; id: string;
@ -14,10 +15,11 @@ interface UserFormData {
department: string; department: string;
position: string; position: string;
phone: string; phone: string;
role: 'admin' | 'user'; role: 'supervisor' | 'admin' | 'user';
} }
export function UserManagementPage() { export function UserManagementPage() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -68,7 +70,7 @@ export function UserManagementPage() {
const handleOpenEdit = (user: User) => { const handleOpenEdit = (user: User) => {
setFormData({ setFormData({
id: user.id, id: user.id,
password: '', // Password empty by default on edit password: '',
name: user.name, name: user.name,
department: user.department || '', department: user.department || '',
position: user.position || '', position: user.position || '',
@ -92,28 +94,20 @@ export function UserManagementPage() {
const formatPhoneNumber = (value: string) => { const formatPhoneNumber = (value: string) => {
const cleaned = value.replace(/\D/g, ''); const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 3) { if (cleaned.length <= 3) return cleaned;
return cleaned; else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
} else if (cleaned.length <= 7) { else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
if (isEditing) { if (isEditing) {
// Update
const payload: any = { ...formData }; 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); await apiClient.put(`/users/${formData.id}`, payload);
alert('수정되었습니다.'); alert('수정되었습니다.');
} else { } else {
// Create
if (!formData.password) return alert('비밀번호를 입력하세요.'); if (!formData.password) return alert('비밀번호를 입력하세요.');
await apiClient.post('/users', formData); await apiClient.post('/users', formData);
alert('등록되었습니다.'); alert('등록되었습니다.');
@ -121,9 +115,30 @@ export function UserManagementPage() {
setIsModalOpen(false); setIsModalOpen(false);
fetchUsers(); fetchUsers();
} catch (error: any) { } catch (error: any) {
console.error('Submit failed', error); alert(`오류: ${error.response?.data?.error || error.message}`);
const errorMsg = error.response?.data?.error || error.message || '저장 중 오류가 발생했습니다.'; }
alert(`오류: ${errorMsg}`); };
const getRoleBadge = (role: string) => {
switch (role) {
case 'supervisor':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-100 text-amber-700 ring-1 ring-amber-200">
<ShieldCheck size={10} className="mr-1" />
</span>
);
case 'admin':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-100 text-indigo-700 ring-1 ring-indigo-200">
<Shield size={10} className="mr-1" />
</span>
);
default:
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-600 ring-1 ring-slate-200">
<UserIcon size={10} className="mr-1" />
</span>
);
} }
}; };
@ -137,10 +152,10 @@ export function UserManagementPage() {
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}> </Button> <Button onClick={handleOpenAdd} icon={<Plus size={16} />}> </Button>
</div> </div>
<Card className="overflow-hidden"> <Card className="overflow-hidden shadow-sm border-slate-200">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left text-slate-600"> <table className="w-full text-sm text-left">
<thead className="text-xs text-slate-700 uppercase bg-slate-50 border-b border-slate-200"> <thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
<tr> <tr>
<th className="px-6 py-4"> / </th> <th className="px-6 py-4"> / </th>
<th className="px-6 py-4"></th> <th className="px-6 py-4"></th>
@ -150,39 +165,44 @@ export function UserManagementPage() {
<th className="px-6 py-4 text-center"></th> <th className="px-6 py-4 text-center"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100 bg-white">
{users.map((user) => ( {loading ? (
<tr key={user.id} className="hover:bg-slate-50 transition-colors"> <tr>
<td className="px-6 py-4"> <td colSpan={6} className="px-6 py-12 text-center text-slate-400">
<div className="font-medium text-slate-900">{user.id}</div> <div className="flex flex-col items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium mt-1 ${user.role === 'admin' ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-600' <RefreshCcw size={24} className="animate-spin text-indigo-500" />
}`}> <span> ...</span>
{user.role === 'admin' ? <Shield size={10} className="mr-1" /> : <UserIcon size={10} className="mr-1" />} </div>
{user.role === 'admin' ? '관리자' : '사용자'}
</span>
</td> </td>
<td className="px-6 py-4 font-medium">{user.name}</td> </tr>
) : users.map((user) => (
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="text-slate-900">{user.department || '-'}</div> <div className="font-bold text-slate-900">{user.id}</div>
<div className="text-slate-500 text-xs">{user.position}</div> <div className="mt-1">{getRoleBadge(user.role)}</div>
</td> </td>
<td className="px-6 py-4">{user.phone || '-'}</td> <td className="px-6 py-4 font-semibold text-slate-800">{user.name}</td>
<td className="px-6 py-4 text-slate-500"> <td className="px-6 py-4">
{user.last_login ? new Date(user.last_login).toLocaleString() : '접속 기록 없음'} <div className="text-slate-900 font-bold">{user.department || '-'}</div>
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div>
</td>
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td>
<td className="px-6 py-4 text-slate-400 text-[11px]">
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-1">
<button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded" onClick={() => handleOpenEdit(user)} title="수정"> <button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(user)}>
<Edit2 size={16} /> <Edit2 size={16} />
</button> </button>
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded" onClick={() => handleDelete(user.id)} title="삭제"> <button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(user.id)}>
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
))} ))}
{users.length === 0 && !loading && ( {!loading && users.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-400"> .</td> <td colSpan={6} className="px-6 py-12 text-center text-slate-400"> .</td>
</tr> </tr>
@ -194,46 +214,44 @@ export function UserManagementPage() {
{/* Modal */} {/* Modal */}
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<Card className="w-full max-w-md shadow-2xl animate-in fade-in zoom-in duration-200"> <Card className="w-full max-w-md shadow-2xl border-slate-200 overflow-hidden">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50"> <div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h2 className="text-lg font-bold text-slate-800"> <h2 className="text-lg font-bold text-slate-800">
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'} {isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
</h2> </h2>
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600"> <button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
<X size={20} /> <X size={18} />
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off"> <form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <span className="text-red-500">*</span></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label>
<Input <Input
value={formData.id} value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })} onChange={(e) => setFormData({ ...formData, id: e.target.value })}
disabled={isEditing} // ID cannot be changed on edit disabled={isEditing}
placeholder="로그인 아이디" placeholder="로그인 아이디 입력"
required required
autoComplete="off"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
<span className="text-red-500">{!isEditing && '*'}</span> <span className="text-red-500">{!isEditing && '*'}</span>
</label> </label>
<Input <Input
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={isEditing ? "(변경시에만 입력)" : "비밀번호"} placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
required={!isEditing} required={!isEditing}
autoComplete="new-password"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <span className="text-red-500">*</span></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label>
<Input <Input
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
@ -241,28 +259,41 @@ export function UserManagementPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"></label>
<select <select
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500 focus:border-transparent" className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all outline-none"
value={formData.role} value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'user' | 'admin' })} onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
disabled={
// 1. 현재 관리자(admin)는 최고관리자(supervisor)의 권한을 바꿀 수 없음
(isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor' && currentUser?.role !== 'supervisor') ||
// 2. 현재 관리자(admin)는 자기 자신의 권한을 'supervisor'로 올릴 수 없음 (애초에 옵션에서 걸러지겠지만 안전 장치)
(formData.role === 'supervisor' && currentUser?.role !== 'supervisor')
}
> >
<option value="user"> </option> <option value="user"> </option>
<option value="admin"> </option> <option value="admin"></option>
{/* 최고관리자(supervisor) 옵션은 오직 최고관리자만 부여 가능 */}
{(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && (
<option value="supervisor"> (Supervisor)</option>
)}
</select> </select>
{currentUser?.role !== 'supervisor' && (
<p className="text-[10px] text-slate-400 mt-1">* .</p>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"></label>
<Input <Input
value={formData.department} value={formData.department}
onChange={(e) => setFormData({ ...formData, department: e.target.value })} onChange={(e) => setFormData({ ...formData, department: e.target.value })}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"></label>
<Input <Input
value={formData.position} value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })} onChange={(e) => setFormData({ ...formData, position: e.target.value })}
@ -271,18 +302,18 @@ export function UserManagementPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>
<Input <Input
value={formData.phone} value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })} onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
placeholder="예: 010-1234-5678" placeholder="010-0000-0000"
maxLength={13} maxLength={13}
/> />
</div> </div>
<div className="pt-4 flex justify-end gap-2"> <div className="pt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}></Button> <Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}></Button>
<Button type="submit" icon={<Check size={16} />}>{isEditing ? '저장' : '등록'}</Button> <Button type="submit" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '저장' : '전송'}</Button>
</div> </div>
</form> </form>
</Card> </Card>

View File

@ -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<VersionInfo | null>(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 (
<div className="page-container p-6 max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Frontend Platform Version */}
<Card className="p-6 border-slate-200 shadow-sm relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Cpu size={120} />
</div>
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl">
<Hash size={24} />
</div>
<div>
<h3 className="text-sm font-bold text-slate-500 uppercase tracking-wider">Frontend Platform</h3>
<p className="text-xl font-black text-slate-900">Smart IMS </p>
</div>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center py-2 border-b border-slate-50">
<span className="text-slate-500 text-sm flex items-center gap-2"><Info size={14} /> </span>
<span className="font-bold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full text-xs">v{frontendVersion}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-slate-50">
<span className="text-slate-500 text-sm flex items-center gap-2"><Calendar size={14} /> </span>
<span className="font-medium text-slate-700 text-sm">{buildDate}</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-slate-500 text-sm flex items-center gap-2"><Database size={14} /> </span>
<span className="font-medium text-slate-700 text-sm">React Agentic Architecture</span>
</div>
</div>
</Card>
{/* Backend Server Version */}
<Card className="p-6 border-slate-200 shadow-sm relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Server size={120} />
</div>
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-xl">
<Database size={24} />
</div>
<div>
<h3 className="text-sm font-bold text-slate-500 uppercase tracking-wider">Backend Core</h3>
<p className="text-xl font-black text-slate-900">IMS </p>
</div>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center py-2 border-b border-slate-50">
<span className="text-slate-500 text-sm flex items-center gap-2"><Info size={14} /> API </span>
<span className="font-bold text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full text-xs">
{loading ? 'Checking...' : healthIcon?.version ? `v${healthIcon.version}` : 'N/A'}
</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-slate-50">
<span className="text-slate-500 text-sm flex items-center gap-2"><Calendar size={14} /> </span>
<span className="font-medium text-slate-700 text-sm truncate max-w-[150px]">
{loading ? '...' : healthIcon?.timestamp || 'Unknown'}
</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-slate-500 text-sm flex items-center gap-2"><Server size={14} /> </span>
<span className={`font-bold text-xs uppercase ${healthIcon?.status === 'ok' ? 'text-emerald-500' : 'text-red-500'}`}>
{loading ? '...' : healthIcon?.status === 'ok' ? 'Running' : 'Offline'}
</span>
</div>
</div>
</Card>
</div>
{/* Release History Section */}
<div className="mt-12 space-y-6">
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2 mb-4">
<Calendar size={22} className="text-indigo-600" />
</h2>
<div className="space-y-4">
{[
{
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) => (
<Card key={entry.version} className={`p-6 border-slate-200 shadow-sm transition-all hover:border-indigo-200 ${idx === 0 ? 'bg-indigo-50/20 border-indigo-100 ring-2 ring-indigo-50/50' : ''}`}>
<div className="flex flex-col md:flex-row md:items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${entry.type === 'feature' ? 'bg-indigo-600 text-white' : 'bg-slate-200 text-slate-700'}`}>
{entry.type}
</span>
<span className="font-bold text-slate-900 font-mono text-base">v{entry.version}</span>
</div>
<div className="hidden md:block w-px h-4 bg-slate-200 mx-2"></div>
<div className="flex-1">
<h4 className="text-sm font-bold text-slate-800">{entry.title}</h4>
</div>
<div className="text-xs text-slate-400 font-medium px-2 py-1 bg-slate-50 rounded italic">{entry.date}</div>
</div>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
{entry.changes.map((change, i) => (
<li key={i} className="flex items-start gap-2 text-[13px] text-slate-600 leading-relaxed">
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500/50 flex-shrink-0 animate-pulse"></div>
<span>{change}</span>
</li>
))}
</ul>
</Card>
))}
</div>
</div>
{/* Bottom Integrity Banner */}
<Card className="mt-12 p-6 bg-slate-900 text-white border-none shadow-xl overflow-hidden relative">
<div className="relative z-10">
<h3 className="text-lg font-bold mb-2 flex items-center gap-2">
<ShieldCheck size={20} className="text-amber-400" /> Platform Integrity
</h3>
<p className="text-slate-400 text-sm leading-relaxed max-w-2xl">
Smart IMS .
Sokuree , (L2 Protection) .
</p>
</div>
<div className="absolute -bottom-4 -right-4 opacity-10">
<Box size={160} />
</div>
</Card>
</div>
);
}
// Internal Local Components if not available in shared/ui
function ShieldCheck({ size, className }: { size?: number, className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size || 24} height={size || 24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
function Box({ size, className }: { size?: number, className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size || 24} height={size || 24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
);
}

View File

@ -60,6 +60,7 @@ body {
a { a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
cursor: pointer;
} }
button { button {

View File

@ -69,8 +69,14 @@ export interface Manual {
export const assetApi = { export const assetApi = {
getAll: async (): Promise<Asset[]> => { getAll: async (): Promise<Asset[]> => {
const response = await apiClient.get<DBAsset[]>('/assets'); try {
return response.data.map(mapDBToAsset); const response = await apiClient.get<DBAsset[]>('/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<Asset> => { getById: async (id: string): Promise<Asset> => {

View File

@ -25,3 +25,18 @@ apiClient.interceptors.request.use((config) => {
} }
return 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);
}
);

View File

@ -4,7 +4,7 @@ import { apiClient, setCsrfToken } from '../api/client';
export interface User { export interface User {
id: string; id: string;
name: string; name: string;
role: 'admin' | 'user'; role: 'supervisor' | 'admin' | 'user';
department?: string; department?: string;
position?: string; position?: string;
phone?: string; phone?: string;
@ -25,25 +25,82 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// Check for existing session on mount // Check for existing session on mount and periodically
useEffect(() => { useEffect(() => {
let lastActivity = Date.now();
let timeoutMs = 3600000; // Default 1 hour
const checkSession = async () => { const checkSession = async () => {
try { try {
const response = await apiClient.get('/check'); const response = await apiClient.get('/check');
if (response.data.isAuthenticated) { if (response.data.isAuthenticated) {
setUser(response.data.user); setUser(response.data.user);
if (response.data.csrfToken) { if (response.data.csrfToken) setCsrfToken(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) { } 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 { } finally {
setIsLoading(false); 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(); 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) => { const login = async (id: string, password: string) => {
try { try {

View File

@ -103,6 +103,10 @@
transition: var(--transition-base); transition: var(--transition-base);
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
text-decoration: none;
cursor: pointer;
position: relative;
z-index: 5;
} }
.nav-item:hover { .nav-item:hover {
@ -181,26 +185,39 @@
background-color: var(--sokuree-bg-main); 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 - White Theme */
.top-header { .top-header {
height: 64px; height: 64px;
background-color: var(--sokuree-bg-card); background-color: var(--sokuree-bg-card);
/* White Header */
border-bottom: 1px solid var(--sokuree-border-color); border-bottom: 1px solid var(--sokuree-border-color);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
/* Tabs at bottom */ padding: 0 2.5rem;
padding: 0 2rem;
color: var(--sokuree-text-primary); color: var(--sokuree-text-primary);
justify-content: flex-end;
/* Align to right */
} }
.header-tabs { .header-tabs {
display: flex; display: flex;
gap: 0.5rem; gap: 1.5rem;
/* Better gap between tabs */
} }
.tab-item { .tab-item {
padding: 0.75rem 1.5rem; padding: 0.75rem 0.5rem;
font-weight: 500; font-weight: 500;
color: var(--sokuree-text-secondary); color: var(--sokuree-text-secondary);
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
@ -211,20 +228,17 @@
position: relative; position: relative;
top: 1px; top: 1px;
text-decoration: none; text-decoration: none;
white-space: nowrap;
} }
.tab-item:hover { .tab-item:hover {
color: var(--sokuree-brand-primary); 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 { .tab-item.active {
color: var(--sokuree-brand-primary); color: var(--sokuree-brand-primary);
font-weight: 700; font-weight: 700;
border-bottom-color: var(--sokuree-brand-primary); border-bottom-color: var(--sokuree-brand-primary);
/* Blue underline */
} }
.header-actions { .header-actions {

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../shared/auth/AuthContext'; import { useAuth } from '../../shared/auth/AuthContext';
import { useSystem } from '../../shared/context/SystemContext'; 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 type { IModuleDefinition } from '../../core/types';
import './MainLayout.css'; import './MainLayout.css';
@ -16,6 +16,15 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const { modules } = useSystem(); const { modules } = useSystem();
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']); const [expandedModules, setExpandedModules] = useState<string[]>(['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) => { const toggleModule = (moduleId: string) => {
setExpandedModules(prev => setExpandedModules(prev =>
prev.includes(moduleId) prev.includes(moduleId)
@ -38,7 +47,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{/* Module: System Management (Platform Core) */} {/* Module: System Management (Platform Core) */}
{user?.role === 'admin' && ( {isAdmin && (
<div className="module-group"> <div className="module-group">
<button <button
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`} className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
@ -65,6 +74,10 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
<Shield size={18} /> <Shield size={18} />
<span>/ </span> <span>/ </span>
</Link> </Link>
<Link to="/admin/version" className={`nav-item ${location.pathname.includes('/admin/version') ? 'active' : ''}`}>
<Info size={18} />
<span> </span>
</Link>
</div> </div>
)} )}
</div> </div>
@ -75,8 +88,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const moduleKey = mod.moduleName.split('-')[0]; const moduleKey = mod.moduleName.split('-')[0];
if (!isModuleActive(moduleKey)) return null; if (!isModuleActive(moduleKey)) return null;
// Check roles // Check roles with hierarchy
if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null; if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
const hasSubMenu = mod.routes.filter(r => r.label).length > 1; const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
const isExpanded = expandedModules.includes(mod.moduleName); 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)); const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath));
if (!activeModule) return null; 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 => ( return topRoutes.map(route => (
<Link <Link
@ -201,14 +216,25 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
)); ));
})()} })()}
{/* Legacy manual override check (can be removed if Asset Settings is fully migrated to route config) */} {/* Asset Settings Specific Tabs */}
{location.pathname.includes('/asset/settings') && ( {location.pathname.startsWith('/asset/settings') && (
<> <div className="header-tabs">
{/* 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. { id: 'basic', label: '기본 설정' },
Leaving for safety. { id: 'category', label: '카테고리 관리' },
*/} { id: 'location', label: '설치 위치' },
</> { id: 'status', label: '자산 상태' },
{ id: 'maintenance', label: '정비 구분' }
].map(tab => (
<Link
key={tab.id}
to={`/asset/settings?tab=${tab.id}`}
className={`tab-item ${(new URLSearchParams(location.search).get('tab') || 'basic') === tab.id ? 'active' : ''}`}
>
{tab.label}
</Link>
))}
</div>
)} )}
</div> </div>
</header> </header>

BIN
temp_auth.js Normal file

Binary file not shown.