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",
"private": true,
"version": "0.2.0",
"version": "0.2.5",
"type": "module",
"scripts": {
"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',
secret: process.env.SESSION_SECRET || 'smartims_session_secret_key',
store: sessionStore,
resave: true, // Force save to avoid session loss in some environments
resave: false,
saveUninitialized: false,
rolling: false, // Do not automatic rolling (we control it in middleware)
cookie: {
httpOnly: true,
secure: false, // Set true if using HTTPS
maxAge: null, // Browser session by default
secure: false, // HTTPS 사용 시 true로 변경 필요
maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정)
sameSite: 'lax'
}
}));
@ -79,13 +80,24 @@ app.use(session({
// Dynamic Session Timeout Middleware
app.use(async (req, res, next) => {
if (req.session && req.session.user) {
// Skip session extension for background check requests
// These requests are prefixed by /api from the client but might be handled differently in middleware
// Checking both common forms for safety
if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) {
return next();
}
try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
// Explicitly save session to ensure store sync
req.session.save();
// Explicitly save session before moving to next middleware
req.session.save((err) => {
if (err) console.error('Session save error:', err);
next();
});
return;
} catch (err) {
console.error('Session timeout fetch error:', err);
}
@ -98,7 +110,10 @@ app.use(csrfProtection);
// Request Logger
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
const now = new Date();
// UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성
const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '');
console.log(`[${kstDate}] ${req.method} ${req.url}`);
next();
});
@ -180,14 +195,21 @@ const initTables = async () => {
department VARCHAR(100),
position VARCHAR(100),
phone VARCHAR(255),
role ENUM('admin', 'user') DEFAULT 'user',
role ENUM('supervisor', 'admin', 'user') DEFAULT 'user',
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`;
await db.query(usersTableSQL);
console.log('✅ Users Table Created');
// Update existing table if needed
try {
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
} catch (e) {
// Ignore if it fails (e.g. column doesn't exist yet handled by SQL above)
}
console.log('✅ Users Table Initialized with Supervisor role');
// Default Admin
const adminId = 'admin';
@ -197,9 +219,12 @@ const initTables = async () => {
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
await db.query(
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
[adminId, hashedPass, '시스템 관리자', 'admin', 'IT팀', '관리자']
[adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자']
);
console.log('✅ Default Admin Created (admin / admin123)');
console.log('✅ Default Admin Created as Supervisor');
} else {
// Ensure existing admin has supervisor role for this transition
await db.query('UPDATE users SET role = "supervisor" WHERE id = ?', [adminId]);
}
}
@ -333,8 +358,14 @@ const initTables = async () => {
};
initTables();
const packageJson = require('./package.json');
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', version: '1.2.0', timestamp: '2026-01-22 21:18' });
res.json({
status: 'ok',
version: packageJson.version,
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0]
});
});
// Routes

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.1.0",
"version": "0.2.5",
"description": "",
"main": "index.js",
"scripts": {
@ -28,4 +28,4 @@
"devDependencies": {
"nodemon": "^3.1.11"
}
}
}

View File

@ -5,54 +5,14 @@ const crypto = require('crypto');
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
const { generateToken } = require('../middleware/csrfMiddleware');
// --- Crypto Utilities ---
// Use a fixed key for MVP. In production, store this securely in .env
// Key must be 32 bytes for aes-256-cbc
// 'my_super_secret_key_manage_asset' is 32 chars?
// let's use a simpler approach to ensure length on startup or fallback
const SECRET_KEY = process.env.ENCRYPTION_KEY || 'smartims_secret_key_0123456789'; // 32 chars needed
// Ideally use a buffer from hex, but string is okay if 32 chars.
// Let's pad it to ensure stability if env is missing.
const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32);
const cryptoUtil = require('../utils/cryptoUtil');
const ALGORITHM = 'aes-256-cbc';
function encrypt(text) {
if (!text) return text;
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
async function encrypt(text) {
return await cryptoUtil.encrypt(text);
}
function decrypt(text) {
if (!text) return text;
// Check if it looks like our encrypted format (hexIV:hexContent)
if (!text.includes(':')) {
return text; // Assume plain text if no separator
}
try {
const textParts = text.split(':');
const ivHex = textParts.shift();
// IV for AES-256-CBC must be 16 bytes (32 hex characters)
if (!ivHex || ivHex.length !== 32) {
return text; // Invalid IV length, return original
}
const iv = Buffer.from(ivHex, 'hex');
const encryptedText = textParts.join(':');
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (e) {
console.error('Decryption failed for:', text, e.message);
return text; // Return original if fail
}
async function decrypt(text) {
return await cryptoUtil.decrypt(text);
}
function hashPassword(password) {
@ -77,7 +37,7 @@ router.post('/login', async (req, res) => {
delete user.password;
// Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it
if (user.phone) user.phone = decrypt(user.phone);
if (user.phone) user.phone = await decrypt(user.phone);
// Save user to session
req.session.user = user;
@ -104,19 +64,30 @@ router.post('/login', async (req, res) => {
});
// 1.5. Check Session (New)
router.get('/check', (req, res) => {
if (req.session.user) {
// Ensure CSRF token exists, if not generate one (edge case)
if (!req.session.csrfToken) {
req.session.csrfToken = generateToken();
router.get('/check', async (req, res) => {
try {
if (req.session.user) {
// Ensure CSRF token exists, if not generate one (edge case)
if (!req.session.csrfToken) {
req.session.csrfToken = generateToken();
}
// Fetch session timeout from settings
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
res.json({
isAuthenticated: true,
user: req.session.user,
csrfToken: req.session.csrfToken,
sessionTimeout: timeoutMinutes * 60 * 1000 // Convert to ms
});
} else {
res.json({ isAuthenticated: false });
}
res.json({
isAuthenticated: true,
user: req.session.user,
csrfToken: req.session.csrfToken
});
} else {
res.json({ isAuthenticated: false });
} catch (err) {
console.error('Check session error:', err);
res.status(500).json({ success: false, message: 'Server error' });
}
});
@ -132,21 +103,52 @@ router.post('/logout', (req, res) => {
});
});
// 1.7 Verify Supervisor (For sensitive settings)
router.post('/verify-supervisor', isAuthenticated, async (req, res) => {
const { password } = req.body;
if (!password) return res.status(400).json({ error: 'Password required' });
try {
if (req.session.user.role !== 'supervisor') {
return res.status(403).json({ error: '권한이 없습니다. 최고관리자만 접근 가능합니다.' });
}
const hashedPassword = hashPassword(password);
const [rows] = await db.query('SELECT 1 FROM users WHERE id = ? AND password = ?', [req.session.user.id, hashedPassword]);
if (rows.length > 0) {
res.json({ success: true, message: 'Verification successful' });
} else {
res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' });
}
} catch (err) {
console.error('Verify supervisor error:', err);
res.status(500).json({ error: '인증 처리 중 오류가 발생했습니다.' });
}
});
// 2. List Users (Admin Only)
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
// ideally check req.user.role if we had middleware, for now assuming client logic protection + internal/local usage
const [rows] = await db.query('SELECT id, name, department, position, phone, role, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
const users = rows.map(u => ({
...u,
phone: decrypt(u.phone) // Decrypt phone for admin view
if (!rows || rows.length === 0) {
return res.json([]);
}
// Use Promise.all for safe async decryption
const users = await Promise.all(rows.map(async (u) => {
const decryptedPhone = await decrypt(u.phone);
return {
...u,
phone: decryptedPhone
};
}));
res.json(users);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
console.error('Failed to list users:', err);
res.status(500).json({ error: '데이터를 불러오는 중 오류가 발생했습니다.' });
}
});
@ -166,7 +168,7 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
}
const hashedPassword = hashPassword(password);
const encryptedPhone = encrypt(phone);
const encryptedPhone = await encrypt(phone);
const sql = `
INSERT INTO users (id, password, name, department, position, phone, role)
@ -213,7 +215,7 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) =>
}
if (phone !== undefined) {
updates.push('phone = ?');
params.push(encrypt(phone));
params.push(await encrypt(phone));
}
if (role) {
updates.push('role = ?');

View File

@ -21,16 +21,72 @@ try {
console.error('❌ Error loading public key:', e);
}
// Helper to check if a setting key is allowed for general get/post
// This prevents modification of sensitive keys if any
const ALLOWED_SETTING_KEYS = [
'subscriber_id',
'session_timeout',
'encryption_key',
'asset_id_rule',
'asset_categories',
'asset_locations',
'asset_statuses',
'asset_maintenance_types'
];
// --- .env File Utilities ---
const envPath = path.join(__dirname, '../.env');
const readEnv = () => {
if (!fs.existsSync(envPath)) return {};
const content = fs.readFileSync(envPath, 'utf8');
const lines = content.split('\n');
const env = {};
lines.forEach(line => {
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
if (match) {
env[match[1]] = match[2] ? match[2].trim() : '';
}
});
return env;
};
const writeEnv = (updates) => {
let content = fs.readFileSync(envPath, 'utf8');
Object.entries(updates).forEach(([key, value]) => {
const regex = new RegExp(`^${key}=.*`, 'm');
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
} else {
content += `\n${key}=${value}`;
}
});
fs.writeFileSync(envPath, content, 'utf8');
};
const mysql = require('mysql2/promise');
// 0. Server Configuration (Subscriber ID & Session Timeout)
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')");
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')");
const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value);
// Include .env DB settings
const env = readEnv();
res.json({
subscriber_id: settings.subscriber_id || '',
session_timeout: parseInt(settings.session_timeout) || 60 // Default 60 min
session_timeout: parseInt(settings.session_timeout) || 60,
encryption_key: settings.encryption_key || '',
db_config: {
host: env.DB_HOST || '',
user: env.DB_USER || '',
password: env.DB_PASSWORD || '',
database: env.DB_NAME || '',
port: env.DB_PORT || '3306'
}
});
} catch (err) {
console.error(err);
@ -39,7 +95,7 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
});
router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
const { subscriber_id, session_timeout } = req.body;
const { subscriber_id, session_timeout, encryption_key, db_config } = req.body;
try {
if (subscriber_id !== undefined) {
@ -48,7 +104,147 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
if (session_timeout !== undefined) {
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]);
}
res.json({ message: 'Settings saved' });
if (encryption_key !== undefined) {
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key);
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
}
// Handle .env DB settings
if (db_config) {
writeEnv({
DB_HOST: db_config.host,
DB_USER: db_config.user,
DB_PASSWORD: db_config.password,
DB_NAME: db_config.database,
DB_PORT: db_config.port
});
}
res.json({ message: 'Settings saved. Server may restart to apply DB changes.' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// --- Crypto & Key Rotation ---
const cryptoUtil = require('../utils/cryptoUtil');
// 0.2 Test DB Connection
router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
const { host, user, password, database, port } = req.body;
let conn;
try {
conn = await mysql.createConnection({
host,
user,
password,
database,
port: parseInt(port) || 3306,
connectTimeout: 5000
});
await conn.query('SELECT 1');
res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' });
} catch (err) {
res.status(400).json({ success: false, error: err.message });
} finally {
if (conn) await conn.end();
}
});
// 0.3 Encryption Key Management & Rotation
router.get('/encryption/status', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
const [userRows] = await db.query('SELECT COUNT(*) as count FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
const currentKey = await cryptoUtil.getMasterKey();
res.json({
current_key: currentKey,
affected_records: {
users: userRows[0].count
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch encryption status' });
}
});
router.post('/encryption/rotate', isAuthenticated, hasRole('admin'), async (req, res) => {
const { new_key } = req.body;
if (!new_key) return res.status(400).json({ error: 'New key is required' });
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const oldKey = await cryptoUtil.getMasterKey();
// 1. Migrate Users Table (phone)
const [users] = await conn.query('SELECT id, phone FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
for (const user of users) {
const decrypted = await cryptoUtil.decrypt(user.phone, oldKey);
const reEncrypted = await cryptoUtil.encrypt(decrypted, new_key);
await conn.query('UPDATE users SET phone = ? WHERE id = ?', [reEncrypted, user.id]);
}
// 2. Update Master Key in settings (Encrypted for DB storage)
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(new_key);
await conn.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
await conn.commit();
cryptoUtil.clearCache(); // Force immediate reload of new key
res.json({ success: true, message: 'Encryption key rotated and data migrated successfully.' });
} catch (err) {
await conn.rollback();
console.error('Rotation failed:', err);
res.status(500).json({ error: 'Key rotation failed: ' + err.message });
} finally {
conn.release();
}
});
// 0-1. Generic Setting Get/Set
router.get('/settings/:key', isAuthenticated, async (req, res) => {
const { key } = req.params;
if (!ALLOWED_SETTING_KEYS.includes(key)) {
return res.status(400).json({ error: 'Invalid setting key' });
}
try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = ?", [key]);
if (rows.length === 0) return res.json({ value: null });
res.json({ value: rows[0].setting_value });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res) => {
const { key } = req.params;
const { value } = req.body;
if (!ALLOWED_SETTING_KEYS.includes(key)) {
return res.status(400).json({ error: 'Invalid setting key' });
}
try {
let stringValue = typeof value === 'string' ? value : JSON.stringify(value);
// Special handling for encryption_key to protect it in DB
if (key === 'encryption_key') {
stringValue = cryptoUtil.encryptMasterKey(stringValue);
}
await db.query(
`INSERT INTO system_settings (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`,
[key, stringValue]
);
res.json({ success: true, message: 'Setting saved' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });

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%;
}
.page-header {
.page-header-right {
display: flex;
justify-content: flex-start;
flex-direction: column;
align-items: flex-end;
text-align: left;
/* Explicitly set text align */
text-align: right;
margin-bottom: 2rem;
width: 100%;
}
.page-title-text {
font-size: 1.5rem;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 0.25rem;
color: var(--sokuree-text-primary);
margin-bottom: 0.5rem;
}
.page-subtitle {
color: var(--color-text-secondary);
font-size: 0.9rem;
color: var(--sokuree-text-secondary);
font-size: 1rem;
}
.content-card {

View File

@ -23,6 +23,7 @@ export function AssetListPage() {
const [currentPage, setCurrentPage] = useState(1);
const [assets, setAssets] = useState<Asset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const itemsPerPage = 8;
// Fetch Assets
@ -33,10 +34,17 @@ export function AssetListPage() {
const loadAssets = async () => {
try {
setIsLoading(true);
setError(null);
const data = await assetApi.getAll();
setAssets(data);
if (Array.isArray(data)) {
setAssets(data);
} else {
console.error("API returned non-array data:", data);
setAssets([]);
}
} catch (error: any) {
console.error("Failed to fetch assets:", error);
setError("데이터를 불러오는 중 오류가 발생했습니다. 서버 연결을 확인해 주세요.");
} finally {
setIsLoading(false);
}
@ -98,8 +106,10 @@ export function AssetListPage() {
// Let's make it strict if possible, or robust for '설비' vs '설비 자산'.
if (asset.category !== currentCategory) {
// Fallback for partial matches if needed, but let's try strict first based on user request.
if (!asset.category.includes(currentCategory)) return false;
// partial match fallback (e.g., '시설' in '시설 자산' or vice versa)
const assetCat = asset.category || '';
const targetCat = currentCategory || '';
if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false;
}
}
@ -240,13 +250,11 @@ export function AssetListPage() {
return (
<div className="page-container">
<div className="page-header" style={{ justifyContent: 'flex-start', textAlign: 'left' }}>
<div>
<h1 className="page-title-text">{getPageTitle()}</h1>
<p className="page-subtitle">
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
</p>
</div>
<div className="page-header-right">
<h1 className="page-title-text">{getPageTitle()}</h1>
<p className="page-subtitle">
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
</p>
</div>
<Card className="content-card">
@ -395,9 +403,18 @@ export function AssetListPage() {
</td>
</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>
<td colSpan={9} className="empty-state">
<td colSpan={10} className="empty-state">
.
</td>
</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 { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
import { assetApi, type Asset } from '../../../shared/api/assetApi';
import './AssetRegisterPage.css';
export function AssetRegisterPage() {
const navigate = useNavigate();
@ -51,13 +52,11 @@ export function AssetRegisterPage() {
if (part.type === 'separator') return part.value;
if (part.type === 'year') return year;
if (part.type === 'category') return category ? category.code : 'UNKNOWN';
if (part.type === 'sequence') return part.value; // In real app, we fetch next seq. here we just show the format pattern e.g. 001
if (part.type === 'sequence') return part.value;
return '';
}).join('');
// Ideally we would fetch the *actual* next sequence from API here.
// For now we assume '001' as a placeholder or "Generating..."
const finalId = generatedId.replace('001', '001'); // Just keeping the placeholder visible for user confirmation
const finalId = generatedId.replace('001', '001');
setFormData(prev => ({ ...prev, id: finalId }));
@ -87,7 +86,7 @@ export function AssetRegisterPage() {
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (e) e.preventDefault();
// Validation
if (!formData.categoryId || !formData.name || !formData.locationId) {
@ -96,11 +95,9 @@ export function AssetRegisterPage() {
}
try {
// Map IDs to Names/Codes for Backend
const selectedCategory = categories.find(c => c.id === formData.categoryId);
const selectedLocation = locations.find(l => l.id === formData.locationId);
// Upload Image if exists
let imageUrl = '';
if (formData.image) {
const uploadRes = await assetApi.uploadImage(formData.image);
@ -110,7 +107,7 @@ export function AssetRegisterPage() {
const payload: Partial<Asset> = {
id: formData.id,
name: formData.name,
category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name
category: selectedCategory ? selectedCategory.name : '미지정',
model: formData.model,
serialNumber: formData.serialNo,
location: selectedLocation ? selectedLocation.name : '미지정',
@ -134,196 +131,208 @@ export function AssetRegisterPage() {
return (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title-text"> </h1>
<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 className="page-header-right">
<h1 className="page-title-text"> </h1>
<p className="page-subtitle"> .</p>
</div>
<Card className="w-full h-full shadow-sm border border-slate-200">
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="col-span-full border-b border-slate-100 pb-2 mb-2">
<h3 className="text-lg font-semibold text-slate-800"> </h3>
</div>
<Card className="w-full shadow-sm border border-slate-200 mb-8">
<form onSubmit={handleSubmit} className="p-2 sm:p-4 lg:p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
{/* Basic Info Section */}
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2">
<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="categoryId"
value={formData.categoryId}
onChange={handleChange}
options={categories.map(c => ({ label: c.name, value: c.id }))}
placeholder="카테고리 선택"
required
/>
<Select
label="카테고리 *"
name="categoryId"
value={formData.categoryId}
onChange={handleChange}
options={categories.map(c => ({ label: c.name, value: c.id }))}
placeholder="카테고리 선택"
required
/>
<Input
label="자산 관리 번호 (자동 생성)"
name="id"
value={formData.id}
disabled
placeholder="카테고리 선택 시 자동 생성됨"
className="bg-slate-50 font-mono text-slate-600"
/>
<Input
label="자산 관리 번호 (자동 생성)"
name="id"
value={formData.id}
disabled
placeholder="카테고리 선택 시 자동 생성됨"
className="bg-slate-50 font-mono text-slate-600"
/>
<Input
label="자산명 *"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="예: CNC 머시닝 센터"
required
/>
<Input
label="자산명 *"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="예: CNC 머시닝 센터"
required
/>
<Input
label="제작사"
name="manufacturer"
value={formData.manufacturer}
onChange={handleChange}
/>
<Input
label="제작사"
name="manufacturer"
value={formData.manufacturer}
onChange={handleChange}
/>
<Input
label="모델명"
name="model"
value={formData.model}
onChange={handleChange}
/>
<Input
label="모델명"
name="model"
value={formData.model}
onChange={handleChange}
/>
<Input
label="시리얼 번호 / 규격"
name="serialNo"
value={formData.serialNo}
onChange={handleChange}
/>
<Input
label="시리얼 번호 / 규격"
name="serialNo"
value={formData.serialNo}
onChange={handleChange}
/>
{/* Image Upload Field */}
<div className="ui-field-container col-span-full">
<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' }}>
{/* Preview Area - Fixed size container */}
<div
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
style={{
width: '400px',
height: '350px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{formData.imagePreview ? (
<img
src={formData.imagePreview}
alt="Preview"
className="w-full h-full object-contain"
{/* Image Upload Field */}
<div className="ui-field-container col-span-full">
<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="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
style={{
width: '400px',
height: '350px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{formData.imagePreview ? (
<img
src={formData.imagePreview}
alt="Preview"
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
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>
{/* 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>
{/* Management 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>
{/* Bottom Action Buttons */}
<div className="form-actions-footer">
<Button
variant="secondary"
onClick={() => navigate(-1)}
icon={<ArrowLeft size={16} />}
>
</Button>
<Button
onClick={handleSubmit}
icon={<Save size={16} />}
>
</Button>
</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>
</Card>
</div>

View File

@ -1,45 +1,45 @@
/* Page Header adjustments for Settings */
.page-header {
/* Page Header - Right Aligned */
.page-header-right {
display: flex;
flex-direction: column;
gap: 1rem;
padding-bottom: 0 !important;
/* Override default bottom padding */
border-bottom: 1px solid var(--color-border);
align-items: flex-end;
text-align: right;
margin-bottom: 2rem;
width: 100%;
}
.header-top {
padding-bottom: 0.5rem;
}
/* Settings Tabs */
.settings-tabs {
display: flex;
gap: 1.5rem;
margin-bottom: -1px;
/* Overlap border */
}
.settings-tab {
padding: 0.75rem 0.5rem;
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text-secondary);
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.settings-tab:hover {
color: var(--color-brand-primary);
}
.settings-tab.active {
color: var(--color-brand-primary);
.page-title-text {
font-size: 1.75rem;
font-weight: 700;
border-bottom-color: var(--color-brand-primary);
color: var(--sokuree-text-primary);
margin-bottom: 0.5rem;
}
.page-subtitle {
font-size: 1rem;
color: var(--sokuree-text-secondary);
}
/* Card Header Styles within Settings */
.card-header {
margin-bottom: 1.5rem;
}
.card-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--sokuree-text-primary);
margin-bottom: 0.25rem;
}
.card-desc {
font-size: 0.95rem;
color: var(--sokuree-text-secondary);
}
/* Local tabs are now hidden and moved to Global TopHeader */
.settings-tabs {
display: none;
}
/* Common Layout */
@ -66,8 +66,8 @@
/* Table Styles */
.table-wrapper {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
border: 1px solid var(--sokuree-border-color);
border-radius: var(--sokuree-radius-md);
overflow: hidden;
}
@ -83,13 +83,13 @@
font-weight: 600;
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--sokuree-border-color);
}
.settings-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
color: var(--color-text-primary);
color: var(--sokuree-text-primary);
vertical-align: middle;
}
@ -144,7 +144,7 @@
.preview-box {
background-color: #f1f5f9;
padding: 1rem;
border-radius: var(--radius-md);
border-radius: var(--sokuree-radius-md);
display: flex;
align-items: center;
gap: 0.75rem;
@ -179,7 +179,7 @@
min-height: 60px;
padding: 0.5rem;
border: 2px dashed #e2e8f0;
border-radius: var(--radius-md);
border-radius: var(--sokuree-radius-md);
background-color: #fcfcfc;
}
@ -282,14 +282,16 @@
height: 32px;
/* Match button height (sm size) */
padding: 0 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border: 1px solid var(--sokuree-border-color);
border-radius: var(--sokuree-radius-sm);
font-size: 0.875rem;
width: 150px;
outline: none;
transition: border-color 0.2s;
background-color: white;
}
.custom-text-input:focus {
border-color: var(--color-brand-primary);
border-color: var(--sokuree-brand-primary);
box-shadow: 0 0 0 2px rgba(var(--sokuree-brand-primary-rgb), 0.1);
}

View File

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

View File

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

View File

@ -13,6 +13,13 @@ export function LoginPage() {
const location = useLocation();
const from = location.state?.from?.pathname || '/asset/dashboard';
const isExpired = new URLSearchParams(location.search).get('expired') === 'true';
useState(() => {
if (isExpired) {
setError('세션이 만료되었습니다. 보안을 위해 다시 로그인해주세요.');
}
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

View File

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

View File

@ -3,23 +3,75 @@ import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input';
import { apiClient } from '../../shared/api/client';
import { Save, Clock, Info } from 'lucide-react';
import { useAuth } from '../../shared/auth/AuthContext';
import { Save, Clock, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock } from 'lucide-react';
interface SystemSettings {
session_timeout: number;
encryption_key: string;
subscriber_id: string;
db_config: {
host: string;
user: string;
password?: string;
database: string;
port: string;
};
}
export function BasicSettingsPage() {
const [settings, setSettings] = useState({
session_timeout: 60
const { user: currentUser } = useAuth();
const [settings, setSettings] = useState<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 [saving, setSaving] = useState(false);
const [rotationStatus, setRotationStatus] = useState<{ currentKey: string; affectedCount: number } | null>(null);
const [rotating, setRotating] = useState(false);
// Supervisor Protection States
const [isDbConfigUnlocked, setIsDbConfigUnlocked] = useState(false);
const [isEncryptionUnlocked, setIsEncryptionUnlocked] = useState(false);
const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | null>(null);
const [showVerifyModal, setShowVerifyModal] = useState(false);
const [verifyPassword, setVerifyPassword] = useState('');
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState('');
useEffect(() => {
fetchSettings();
fetchEncryptionStatus();
}, []);
const fetchSettings = async () => {
try {
const res = await apiClient.get('/system/settings');
setSettings(res.data);
const data = res.data;
if (!data.db_config) {
data.db_config = { host: '', user: '', password: '', database: '', port: '3306' };
}
setSettings(data);
setIsDbVerified(true);
} catch (error) {
console.error('Failed to fetch settings', error);
} finally {
@ -27,95 +79,416 @@ export function BasicSettingsPage() {
}
};
const handleSave = async () => {
setSaving(true);
const fetchEncryptionStatus = async () => {
try {
await apiClient.post('/system/settings', settings);
alert('설정이 저장되었습니다.');
const res = await apiClient.get('/system/encryption/status');
setRotationStatus({
currentKey: res.data.current_key,
affectedCount: Object.values(res.data.affected_records as Record<string, number>).reduce((a, b) => a + b, 0)
});
} catch (error) {
console.error('Save failed', error);
alert('저장 중 오류가 발생했습니다.');
console.error('Failed to fetch encryption status', error);
}
};
const generateNewKey = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()';
let key = 'smartims_';
for (let i = 0; i < 24; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
setSettings({ ...settings, encryption_key: key });
};
const handleRotateKey = async () => {
if (!settings.encryption_key) {
alert('새로운 암호화 키를 입력하거나 생성해 주세요.');
return;
}
const confirmMsg = `암호화 키 변경 및 데이터 마이그레이션을 시작합니다.\n- 대상 레코드: 약 ${rotationStatus?.affectedCount || 0}\n- 소요 시간: 데이터 양에 따라 수 초가 걸릴 수 있습니다.\n\n주의: 절차 진행 중에는 서버 연결이 일시적으로 끊길 수 있으며, 중간에 브라우저를 닫지 마십시오.\n진행하시겠습니까?`;
if (!confirm(confirmMsg)) return;
setRotating(true);
setSaveResults(prev => ({ ...prev, encryption: null }));
try {
await apiClient.post('/system/encryption/rotate', { new_key: settings.encryption_key });
setSaveResults(prev => ({
...prev,
encryption: { success: true, message: '암호화 키 변경 및 데이터 마이그레이션 완료!' }
}));
fetchSettings();
fetchEncryptionStatus();
setTimeout(() => setSaveResults(prev => ({ ...prev, encryption: null })), 5000);
} catch (error: any) {
setSaveResults(prev => ({
...prev,
encryption: { success: false, message: error.response?.data?.error || '재암호화 작업 중 오류가 발생했습니다.' }
}));
} finally {
setRotating(false);
}
};
const handleTestConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await apiClient.post('/system/test-db', settings.db_config);
setTestResult({ success: true, message: res.data.message });
setIsDbVerified(true);
} catch (error: any) {
setTestResult({ success: false, message: error.response?.data?.error || '접속 테스트에 실패했습니다.' });
setIsDbVerified(false);
} finally {
setTesting(false);
}
};
const handleOpenVerify = (target: 'database' | 'encryption') => {
if (currentUser?.role !== 'supervisor') {
alert('해당 권한이 없습니다. 최고관리자(Supervisor)만 접근 가능합니다.');
return;
}
setVerifyingTarget(target);
setShowVerifyModal(true);
};
const handleVerifySupervisor = async () => {
setVerifying(true);
setVerifyError('');
try {
const res = await apiClient.post('/verify-supervisor', { password: verifyPassword });
if (res.data.success) {
if (verifyingTarget === 'database') setIsDbConfigUnlocked(true);
else if (verifyingTarget === 'encryption') setIsEncryptionUnlocked(true);
setShowVerifyModal(false);
setVerifyPassword('');
setVerifyingTarget(null);
}
} catch (error: any) {
setVerifyError(error.response?.data?.message || '인증에 실패했습니다. 비밀번호를 확인해주세요.');
} finally {
setVerifying(false);
}
};
const handleSaveSection = async (section: 'security' | 'encryption' | 'database') => {
if (section === 'database' && !isDbVerified) {
alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.');
return;
}
let payload: any = {};
const successMessage = section === 'database' ? 'DB 설정이 저장되었습니다.' : '설정이 성공적으로 저장되었습니다.';
if (section === 'security') payload = { session_timeout: settings.session_timeout };
else if (section === 'encryption') payload = { encryption_key: settings.encryption_key };
else if (section === 'database') {
if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return;
payload = { db_config: settings.db_config };
}
setSaving(true);
setSaveResults(prev => ({ ...prev, [section]: null }));
try {
await apiClient.post('/system/settings', payload);
setSaveResults(prev => ({ ...prev, [section]: { success: true, message: successMessage } }));
setTimeout(() => setSaveResults(prev => ({ ...prev, [section]: null })), 3000);
if (section !== 'database') fetchSettings();
} catch (error: any) {
setSaveResults(prev => ({
...prev,
[section]: { success: false, message: error.response?.data?.error || '저장 중 오류 발생' }
}));
} finally {
setSaving(false);
}
};
if (loading) return <div className="p-6"> ...</div>;
if (loading) return <div className="p-12 text-center text-slate-500"> ...</div>;
return (
<div className="page-container p-6 max-w-4xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
<div className="page-container p-6 max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
</div>
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Clock size={20} className="text-amber-500" />
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<div className="flex items-center gap-2 flex-wrap text-sm text-slate-600">
<div className="flex items-center gap-1">
<Input
type="number"
min="0"
max="24"
className="!w-auto !mb-0"
style={{ width: '70px', textAlign: 'center' }}
value={Math.floor((settings.session_timeout || 10) / 60)}
onChange={e => {
const newHours = parseInt(e.target.value) || 0;
const currentTotal = settings.session_timeout || 10;
const currentMinutes = currentTotal % 60;
setSettings({ ...settings, session_timeout: (newHours * 60) + currentMinutes });
}}
placeholder="0"
/>
<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 className="space-y-8">
{/* Section 1: Security & Session */}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Clock size={20} className="text-blue-600" />
</h2>
</div>
<div className="p-6">
<label className="block text-sm font-medium text-slate-700 mb-3"> </label>
<div className="flex items-center gap-3 text-sm text-slate-600">
<div className="flex items-center gap-1">
<Input
type="number"
className="!w-20 text-center"
value={Math.floor((settings.session_timeout || 60) / 60)}
onChange={e => {
const h = parseInt(e.target.value) || 0;
setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) });
}}
/>
<span className="font-medium text-slate-900 mx-1"></span>
</div>
<p className="text-xs text-slate-400 mt-1 pl-1">
( {settings.session_timeout || 10} / 5 ~ 24 )
</p>
<div className="flex items-center gap-1">
<Input
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>
</Card>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={saving}
icon={<Save size={18} />}
>
{saving ? '저장 중...' : '설정 저장'}
</Button>
</div>
{/* Section 2: Encryption Master Key & Rotation (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-800 flex items-center gap-2">
<Key size={20} className="text-red-600" />
</h2>
{!isEncryptionUnlocked && (
<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>
{/* 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>
);
}

View File

@ -3,9 +3,10 @@ import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input';
import { apiClient } from '../../shared/api/client';
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon } from 'lucide-react';
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw } from 'lucide-react';
import type { User } from '../../shared/auth/AuthContext';
import './UserManagementPage.css'; // Will create CSS separately or inline styles initially. Let's assume global css or create specific.
import { useAuth } from '../../shared/auth/AuthContext';
import './UserManagementPage.css';
interface UserFormData {
id: string;
@ -14,10 +15,11 @@ interface UserFormData {
department: string;
position: string;
phone: string;
role: 'admin' | 'user';
role: 'supervisor' | 'admin' | 'user';
}
export function UserManagementPage() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
@ -68,7 +70,7 @@ export function UserManagementPage() {
const handleOpenEdit = (user: User) => {
setFormData({
id: user.id,
password: '', // Password empty by default on edit
password: '',
name: user.name,
department: user.department || '',
position: user.position || '',
@ -92,28 +94,20 @@ export function UserManagementPage() {
const formatPhoneNumber = (value: string) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 3) {
return cleaned;
} else if (cleaned.length <= 7) {
return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
} else {
return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
}
if (cleaned.length <= 3) return cleaned;
else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (isEditing) {
// Update
const payload: any = { ...formData };
if (!payload.password) delete payload.password; // Don't send empty password
if (!payload.password) delete payload.password;
await apiClient.put(`/users/${formData.id}`, payload);
alert('수정되었습니다.');
} else {
// Create
if (!formData.password) return alert('비밀번호를 입력하세요.');
await apiClient.post('/users', formData);
alert('등록되었습니다.');
@ -121,9 +115,30 @@ export function UserManagementPage() {
setIsModalOpen(false);
fetchUsers();
} catch (error: any) {
console.error('Submit failed', error);
const errorMsg = error.response?.data?.error || error.message || '저장 중 오류가 발생했습니다.';
alert(`오류: ${errorMsg}`);
alert(`오류: ${error.response?.data?.error || error.message}`);
}
};
const getRoleBadge = (role: string) => {
switch (role) {
case 'supervisor':
return (
<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>
</div>
<Card className="overflow-hidden">
<Card className="overflow-hidden shadow-sm border-slate-200">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left text-slate-600">
<thead className="text-xs text-slate-700 uppercase bg-slate-50 border-b border-slate-200">
<table className="w-full text-sm text-left">
<thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
<tr>
<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>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div className="font-medium text-slate-900">{user.id}</div>
<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'
}`}>
{user.role === 'admin' ? <Shield size={10} className="mr-1" /> : <UserIcon size={10} className="mr-1" />}
{user.role === 'admin' ? '관리자' : '사용자'}
</span>
<tbody className="divide-y divide-slate-100 bg-white">
{loading ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">
<div className="flex flex-col items-center gap-2">
<RefreshCcw size={24} className="animate-spin text-indigo-500" />
<span> ...</span>
</div>
</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">
<div className="text-slate-900">{user.department || '-'}</div>
<div className="text-slate-500 text-xs">{user.position}</div>
<div className="font-bold text-slate-900">{user.id}</div>
<div className="mt-1">{getRoleBadge(user.role)}</div>
</td>
<td className="px-6 py-4">{user.phone || '-'}</td>
<td className="px-6 py-4 text-slate-500">
{user.last_login ? new Date(user.last_login).toLocaleString() : '접속 기록 없음'}
<td className="px-6 py-4 font-semibold text-slate-800">{user.name}</td>
<td className="px-6 py-4">
<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 className="px-6 py-4">
<div className="flex justify-center gap-2">
<button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded" onClick={() => handleOpenEdit(user)} title="수정">
<div className="flex justify-center gap-1">
<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} />
</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} />
</button>
</div>
</td>
</tr>
))}
{users.length === 0 && !loading && (
{!loading && users.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-400"> .</td>
</tr>
@ -194,46 +214,44 @@ export function UserManagementPage() {
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<Card className="w-full max-w-md shadow-2xl animate-in fade-in zoom-in duration-200">
<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 border-slate-200 overflow-hidden">
<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">
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
</h2>
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600">
<X size={20} />
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
<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
value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
disabled={isEditing} // ID cannot be changed on edit
placeholder="로그인 아이디"
disabled={isEditing}
placeholder="로그인 아이디 입력"
required
autoComplete="off"
/>
</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>
</label>
<Input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={isEditing ? "(변경시에만 입력)" : "비밀번호"}
placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
required={!isEditing}
autoComplete="new-password"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<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
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
@ -241,28 +259,41 @@ export function UserManagementPage() {
/>
</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
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}
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="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>
{currentUser?.role !== 'supervisor' && (
<p className="text-[10px] text-slate-400 mt-1">* .</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<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
value={formData.department}
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
/>
</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
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
@ -271,18 +302,18 @@ export function UserManagementPage() {
</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
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
placeholder="예: 010-1234-5678"
placeholder="010-0000-0000"
maxLength={13}
/>
</div>
<div className="pt-4 flex justify-end gap-2">
<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>
</form>
</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 {
text-decoration: none;
color: inherit;
cursor: pointer;
}
button {

View File

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

View File

@ -25,3 +25,18 @@ apiClient.interceptors.request.use((config) => {
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
// Avoid infinite loops if we are already on login page or checking session
const currentPath = window.location.pathname;
if (currentPath !== '/login' && !error.config.url.includes('/check')) {
console.warn('Session expired or security error. Redirecting to login.');
// Brute force redirect for simplicity in MVP
window.location.href = '/login?expired=true';
}
}
return Promise.reject(error);
}
);

View File

@ -4,7 +4,7 @@ import { apiClient, setCsrfToken } from '../api/client';
export interface User {
id: string;
name: string;
role: 'admin' | 'user';
role: 'supervisor' | 'admin' | 'user';
department?: string;
position?: string;
phone?: string;
@ -25,25 +25,82 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Check for existing session on mount
// Check for existing session on mount and periodically
useEffect(() => {
let lastActivity = Date.now();
let timeoutMs = 3600000; // Default 1 hour
const checkSession = async () => {
try {
const response = await apiClient.get('/check');
if (response.data.isAuthenticated) {
setUser(response.data.user);
if (response.data.csrfToken) {
setCsrfToken(response.data.csrfToken);
}
if (response.data.csrfToken) setCsrfToken(response.data.csrfToken);
if (response.data.sessionTimeout) timeoutMs = response.data.sessionTimeout;
} else {
// Safety: only redirect if we were previously logged in
setUser(prev => {
if (prev) {
setCsrfToken(null);
window.location.href = '/login?expired=true';
return null;
}
return prev;
});
}
} catch (error) {
console.error('Session check failed:', error);
setUser(prev => {
if (prev) {
setCsrfToken(null);
window.location.href = '/login?expired=true';
return null;
}
return prev;
});
} finally {
setIsLoading(false);
}
};
const updateActivity = () => {
lastActivity = Date.now();
};
// Activity Listeners
window.addEventListener('mousemove', updateActivity);
window.addEventListener('keydown', updateActivity);
window.addEventListener('scroll', updateActivity);
window.addEventListener('click', updateActivity);
checkSession();
}, []);
// Check activity status every 30 seconds
const activityInterval = setInterval(() => {
// Functional check to avoid stale user closure
setUser(current => {
if (current) {
const idleTime = Date.now() - lastActivity;
if (idleTime >= timeoutMs) {
console.log('Idle timeout reached. Checking session...');
checkSession();
}
}
return current;
});
}, 30000);
// Fallback polling every 5 minutes (for secondary tabs etc)
const pollInterval = setInterval(checkSession, 300000);
return () => {
window.removeEventListener('mousemove', updateActivity);
window.removeEventListener('keydown', updateActivity);
window.removeEventListener('scroll', updateActivity);
window.removeEventListener('click', updateActivity);
clearInterval(activityInterval);
clearInterval(pollInterval);
};
}, []); // Removed [user] to prevent infinite loop
const login = async (id: string, password: string) => {
try {

View File

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

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../shared/auth/AuthContext';
import { useSystem } from '../../shared/context/SystemContext';
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield } from 'lucide-react';
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info } from 'lucide-react';
import type { IModuleDefinition } from '../../core/types';
import './MainLayout.css';
@ -16,6 +16,15 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const { modules } = useSystem();
const [expandedModules, setExpandedModules] = useState<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) => {
setExpandedModules(prev =>
prev.includes(moduleId)
@ -38,7 +47,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
<nav className="sidebar-nav">
{/* Module: System Management (Platform Core) */}
{user?.role === 'admin' && (
{isAdmin && (
<div className="module-group">
<button
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
@ -65,6 +74,10 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
<Shield size={18} />
<span>/ </span>
</Link>
<Link to="/admin/version" className={`nav-item ${location.pathname.includes('/admin/version') ? 'active' : ''}`}>
<Info size={18} />
<span> </span>
</Link>
</div>
)}
</div>
@ -75,8 +88,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const moduleKey = mod.moduleName.split('-')[0];
if (!isModuleActive(moduleKey)) return null;
// Check roles
if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null;
// Check roles with hierarchy
if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
const isExpanded = expandedModules.includes(mod.moduleName);
@ -188,7 +201,9 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath));
if (!activeModule) return null;
const topRoutes = activeModule.routes.filter(r => r.position === 'top');
const topRoutes = location.pathname.includes('/settings')
? []
: activeModule.routes.filter(r => r.position === 'top');
return topRoutes.map(route => (
<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) */}
{location.pathname.includes('/asset/settings') && (
<>
{/* Keeping this just in case, but ideally should be managed via route config now?
Actually settings tabs are usually sub-pages of settings, not top-level module nav.
Leaving for safety.
*/}
</>
{/* Asset Settings Specific Tabs */}
{location.pathname.startsWith('/asset/settings') && (
<div className="header-tabs">
{[
{ id: 'basic', label: '기본 설정' },
{ 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>
</header>

BIN
temp_auth.js Normal file

Binary file not shown.