feat: 플랫폼 보안 강화, 권한 계층 시스템 도입 및 버전 관리 통합 (v0.2.5)
- 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용 - 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축 - 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화 - 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5) - 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화
This commit is contained in:
parent
7573c18832
commit
8b2589b6fa
BIN
auth_head.js
Normal file
BIN
auth_head.js
Normal file
Binary file not shown.
BIN
auth_v0.js
Normal file
BIN
auth_v0.js
Normal file
Binary file not shown.
BIN
auth_v1.js
Normal file
BIN
auth_v1.js
Normal file
Binary file not shown.
71
docs/DIRECTORY_STRUCTURE.md
Normal file
71
docs/DIRECTORY_STRUCTURE.md
Normal 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
45
docs/ENVIRONMENT_SETUP.md
Normal 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
BIN
history.txt
Normal file
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "smartims",
|
"name": "smartims",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.2.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
36
server/fix_admin.js
Normal file
36
server/fix_admin.js
Normal 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();
|
||||||
@ -66,12 +66,13 @@ app.use(session({
|
|||||||
key: 'smartims_sid',
|
key: 'smartims_sid',
|
||||||
secret: process.env.SESSION_SECRET || 'smartims_session_secret_key',
|
secret: process.env.SESSION_SECRET || 'smartims_session_secret_key',
|
||||||
store: sessionStore,
|
store: sessionStore,
|
||||||
resave: true, // Force save to avoid session loss in some environments
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
|
rolling: false, // Do not automatic rolling (we control it in middleware)
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: false, // Set true if using HTTPS
|
secure: false, // HTTPS 사용 시 true로 변경 필요
|
||||||
maxAge: null, // Browser session by default
|
maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정)
|
||||||
sameSite: 'lax'
|
sameSite: 'lax'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@ -79,13 +80,24 @@ app.use(session({
|
|||||||
// Dynamic Session Timeout Middleware
|
// Dynamic Session Timeout Middleware
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
if (req.session && req.session.user) {
|
if (req.session && req.session.user) {
|
||||||
|
// Skip session extension for background check requests
|
||||||
|
// These requests are prefixed by /api from the client but might be handled differently in middleware
|
||||||
|
// Checking both common forms for safety
|
||||||
|
if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
||||||
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
|
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
|
||||||
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
|
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
|
||||||
|
|
||||||
// Explicitly save session to ensure store sync
|
// Explicitly save session before moving to next middleware
|
||||||
req.session.save();
|
req.session.save((err) => {
|
||||||
|
if (err) console.error('Session save error:', err);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Session timeout fetch error:', err);
|
console.error('Session timeout fetch error:', err);
|
||||||
}
|
}
|
||||||
@ -98,7 +110,10 @@ app.use(csrfProtection);
|
|||||||
|
|
||||||
// Request Logger
|
// Request Logger
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
const now = new Date();
|
||||||
|
// UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성
|
||||||
|
const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '');
|
||||||
|
console.log(`[${kstDate}] ${req.method} ${req.url}`);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -180,14 +195,21 @@ const initTables = async () => {
|
|||||||
department VARCHAR(100),
|
department VARCHAR(100),
|
||||||
position VARCHAR(100),
|
position VARCHAR(100),
|
||||||
phone VARCHAR(255),
|
phone VARCHAR(255),
|
||||||
role ENUM('admin', 'user') DEFAULT 'user',
|
role ENUM('supervisor', 'admin', 'user') DEFAULT 'user',
|
||||||
last_login TIMESTAMP NULL,
|
last_login TIMESTAMP NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
`;
|
`;
|
||||||
await db.query(usersTableSQL);
|
await db.query(usersTableSQL);
|
||||||
console.log('✅ Users Table Created');
|
|
||||||
|
// Update existing table if needed
|
||||||
|
try {
|
||||||
|
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore if it fails (e.g. column doesn't exist yet handled by SQL above)
|
||||||
|
}
|
||||||
|
console.log('✅ Users Table Initialized with Supervisor role');
|
||||||
|
|
||||||
// Default Admin
|
// Default Admin
|
||||||
const adminId = 'admin';
|
const adminId = 'admin';
|
||||||
@ -197,9 +219,12 @@ const initTables = async () => {
|
|||||||
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
|
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
|
||||||
await db.query(
|
await db.query(
|
||||||
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
[adminId, hashedPass, '시스템 관리자', 'admin', 'IT팀', '관리자']
|
[adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자']
|
||||||
);
|
);
|
||||||
console.log('✅ Default Admin Created (admin / admin123)');
|
console.log('✅ Default Admin Created as Supervisor');
|
||||||
|
} else {
|
||||||
|
// Ensure existing admin has supervisor role for this transition
|
||||||
|
await db.query('UPDATE users SET role = "supervisor" WHERE id = ?', [adminId]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,8 +358,14 @@ const initTables = async () => {
|
|||||||
};
|
};
|
||||||
initTables();
|
initTables();
|
||||||
|
|
||||||
|
const packageJson = require('./package.json');
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ status: 'ok', version: '1.2.0', timestamp: '2026-01-22 21:18' });
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
version: packageJson.version,
|
||||||
|
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
|
|||||||
@ -1,3 +1,15 @@
|
|||||||
|
const ROLES = {
|
||||||
|
SUPERVISOR: 'supervisor',
|
||||||
|
ADMIN: 'admin',
|
||||||
|
USER: 'user'
|
||||||
|
};
|
||||||
|
|
||||||
|
const HIERARCHY = {
|
||||||
|
[ROLES.SUPERVISOR]: 100,
|
||||||
|
[ROLES.ADMIN]: 50,
|
||||||
|
[ROLES.USER]: 10
|
||||||
|
};
|
||||||
|
|
||||||
const isAuthenticated = (req, res, next) => {
|
const isAuthenticated = (req, res, next) => {
|
||||||
if (req.session && req.session.user) {
|
if (req.session && req.session.user) {
|
||||||
return next();
|
return next();
|
||||||
@ -5,13 +17,17 @@ const isAuthenticated = (req, res, next) => {
|
|||||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasRole = (...roles) => {
|
const hasRole = (requiredRole) => {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.session || !req.session.user) {
|
if (!req.session || !req.session.user) {
|
||||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roles.includes(req.session.user.role)) {
|
const userRole = req.session.user.role;
|
||||||
|
const userLevel = HIERARCHY[userRole] || 0;
|
||||||
|
const requiredLevel = HIERARCHY[requiredRole] || 999;
|
||||||
|
|
||||||
|
if (userLevel >= requiredLevel) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,4 +35,4 @@ const hasRole = (...roles) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { isAuthenticated, hasRole };
|
module.exports = { isAuthenticated, hasRole, ROLES };
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const csrfProtection = (req, res, next) => {
|
|||||||
console.error(`- Path: ${req.path}`);
|
console.error(`- Path: ${req.path}`);
|
||||||
console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`);
|
console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`);
|
||||||
console.error(`- Session User: ${req.session?.user?.id || 'GUEST'}`);
|
console.error(`- Session User: ${req.session?.user?.id || 'GUEST'}`);
|
||||||
|
console.error(`- Session MaxAge: ${req.session?.cookie?.maxAge / 1000 / 60} min`);
|
||||||
console.error(`- Token in Session: ${tokenFromSession ? 'EXISTS (' + tokenFromSession.substring(0, 5) + '...)' : 'MISSING'}`);
|
console.error(`- Token in Session: ${tokenFromSession ? 'EXISTS (' + tokenFromSession.substring(0, 5) + '...)' : 'MISSING'}`);
|
||||||
console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`);
|
console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.1.0",
|
"version": "0.2.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -28,4 +28,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.11"
|
"nodemon": "^3.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5,54 +5,14 @@ const crypto = require('crypto');
|
|||||||
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
|
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
|
||||||
const { generateToken } = require('../middleware/csrfMiddleware');
|
const { generateToken } = require('../middleware/csrfMiddleware');
|
||||||
|
|
||||||
// --- Crypto Utilities ---
|
const cryptoUtil = require('../utils/cryptoUtil');
|
||||||
// Use a fixed key for MVP. In production, store this securely in .env
|
|
||||||
// Key must be 32 bytes for aes-256-cbc
|
|
||||||
// 'my_super_secret_key_manage_asset' is 32 chars?
|
|
||||||
// let's use a simpler approach to ensure length on startup or fallback
|
|
||||||
const SECRET_KEY = process.env.ENCRYPTION_KEY || 'smartims_secret_key_0123456789'; // 32 chars needed
|
|
||||||
// Ideally use a buffer from hex, but string is okay if 32 chars.
|
|
||||||
// Let's pad it to ensure stability if env is missing.
|
|
||||||
const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32);
|
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-cbc';
|
async function encrypt(text) {
|
||||||
|
return await cryptoUtil.encrypt(text);
|
||||||
function encrypt(text) {
|
|
||||||
if (!text) return text;
|
|
||||||
const iv = crypto.randomBytes(16);
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
|
||||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
||||||
encrypted += cipher.final('hex');
|
|
||||||
return iv.toString('hex') + ':' + encrypted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decrypt(text) {
|
async function decrypt(text) {
|
||||||
if (!text) return text;
|
return await cryptoUtil.decrypt(text);
|
||||||
// Check if it looks like our encrypted format (hexIV:hexContent)
|
|
||||||
if (!text.includes(':')) {
|
|
||||||
return text; // Assume plain text if no separator
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const textParts = text.split(':');
|
|
||||||
const ivHex = textParts.shift();
|
|
||||||
|
|
||||||
// IV for AES-256-CBC must be 16 bytes (32 hex characters)
|
|
||||||
if (!ivHex || ivHex.length !== 32) {
|
|
||||||
return text; // Invalid IV length, return original
|
|
||||||
}
|
|
||||||
|
|
||||||
const iv = Buffer.from(ivHex, 'hex');
|
|
||||||
const encryptedText = textParts.join(':');
|
|
||||||
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
|
||||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
|
||||||
decrypted += decipher.final('utf8');
|
|
||||||
return decrypted;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Decryption failed for:', text, e.message);
|
|
||||||
return text; // Return original if fail
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashPassword(password) {
|
function hashPassword(password) {
|
||||||
@ -77,7 +37,7 @@ router.post('/login', async (req, res) => {
|
|||||||
delete user.password;
|
delete user.password;
|
||||||
|
|
||||||
// Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it
|
// Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it
|
||||||
if (user.phone) user.phone = decrypt(user.phone);
|
if (user.phone) user.phone = await decrypt(user.phone);
|
||||||
|
|
||||||
// Save user to session
|
// Save user to session
|
||||||
req.session.user = user;
|
req.session.user = user;
|
||||||
@ -104,19 +64,30 @@ router.post('/login', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 1.5. Check Session (New)
|
// 1.5. Check Session (New)
|
||||||
router.get('/check', (req, res) => {
|
router.get('/check', async (req, res) => {
|
||||||
if (req.session.user) {
|
try {
|
||||||
// Ensure CSRF token exists, if not generate one (edge case)
|
if (req.session.user) {
|
||||||
if (!req.session.csrfToken) {
|
// Ensure CSRF token exists, if not generate one (edge case)
|
||||||
req.session.csrfToken = generateToken();
|
if (!req.session.csrfToken) {
|
||||||
|
req.session.csrfToken = generateToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch session timeout from settings
|
||||||
|
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
||||||
|
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: req.session.user,
|
||||||
|
csrfToken: req.session.csrfToken,
|
||||||
|
sessionTimeout: timeoutMinutes * 60 * 1000 // Convert to ms
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({ isAuthenticated: false });
|
||||||
}
|
}
|
||||||
res.json({
|
} catch (err) {
|
||||||
isAuthenticated: true,
|
console.error('Check session error:', err);
|
||||||
user: req.session.user,
|
res.status(500).json({ success: false, message: 'Server error' });
|
||||||
csrfToken: req.session.csrfToken
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.json({ isAuthenticated: false });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -132,21 +103,52 @@ router.post('/logout', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 1.7 Verify Supervisor (For sensitive settings)
|
||||||
|
router.post('/verify-supervisor', isAuthenticated, async (req, res) => {
|
||||||
|
const { password } = req.body;
|
||||||
|
if (!password) return res.status(400).json({ error: 'Password required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.session.user.role !== 'supervisor') {
|
||||||
|
return res.status(403).json({ error: '권한이 없습니다. 최고관리자만 접근 가능합니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = hashPassword(password);
|
||||||
|
const [rows] = await db.query('SELECT 1 FROM users WHERE id = ? AND password = ?', [req.session.user.id, hashedPassword]);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
res.json({ success: true, message: 'Verification successful' });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Verify supervisor error:', err);
|
||||||
|
res.status(500).json({ error: '인증 처리 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 2. List Users (Admin Only)
|
// 2. List Users (Admin Only)
|
||||||
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// ideally check req.user.role if we had middleware, for now assuming client logic protection + internal/local usage
|
|
||||||
const [rows] = await db.query('SELECT id, name, department, position, phone, role, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
const [rows] = await db.query('SELECT id, name, department, position, phone, role, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
||||||
|
|
||||||
const users = rows.map(u => ({
|
if (!rows || rows.length === 0) {
|
||||||
...u,
|
return res.json([]);
|
||||||
phone: decrypt(u.phone) // Decrypt phone for admin view
|
}
|
||||||
|
|
||||||
|
// Use Promise.all for safe async decryption
|
||||||
|
const users = await Promise.all(rows.map(async (u) => {
|
||||||
|
const decryptedPhone = await decrypt(u.phone);
|
||||||
|
return {
|
||||||
|
...u,
|
||||||
|
phone: decryptedPhone
|
||||||
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json(users);
|
res.json(users);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Failed to list users:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: '데이터를 불러오는 중 오류가 발생했습니다.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -166,7 +168,7 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = hashPassword(password);
|
const hashedPassword = hashPassword(password);
|
||||||
const encryptedPhone = encrypt(phone);
|
const encryptedPhone = await encrypt(phone);
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO users (id, password, name, department, position, phone, role)
|
INSERT INTO users (id, password, name, department, position, phone, role)
|
||||||
@ -213,7 +215,7 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) =>
|
|||||||
}
|
}
|
||||||
if (phone !== undefined) {
|
if (phone !== undefined) {
|
||||||
updates.push('phone = ?');
|
updates.push('phone = ?');
|
||||||
params.push(encrypt(phone));
|
params.push(await encrypt(phone));
|
||||||
}
|
}
|
||||||
if (role) {
|
if (role) {
|
||||||
updates.push('role = ?');
|
updates.push('role = ?');
|
||||||
|
|||||||
@ -21,16 +21,72 @@ try {
|
|||||||
console.error('❌ Error loading public key:', e);
|
console.error('❌ Error loading public key:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to check if a setting key is allowed for general get/post
|
||||||
|
// This prevents modification of sensitive keys if any
|
||||||
|
const ALLOWED_SETTING_KEYS = [
|
||||||
|
'subscriber_id',
|
||||||
|
'session_timeout',
|
||||||
|
'encryption_key',
|
||||||
|
'asset_id_rule',
|
||||||
|
'asset_categories',
|
||||||
|
'asset_locations',
|
||||||
|
'asset_statuses',
|
||||||
|
'asset_maintenance_types'
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- .env File Utilities ---
|
||||||
|
const envPath = path.join(__dirname, '../.env');
|
||||||
|
|
||||||
|
const readEnv = () => {
|
||||||
|
if (!fs.existsSync(envPath)) return {};
|
||||||
|
const content = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const env = {};
|
||||||
|
lines.forEach(line => {
|
||||||
|
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
|
||||||
|
if (match) {
|
||||||
|
env[match[1]] = match[2] ? match[2].trim() : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return env;
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeEnv = (updates) => {
|
||||||
|
let content = fs.readFileSync(envPath, 'utf8');
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
const regex = new RegExp(`^${key}=.*`, 'm');
|
||||||
|
if (regex.test(content)) {
|
||||||
|
content = content.replace(regex, `${key}=${value}`);
|
||||||
|
} else {
|
||||||
|
content += `\n${key}=${value}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs.writeFileSync(envPath, content, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
// 0. Server Configuration (Subscriber ID & Session Timeout)
|
// 0. Server Configuration (Subscriber ID & Session Timeout)
|
||||||
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')");
|
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')");
|
||||||
const settings = {};
|
const settings = {};
|
||||||
rows.forEach(r => settings[r.setting_key] = r.setting_value);
|
rows.forEach(r => settings[r.setting_key] = r.setting_value);
|
||||||
|
|
||||||
|
// Include .env DB settings
|
||||||
|
const env = readEnv();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
subscriber_id: settings.subscriber_id || '',
|
subscriber_id: settings.subscriber_id || '',
|
||||||
session_timeout: parseInt(settings.session_timeout) || 60 // Default 60 min
|
session_timeout: parseInt(settings.session_timeout) || 60,
|
||||||
|
encryption_key: settings.encryption_key || '',
|
||||||
|
db_config: {
|
||||||
|
host: env.DB_HOST || '',
|
||||||
|
user: env.DB_USER || '',
|
||||||
|
password: env.DB_PASSWORD || '',
|
||||||
|
database: env.DB_NAME || '',
|
||||||
|
port: env.DB_PORT || '3306'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -39,7 +95,7 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
const { subscriber_id, session_timeout } = req.body;
|
const { subscriber_id, session_timeout, encryption_key, db_config } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (subscriber_id !== undefined) {
|
if (subscriber_id !== undefined) {
|
||||||
@ -48,7 +104,147 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
|
|||||||
if (session_timeout !== undefined) {
|
if (session_timeout !== undefined) {
|
||||||
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]);
|
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]);
|
||||||
}
|
}
|
||||||
res.json({ message: 'Settings saved' });
|
if (encryption_key !== undefined) {
|
||||||
|
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key);
|
||||||
|
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle .env DB settings
|
||||||
|
if (db_config) {
|
||||||
|
writeEnv({
|
||||||
|
DB_HOST: db_config.host,
|
||||||
|
DB_USER: db_config.user,
|
||||||
|
DB_PASSWORD: db_config.password,
|
||||||
|
DB_NAME: db_config.database,
|
||||||
|
DB_PORT: db_config.port
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Settings saved. Server may restart to apply DB changes.' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Crypto & Key Rotation ---
|
||||||
|
const cryptoUtil = require('../utils/cryptoUtil');
|
||||||
|
|
||||||
|
// 0.2 Test DB Connection
|
||||||
|
router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
|
const { host, user, password, database, port } = req.body;
|
||||||
|
|
||||||
|
let conn;
|
||||||
|
try {
|
||||||
|
conn = await mysql.createConnection({
|
||||||
|
host,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
port: parseInt(port) || 3306,
|
||||||
|
connectTimeout: 5000
|
||||||
|
});
|
||||||
|
await conn.query('SELECT 1');
|
||||||
|
res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err.message });
|
||||||
|
} finally {
|
||||||
|
if (conn) await conn.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 0.3 Encryption Key Management & Rotation
|
||||||
|
router.get('/encryption/status', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [userRows] = await db.query('SELECT COUNT(*) as count FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
|
||||||
|
const currentKey = await cryptoUtil.getMasterKey();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
current_key: currentKey,
|
||||||
|
affected_records: {
|
||||||
|
users: userRows[0].count
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch encryption status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/encryption/rotate', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
|
const { new_key } = req.body;
|
||||||
|
if (!new_key) return res.status(400).json({ error: 'New key is required' });
|
||||||
|
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const oldKey = await cryptoUtil.getMasterKey();
|
||||||
|
|
||||||
|
// 1. Migrate Users Table (phone)
|
||||||
|
const [users] = await conn.query('SELECT id, phone FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
|
||||||
|
for (const user of users) {
|
||||||
|
const decrypted = await cryptoUtil.decrypt(user.phone, oldKey);
|
||||||
|
const reEncrypted = await cryptoUtil.encrypt(decrypted, new_key);
|
||||||
|
await conn.query('UPDATE users SET phone = ? WHERE id = ?', [reEncrypted, user.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update Master Key in settings (Encrypted for DB storage)
|
||||||
|
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(new_key);
|
||||||
|
await conn.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
cryptoUtil.clearCache(); // Force immediate reload of new key
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Encryption key rotated and data migrated successfully.' });
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
console.error('Rotation failed:', err);
|
||||||
|
res.status(500).json({ error: 'Key rotation failed: ' + err.message });
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 0-1. Generic Setting Get/Set
|
||||||
|
router.get('/settings/:key', isAuthenticated, async (req, res) => {
|
||||||
|
const { key } = req.params;
|
||||||
|
if (!ALLOWED_SETTING_KEYS.includes(key)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid setting key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = ?", [key]);
|
||||||
|
if (rows.length === 0) return res.json({ value: null });
|
||||||
|
res.json({ value: rows[0].setting_value });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
|
const { key } = req.params;
|
||||||
|
const { value } = req.body;
|
||||||
|
|
||||||
|
if (!ALLOWED_SETTING_KEYS.includes(key)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid setting key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
|
// Special handling for encryption_key to protect it in DB
|
||||||
|
if (key === 'encryption_key') {
|
||||||
|
stringValue = cryptoUtil.encryptMasterKey(stringValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO system_settings (setting_key, setting_value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`,
|
||||||
|
[key, stringValue]
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: 'Setting saved' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
|||||||
111
server/utils/cryptoUtil.js
Normal file
111
server/utils/cryptoUtil.js
Normal 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;
|
||||||
@ -6,24 +6,25 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
text-align: left;
|
text-align: right;
|
||||||
/* Explicitly set text align */
|
margin-bottom: 2rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title-text {
|
.page-title-text {
|
||||||
font-size: 1.5rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-primary);
|
color: var(--sokuree-text-primary);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
color: var(--color-text-secondary);
|
color: var(--sokuree-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-card {
|
.content-card {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export function AssetListPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const itemsPerPage = 8;
|
const itemsPerPage = 8;
|
||||||
|
|
||||||
// Fetch Assets
|
// Fetch Assets
|
||||||
@ -33,10 +34,17 @@ export function AssetListPage() {
|
|||||||
const loadAssets = async () => {
|
const loadAssets = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
const data = await assetApi.getAll();
|
const data = await assetApi.getAll();
|
||||||
setAssets(data);
|
if (Array.isArray(data)) {
|
||||||
|
setAssets(data);
|
||||||
|
} else {
|
||||||
|
console.error("API returned non-array data:", data);
|
||||||
|
setAssets([]);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to fetch assets:", error);
|
console.error("Failed to fetch assets:", error);
|
||||||
|
setError("데이터를 불러오는 중 오류가 발생했습니다. 서버 연결을 확인해 주세요.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -98,8 +106,10 @@ export function AssetListPage() {
|
|||||||
// Let's make it strict if possible, or robust for '설비' vs '설비 자산'.
|
// Let's make it strict if possible, or robust for '설비' vs '설비 자산'.
|
||||||
|
|
||||||
if (asset.category !== currentCategory) {
|
if (asset.category !== currentCategory) {
|
||||||
// Fallback for partial matches if needed, but let's try strict first based on user request.
|
// partial match fallback (e.g., '시설' in '시설 자산' or vice versa)
|
||||||
if (!asset.category.includes(currentCategory)) return false;
|
const assetCat = asset.category || '';
|
||||||
|
const targetCat = currentCategory || '';
|
||||||
|
if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,13 +250,11 @@ export function AssetListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header" style={{ justifyContent: 'flex-start', textAlign: 'left' }}>
|
<div className="page-header-right">
|
||||||
<div>
|
<h1 className="page-title-text">{getPageTitle()}</h1>
|
||||||
<h1 className="page-title-text">{getPageTitle()}</h1>
|
<p className="page-subtitle">
|
||||||
<p className="page-subtitle">
|
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
|
||||||
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="content-card">
|
<Card className="content-card">
|
||||||
@ -395,9 +403,18 @@ export function AssetListPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
) : error ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="empty-state text-red-500">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span>⚠️ {error}</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={loadAssets}>다시 시도</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="empty-state">
|
<td colSpan={10} className="empty-state">
|
||||||
데이터가 없습니다.
|
데이터가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
43
src/modules/asset/pages/AssetRegisterPage.css
Normal file
43
src/modules/asset/pages/AssetRegisterPage.css
Normal 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;
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { Select } from '../../../shared/ui/Select';
|
|||||||
import { ArrowLeft, Save, Upload } from 'lucide-react';
|
import { ArrowLeft, Save, Upload } from 'lucide-react';
|
||||||
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
|
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
|
||||||
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
||||||
|
import './AssetRegisterPage.css';
|
||||||
|
|
||||||
export function AssetRegisterPage() {
|
export function AssetRegisterPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -51,13 +52,11 @@ export function AssetRegisterPage() {
|
|||||||
if (part.type === 'separator') return part.value;
|
if (part.type === 'separator') return part.value;
|
||||||
if (part.type === 'year') return year;
|
if (part.type === 'year') return year;
|
||||||
if (part.type === 'category') return category ? category.code : 'UNKNOWN';
|
if (part.type === 'category') return category ? category.code : 'UNKNOWN';
|
||||||
if (part.type === 'sequence') return part.value; // In real app, we fetch next seq. here we just show the format pattern e.g. 001
|
if (part.type === 'sequence') return part.value;
|
||||||
return '';
|
return '';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Ideally we would fetch the *actual* next sequence from API here.
|
const finalId = generatedId.replace('001', '001');
|
||||||
// For now we assume '001' as a placeholder or "Generating..."
|
|
||||||
const finalId = generatedId.replace('001', '001'); // Just keeping the placeholder visible for user confirmation
|
|
||||||
|
|
||||||
setFormData(prev => ({ ...prev, id: finalId }));
|
setFormData(prev => ({ ...prev, id: finalId }));
|
||||||
|
|
||||||
@ -87,7 +86,7 @@ export function AssetRegisterPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!formData.categoryId || !formData.name || !formData.locationId) {
|
if (!formData.categoryId || !formData.name || !formData.locationId) {
|
||||||
@ -96,11 +95,9 @@ export function AssetRegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Map IDs to Names/Codes for Backend
|
|
||||||
const selectedCategory = categories.find(c => c.id === formData.categoryId);
|
const selectedCategory = categories.find(c => c.id === formData.categoryId);
|
||||||
const selectedLocation = locations.find(l => l.id === formData.locationId);
|
const selectedLocation = locations.find(l => l.id === formData.locationId);
|
||||||
|
|
||||||
// Upload Image if exists
|
|
||||||
let imageUrl = '';
|
let imageUrl = '';
|
||||||
if (formData.image) {
|
if (formData.image) {
|
||||||
const uploadRes = await assetApi.uploadImage(formData.image);
|
const uploadRes = await assetApi.uploadImage(formData.image);
|
||||||
@ -110,7 +107,7 @@ export function AssetRegisterPage() {
|
|||||||
const payload: Partial<Asset> = {
|
const payload: Partial<Asset> = {
|
||||||
id: formData.id,
|
id: formData.id,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name
|
category: selectedCategory ? selectedCategory.name : '미지정',
|
||||||
model: formData.model,
|
model: formData.model,
|
||||||
serialNumber: formData.serialNo,
|
serialNumber: formData.serialNo,
|
||||||
location: selectedLocation ? selectedLocation.name : '미지정',
|
location: selectedLocation ? selectedLocation.name : '미지정',
|
||||||
@ -134,196 +131,208 @@ export function AssetRegisterPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header">
|
<div className="page-header-right">
|
||||||
<div>
|
<h1 className="page-title-text">자산 등록</h1>
|
||||||
<h1 className="page-title-text">자산 등록</h1>
|
<p className="page-subtitle">새로운 자산을 시스템에 등록합니다.</p>
|
||||||
<p className="page-subtitle">새로운 자산을 시스템에 등록합니다.</p>
|
|
||||||
</div>
|
|
||||||
<div className="header-actions-row">
|
|
||||||
<Button variant="secondary" onClick={() => navigate(-1)} icon={<ArrowLeft size={16} />}>취소</Button>
|
|
||||||
<Button onClick={handleSubmit} icon={<Save size={16} />}>저장</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="w-full h-full shadow-sm border border-slate-200">
|
<Card className="w-full shadow-sm border border-slate-200 mb-8">
|
||||||
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
<form onSubmit={handleSubmit} className="p-2 sm:p-4 lg:p-6">
|
||||||
{/* Basic Info */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
|
||||||
<div className="col-span-full border-b border-slate-100 pb-2 mb-2">
|
{/* Basic Info Section */}
|
||||||
<h3 className="text-lg font-semibold text-slate-800">기본 정보</h3>
|
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2">
|
||||||
</div>
|
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-800">기본 정보</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="카테고리 *"
|
label="카테고리 *"
|
||||||
name="categoryId"
|
name="categoryId"
|
||||||
value={formData.categoryId}
|
value={formData.categoryId}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
options={categories.map(c => ({ label: c.name, value: c.id }))}
|
options={categories.map(c => ({ label: c.name, value: c.id }))}
|
||||||
placeholder="카테고리 선택"
|
placeholder="카테고리 선택"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="자산 관리 번호 (자동 생성)"
|
label="자산 관리 번호 (자동 생성)"
|
||||||
name="id"
|
name="id"
|
||||||
value={formData.id}
|
value={formData.id}
|
||||||
disabled
|
disabled
|
||||||
placeholder="카테고리 선택 시 자동 생성됨"
|
placeholder="카테고리 선택 시 자동 생성됨"
|
||||||
className="bg-slate-50 font-mono text-slate-600"
|
className="bg-slate-50 font-mono text-slate-600"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="자산명 *"
|
label="자산명 *"
|
||||||
name="name"
|
name="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="예: CNC 머시닝 센터"
|
placeholder="예: CNC 머시닝 센터"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="제작사"
|
label="제작사"
|
||||||
name="manufacturer"
|
name="manufacturer"
|
||||||
value={formData.manufacturer}
|
value={formData.manufacturer}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="모델명"
|
label="모델명"
|
||||||
name="model"
|
name="model"
|
||||||
value={formData.model}
|
value={formData.model}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="시리얼 번호 / 규격"
|
label="시리얼 번호 / 규격"
|
||||||
name="serialNo"
|
name="serialNo"
|
||||||
value={formData.serialNo}
|
value={formData.serialNo}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Image Upload Field */}
|
{/* Image Upload Field */}
|
||||||
<div className="ui-field-container col-span-full">
|
<div className="ui-field-container col-span-full">
|
||||||
<label className="ui-label">자산 이미지</label>
|
<label className="ui-label">자산 이미지</label>
|
||||||
<div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}>
|
<div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}>
|
||||||
{/* Preview Area - Fixed size container */}
|
<div
|
||||||
<div
|
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
|
||||||
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
|
style={{
|
||||||
style={{
|
width: '400px',
|
||||||
width: '400px',
|
height: '350px',
|
||||||
height: '350px',
|
display: 'flex',
|
||||||
display: 'flex',
|
alignItems: 'center',
|
||||||
alignItems: 'center',
|
justifyContent: 'center'
|
||||||
justifyContent: 'center'
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{formData.imagePreview ? (
|
||||||
{formData.imagePreview ? (
|
<img
|
||||||
<img
|
src={formData.imagePreview}
|
||||||
src={formData.imagePreview}
|
alt="Preview"
|
||||||
alt="Preview"
|
className="w-full h-full object-contain"
|
||||||
className="w-full h-full object-contain"
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="text-slate-300"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={32} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
|
||||||
|
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
|
||||||
|
파일 선택
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
|
||||||
|
{formData.image ? formData.image.name : '선택된 파일 없음'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
|
||||||
|
지원 형식: JPG, PNG, GIF (최대 5MB)
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="image-upload"
|
||||||
|
type="file"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div
|
|
||||||
className="text-slate-300"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Upload size={32} strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload Controls - Moved below */}
|
|
||||||
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
|
|
||||||
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
|
|
||||||
파일 선택
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
|
|
||||||
{formData.image ? formData.image.name : '선택된 파일 없음'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
|
|
||||||
지원 형식: JPG, PNG, GIF (최대 5MB)
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<input
|
|
||||||
id="image-upload"
|
|
||||||
type="file"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Management Info Section */}
|
||||||
|
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
|
||||||
|
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-800">관리 정보</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="설치 위치 / 보관 장소 *"
|
||||||
|
name="locationId"
|
||||||
|
value={formData.locationId}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={locations.map(l => ({ label: l.name, value: l.id }))}
|
||||||
|
placeholder="위치 선택"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="관리자 / 부서"
|
||||||
|
name="manager"
|
||||||
|
value={formData.manager}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="초기 상태"
|
||||||
|
name="status"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{ label: '정상 가동 (Active)', value: 'active' },
|
||||||
|
{ label: '대기 (Idle)', value: 'idle' },
|
||||||
|
{ label: '설치 중 (Installing)', value: 'installing' },
|
||||||
|
{ label: '점검 중 (Maintenance)', value: 'maintain' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="col-span-1"></div>
|
||||||
|
|
||||||
|
{/* Purchasing Info Section */}
|
||||||
|
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
|
||||||
|
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-800">도입 정보</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="도입일"
|
||||||
|
name="purchaseDate"
|
||||||
|
type="date"
|
||||||
|
value={formData.purchaseDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="구입 가격 (KRW)"
|
||||||
|
name="purchasePrice"
|
||||||
|
type="number"
|
||||||
|
value={formData.purchasePrice}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Action Buttons */}
|
||||||
{/* Management Info */}
|
<div className="form-actions-footer">
|
||||||
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4">
|
<Button
|
||||||
<h3 className="text-lg font-semibold text-slate-800">관리 정보</h3>
|
variant="secondary"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
icon={<ArrowLeft size={16} />}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
icon={<Save size={16} />}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
|
||||||
label="설치 위치 / 보관 장소 *"
|
|
||||||
name="locationId"
|
|
||||||
value={formData.locationId}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={locations.map(l => ({ label: l.name, value: l.id }))}
|
|
||||||
placeholder="위치 선택"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="관리자 / 부서"
|
|
||||||
name="manager"
|
|
||||||
value={formData.manager}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="초기 상태"
|
|
||||||
name="status"
|
|
||||||
value={formData.status}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ label: '정상 가동 (Active)', value: 'active' },
|
|
||||||
{ label: '대기 (Idle)', value: 'idle' },
|
|
||||||
{ label: '설치 중 (Installing)', value: 'installing' },
|
|
||||||
{ label: '점검 중 (Maintenance)', value: 'maintain' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="col-span-1"></div>
|
|
||||||
|
|
||||||
{/* Purchasing Info */}
|
|
||||||
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-800">도입 정보</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="도입일"
|
|
||||||
name="purchaseDate"
|
|
||||||
type="date"
|
|
||||||
value={formData.purchaseDate}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="구입 가격 (KRW)"
|
|
||||||
name="purchasePrice"
|
|
||||||
type="number"
|
|
||||||
value={formData.purchasePrice}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,45 +1,45 @@
|
|||||||
/* Page Header adjustments for Settings */
|
/* Page Header - Right Aligned */
|
||||||
.page-header {
|
.page-header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
align-items: flex-end;
|
||||||
padding-bottom: 0 !important;
|
text-align: right;
|
||||||
/* Override default bottom padding */
|
margin-bottom: 2rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-top {
|
.page-title-text {
|
||||||
padding-bottom: 0.5rem;
|
font-size: 1.75rem;
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Tabs */
|
|
||||||
.settings-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
/* Overlap border */
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab {
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab:hover {
|
|
||||||
color: var(--color-brand-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-tab.active {
|
|
||||||
color: var(--color-brand-primary);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border-bottom-color: var(--color-brand-primary);
|
color: var(--sokuree-text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--sokuree-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Header Styles within Settings */
|
||||||
|
.card-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--sokuree-text-primary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--sokuree-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Local tabs are now hidden and moved to Global TopHeader */
|
||||||
|
.settings-tabs {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Common Layout */
|
/* Common Layout */
|
||||||
@ -66,8 +66,8 @@
|
|||||||
|
|
||||||
/* Table Styles */
|
/* Table Styles */
|
||||||
.table-wrapper {
|
.table-wrapper {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--sokuree-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--sokuree-radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,13 +83,13 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--sokuree-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-table td {
|
.settings-table td {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid #f1f5f9;
|
||||||
color: var(--color-text-primary);
|
color: var(--sokuree-text-primary);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,7 +144,7 @@
|
|||||||
.preview-box {
|
.preview-box {
|
||||||
background-color: #f1f5f9;
|
background-color: #f1f5f9;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--sokuree-radius-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@ -179,7 +179,7 @@
|
|||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 2px dashed #e2e8f0;
|
border: 2px dashed #e2e8f0;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--sokuree-radius-md);
|
||||||
background-color: #fcfcfc;
|
background-color: #fcfcfc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,14 +282,16 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
/* Match button height (sm size) */
|
/* Match button height (sm size) */
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--sokuree-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--sokuree-radius-sm);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
width: 150px;
|
width: 150px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-text-input:focus {
|
.custom-text-input:focus {
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--sokuree-brand-primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--sokuree-brand-primary-rgb), 0.1);
|
||||||
}
|
}
|
||||||
@ -3,7 +3,8 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { Card } from '../../../shared/ui/Card';
|
import { Card } from '../../../shared/ui/Card';
|
||||||
import { Button } from '../../../shared/ui/Button';
|
import { Button } from '../../../shared/ui/Button';
|
||||||
import { Input } from '../../../shared/ui/Input';
|
import { Input } from '../../../shared/ui/Input';
|
||||||
import { Plus, Trash2, Edit2, X, Check, GripVertical } from 'lucide-react';
|
import { Plus, Trash2, Edit2, X, Check, GripVertical, Save, Loader2 } from 'lucide-react';
|
||||||
|
import { apiClient } from '../../../shared/api/client';
|
||||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||||
@ -76,6 +77,7 @@ function SortableRuleItem({ id, children, className }: { id: string; children: R
|
|||||||
|
|
||||||
// ---------------------
|
// ---------------------
|
||||||
|
|
||||||
|
// Types
|
||||||
// Types
|
// Types
|
||||||
interface AssetCategory {
|
interface AssetCategory {
|
||||||
id: string;
|
id: string;
|
||||||
@ -96,7 +98,7 @@ interface AssetStatus {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IDRuleComponentType = 'company' | 'category' | 'year' | 'sequence' | 'separator' | 'custom';
|
type IDRuleComponentType = 'company' | 'category' | 'year' | 'month' | 'sequence' | 'separator' | 'custom';
|
||||||
|
|
||||||
interface IDRuleComponent {
|
interface IDRuleComponent {
|
||||||
id: string;
|
id: string;
|
||||||
@ -105,7 +107,13 @@ interface IDRuleComponent {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial Mock Data
|
export interface AssetMaintenanceType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal values for initial fallback
|
||||||
let GLOBAL_CATEGORIES: AssetCategory[] = [
|
let GLOBAL_CATEGORIES: AssetCategory[] = [
|
||||||
{ id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' },
|
{ id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' },
|
||||||
{ id: '2', name: '공구', code: 'TOL', menuLink: 'tools' },
|
{ id: '2', name: '공구', code: 'TOL', menuLink: 'tools' },
|
||||||
@ -130,7 +138,7 @@ let GLOBAL_STATUSES: AssetStatus[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let GLOBAL_ID_RULE: IDRuleComponent[] = [
|
let GLOBAL_ID_RULE: IDRuleComponent[] = [
|
||||||
{ id: 'r1', type: 'company', value: 'HK', label: '회사약어' },
|
{ id: 'r1', type: 'company', value: 'SKR', label: '회사약어' },
|
||||||
{ id: 'r2', type: 'separator', value: '-', label: '구분자' },
|
{ id: 'r2', type: 'separator', value: '-', label: '구분자' },
|
||||||
{ id: 'r3', type: 'category', value: '', label: '카테고리' },
|
{ id: 'r3', type: 'category', value: '', label: '카테고리' },
|
||||||
{ id: 'r4', type: 'separator', value: '-', label: '구분자' },
|
{ id: 'r4', type: 'separator', value: '-', label: '구분자' },
|
||||||
@ -139,12 +147,6 @@ let GLOBAL_ID_RULE: IDRuleComponent[] = [
|
|||||||
{ id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' },
|
{ id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface AssetMaintenanceType {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [
|
let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [
|
||||||
{ id: '1', name: '정기점검', color: 'success' },
|
{ id: '1', name: '정기점검', color: 'success' },
|
||||||
{ id: '2', name: '수리', color: 'danger' },
|
{ id: '2', name: '수리', color: 'danger' },
|
||||||
@ -170,6 +172,68 @@ export function AssetSettingsPage() {
|
|||||||
const [maintenanceTypes, setMaintenanceTypes] = useState<AssetMaintenanceType[]>(GLOBAL_MAINTENANCE_TYPES);
|
const [maintenanceTypes, setMaintenanceTypes] = useState<AssetMaintenanceType[]>(GLOBAL_MAINTENANCE_TYPES);
|
||||||
const [idRule, setIdRule] = useState<IDRuleComponent[]>(GLOBAL_ID_RULE);
|
const [idRule, setIdRule] = useState<IDRuleComponent[]>(GLOBAL_ID_RULE);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Initial Load
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchAllSettings = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const keys = ['asset_id_rule', 'asset_categories', 'asset_locations', 'asset_statuses', 'asset_maintenance_types'];
|
||||||
|
const results = await Promise.all(keys.map(k => apiClient.get(`/system/settings/${k}`)));
|
||||||
|
|
||||||
|
results.forEach((res, idx) => {
|
||||||
|
const key = keys[idx];
|
||||||
|
const val = res.data.value;
|
||||||
|
if (val) {
|
||||||
|
const parsed = JSON.parse(val);
|
||||||
|
if (key === 'asset_id_rule') {
|
||||||
|
setIdRule(parsed);
|
||||||
|
GLOBAL_ID_RULE = parsed;
|
||||||
|
// Update company code input from the rule if available
|
||||||
|
const comp = parsed.find((p: any) => p.type === 'company');
|
||||||
|
if (comp) setCompanyCodeInput(comp.value);
|
||||||
|
}
|
||||||
|
if (key === 'asset_categories') { setCategories(parsed); GLOBAL_CATEGORIES = parsed; }
|
||||||
|
if (key === 'asset_locations') { setLocations(parsed); GLOBAL_LOCATIONS = parsed; }
|
||||||
|
if (key === 'asset_statuses') { setStatuses(parsed); GLOBAL_STATUSES = parsed; }
|
||||||
|
if (key === 'asset_maintenance_types') { setMaintenanceTypes(parsed); GLOBAL_MAINTENANCE_TYPES = parsed; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load settings:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAllSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveSettings = async (currentTab: string) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
let key = '';
|
||||||
|
let value: any = null;
|
||||||
|
|
||||||
|
if (currentTab === 'basic') { key = 'asset_id_rule'; value = idRule; }
|
||||||
|
else if (currentTab === 'category') { key = 'asset_categories'; value = categories; }
|
||||||
|
else if (currentTab === 'location') { key = 'asset_locations'; value = locations; }
|
||||||
|
else if (currentTab === 'status') { key = 'asset_statuses'; value = statuses; }
|
||||||
|
else if (currentTab === 'maintenance') { key = 'asset_maintenance_types'; value = maintenanceTypes; }
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
await apiClient.post(`/system/settings/${key}`, { value });
|
||||||
|
alert('설정이 저장되었습니다.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err);
|
||||||
|
alert('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Form inputs
|
// Form inputs
|
||||||
const [newCategoryName, setNewCategoryName] = useState('');
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
const [newCategoryCode, setNewCategoryCode] = useState('');
|
const [newCategoryCode, setNewCategoryCode] = useState('');
|
||||||
@ -445,6 +509,12 @@ export function AssetSettingsPage() {
|
|||||||
setCustomRuleText('');
|
setCustomRuleText('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCompanyValue = (newVal: string) => {
|
||||||
|
const updated = idRule.map(part => part.type === 'company' ? { ...part, value: newVal } : part);
|
||||||
|
setIdRule(updated);
|
||||||
|
GLOBAL_ID_RULE = updated;
|
||||||
|
};
|
||||||
|
|
||||||
const removeRuleComponent = (id: string) => {
|
const removeRuleComponent = (id: string) => {
|
||||||
const updated = idRule.filter(comp => comp.id !== id);
|
const updated = idRule.filter(comp => comp.id !== id);
|
||||||
setIdRule(updated);
|
setIdRule(updated);
|
||||||
@ -453,10 +523,12 @@ export function AssetSettingsPage() {
|
|||||||
|
|
||||||
|
|
||||||
const getPreviewId = () => {
|
const getPreviewId = () => {
|
||||||
|
const now = new Date();
|
||||||
const mockData = {
|
const mockData = {
|
||||||
company: 'HK',
|
company: 'HK', // Fallback
|
||||||
category: 'FAC',
|
category: 'FAC',
|
||||||
year: new Date().getFullYear().toString(),
|
year: now.getFullYear().toString(),
|
||||||
|
month: String(now.getMonth() + 1).padStart(2, '0'),
|
||||||
sequence: '001',
|
sequence: '001',
|
||||||
separator: '-'
|
separator: '-'
|
||||||
};
|
};
|
||||||
@ -466,22 +538,33 @@ export function AssetSettingsPage() {
|
|||||||
if (part.type === 'company') return part.value;
|
if (part.type === 'company') return part.value;
|
||||||
if (part.type === 'category') return mockData.category;
|
if (part.type === 'category') return mockData.category;
|
||||||
if (part.type === 'year') return mockData.year;
|
if (part.type === 'year') return mockData.year;
|
||||||
|
if (part.type === 'month') return mockData.month;
|
||||||
if (part.type === 'sequence') return part.value;
|
if (part.type === 'sequence') return part.value;
|
||||||
if (part.type === 'separator') return part.value;
|
if (part.type === 'separator') return part.value;
|
||||||
return '';
|
return '';
|
||||||
}).join('');
|
}).join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-12 text-center text-slate-500 flex flex-col items-center gap-4">
|
||||||
|
<Loader2 size={32} className="animate-spin" />
|
||||||
|
설정을 불러오는 중입니다...
|
||||||
|
</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container h-full flex flex-col">
|
||||||
<div className="settings-content mt-4">
|
<div className="page-header-right">
|
||||||
|
<h1 className="page-title-text">자산 설정</h1>
|
||||||
|
<p className="page-subtitle">자산 관리 모듈의 기준 정보를 설정합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-content mt-4 flex-1">
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
{activeTab === 'basic' && (
|
{activeTab === 'basic' && (
|
||||||
<Card className="settings-card max-w-5xl">
|
<Card className="settings-card w-full">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h2 className="card-title">자산 관리번호 생성 규칙</h2>
|
<h2 className="card-title">자산 관리번호 생성 규칙</h2>
|
||||||
<p className="card-desc">자산 등록 시 자동 생성될 관리번호의 포맷을 조합합니다.</p>
|
<p className="card-desc">자산 등록 시 자동 생성될 관리번호의 포맷을 조합합니다.</p>
|
||||||
@ -518,40 +601,57 @@ export function AssetSettingsPage() {
|
|||||||
<div className="rule-tools">
|
<div className="rule-tools">
|
||||||
<h4 className="tools-title">규칙 요소 추가</h4>
|
<h4 className="tools-title">규칙 요소 추가</h4>
|
||||||
<div className="tools-grid">
|
<div className="tools-grid">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: 'auto' }}>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
placeholder="약어 (예: SKR)"
|
||||||
placeholder="약어 (예: HK)"
|
value={companyCodeInput}
|
||||||
value={companyCodeInput}
|
onChange={e => {
|
||||||
onChange={e => setCompanyCodeInput(e.target.value.toUpperCase())}
|
const val = e.target.value.toUpperCase();
|
||||||
className="custom-text-input"
|
setCompanyCodeInput(val);
|
||||||
maxLength={5}
|
updateCompanyValue(val);
|
||||||
style={{ width: '100px' }}
|
}}
|
||||||
/>
|
className="custom-text-input"
|
||||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, `회사약어 (${companyCodeInput})`)}>회사약어 ({companyCodeInput})</Button>
|
maxLength={5}
|
||||||
</div>
|
style={{ width: '80px' }}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, '회사약어')}>회사약어 ({companyCodeInput})</Button>
|
||||||
|
|
||||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('category', '', '카테고리')}>카테고리</Button>
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('category', '', '카테고리')}>카테고리</Button>
|
||||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('year', 'YYYY', '등록년도')}>년도</Button>
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('year', 'YYYY', '등록년도')}>년도</Button>
|
||||||
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('month', 'MM', '월')}>월 (MM)</Button>
|
||||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('sequence', '001', '일련번호')}>일련번호</Button>
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('sequence', '001', '일련번호')}>일련번호</Button>
|
||||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자 (-)')}>기호 (-)</Button>
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자')}>구분자 (-)</Button>
|
||||||
<div className="custom-tool">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="사용자 정의 텍스트"
|
placeholder="사용자 정의 텍스트"
|
||||||
value={customRuleText}
|
value={customRuleText}
|
||||||
onChange={e => setCustomRuleText(e.target.value)}
|
onChange={e => setCustomRuleText(e.target.value)}
|
||||||
className="custom-text-input"
|
className="custom-text-input"
|
||||||
/>
|
style={{ marginLeft: '0.5rem', width: '130px' }}
|
||||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}>추가</Button>
|
/>
|
||||||
</div>
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}>추가</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Action */}
|
||||||
|
<div className="mt-12 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => handleSaveSettings('basic')}
|
||||||
|
className="w-24"
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'category' && (
|
{activeTab === 'category' && (
|
||||||
<Card className="settings-card max-w-4xl">
|
<Card className="settings-card w-full">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h2 className="card-title">카테고리 관리</h2>
|
<h2 className="card-title">카테고리 관리</h2>
|
||||||
<p className="card-desc">자산 유형을 분류하는 카테고리를 관리합니다. (약어는 자산번호 생성 시 사용됩니다)</p>
|
<p className="card-desc">자산 유형을 분류하는 카테고리를 관리합니다. (약어는 자산번호 생성 시 사용됩니다)</p>
|
||||||
@ -666,12 +766,25 @@ export function AssetSettingsPage() {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Action */}
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => handleSaveSettings('category')}
|
||||||
|
className="w-32"
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'location' && (
|
{activeTab === 'location' && (
|
||||||
<Card className="settings-card max-w-3xl">
|
<Card className="settings-card w-full">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h2 className="card-title">설치 위치 / 보관 장소</h2>
|
<h2 className="card-title">설치 위치 / 보관 장소</h2>
|
||||||
<p className="card-desc">자산이 위치할 수 있는 장소를 관리합니다.</p>
|
<p className="card-desc">자산이 위치할 수 있는 장소를 관리합니다.</p>
|
||||||
@ -735,12 +848,25 @@ export function AssetSettingsPage() {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Action */}
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => handleSaveSettings('location')}
|
||||||
|
className="w-32"
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'status' && (
|
{activeTab === 'status' && (
|
||||||
<Card className="settings-card max-w-5xl">
|
<Card className="settings-card w-full">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h2 className="card-title">자산 상태 관리</h2>
|
<h2 className="card-title">자산 상태 관리</h2>
|
||||||
<p className="card-desc">자산의 상태(운용, 파손, 수리 등)를 정의하고 관리합니다.</p>
|
<p className="card-desc">자산의 상태(운용, 파손, 수리 등)를 정의하고 관리합니다.</p>
|
||||||
@ -834,12 +960,25 @@ export function AssetSettingsPage() {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Action */}
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => handleSaveSettings('status')}
|
||||||
|
className="w-32"
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'maintenance' && (
|
{activeTab === 'maintenance' && (
|
||||||
<Card className="settings-card max-w-4xl">
|
<Card className="settings-card w-full">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h2 className="card-title">유지보수 구분 관리</h2>
|
<h2 className="card-title">유지보수 구분 관리</h2>
|
||||||
<p className="card-desc">유지보수 이력 등록 시 사용할 작업 구분을 관리합니다.</p>
|
<p className="card-desc">유지보수 이력 등록 시 사용할 작업 구분을 관리합니다.</p>
|
||||||
@ -923,6 +1062,19 @@ export function AssetSettingsPage() {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Action */}
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => handleSaveSettings('maintenance')}
|
||||||
|
className="w-32"
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--sokuree-bg-main);
|
||||||
background: linear-gradient(135deg, var(--color-bg-sidebar) 0%, var(--color-brand-primary) 100%);
|
background: linear-gradient(135deg, var(--sokuree-bg-sidebar) 0%, var(--sokuree-brand-primary) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
@ -14,8 +14,8 @@
|
|||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--sokuree-radius-lg);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header {
|
.login-header {
|
||||||
@ -26,22 +26,22 @@
|
|||||||
.brand-logo {
|
.brand-logo {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: var(--color-bg-sidebar);
|
background-color: var(--sokuree-bg-sidebar);
|
||||||
color: var(--color-text-inverse);
|
color: var(--sokuree-text-inverse);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--sokuree-radius-md);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header h1 {
|
.login-header h1 {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-primary);
|
color: var(--sokuree-text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header p {
|
.login-header p {
|
||||||
color: var(--color-text-secondary);
|
color: var(--sokuree-text-secondary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +60,7 @@
|
|||||||
.form-group label {
|
.form-group label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-primary);
|
color: var(--sokuree-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
@ -72,33 +72,33 @@
|
|||||||
.input-icon {
|
.input-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--sokuree-text-secondary);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input {
|
.form-group input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem 1rem 0.75rem 2.75rem;
|
padding: 0.75rem 1rem 0.75rem 2.75rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--sokuree-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--sokuree-radius-md);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background-color: var(--color-bg-surface);
|
background-color: var(--sokuree-bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus {
|
.form-group input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--sokuree-brand-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(82, 109, 130, 0.1);
|
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.875rem;
|
padding: 0.875rem;
|
||||||
background-color: var(--color-bg-sidebar);
|
background-color: var(--sokuree-bg-sidebar);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--sokuree-radius-md);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@ -107,7 +107,7 @@
|
|||||||
.login-btn:hover {
|
.login-btn:hover {
|
||||||
background-color: #1e293b;
|
background-color: #1e293b;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
@ -116,12 +116,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--sokuree-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-footer {
|
.login-footer {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--sokuree-text-secondary);
|
||||||
}
|
}
|
||||||
@ -13,6 +13,13 @@ export function LoginPage() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const from = location.state?.from?.pathname || '/asset/dashboard';
|
const from = location.state?.from?.pathname || '/asset/dashboard';
|
||||||
|
const isExpired = new URLSearchParams(location.search).get('expired') === 'true';
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
if (isExpired) {
|
||||||
|
setError('세션이 만료되었습니다. 보안을 위해 다시 로그인해주세요.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import ModuleLoader from './ModuleLoader';
|
|||||||
// Platform / System Pages
|
// Platform / System Pages
|
||||||
import { UserManagementPage } from './pages/UserManagementPage';
|
import { UserManagementPage } from './pages/UserManagementPage';
|
||||||
import { BasicSettingsPage } from './pages/BasicSettingsPage';
|
import { BasicSettingsPage } from './pages/BasicSettingsPage';
|
||||||
|
import { VersionPage } from './pages/VersionPage';
|
||||||
import { LicensePage } from '../system/pages/LicensePage';
|
import { LicensePage } from '../system/pages/LicensePage';
|
||||||
|
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
@ -45,6 +46,7 @@ export const App = () => {
|
|||||||
<Route path="/admin/users" element={<UserManagementPage />} />
|
<Route path="/admin/users" element={<UserManagementPage />} />
|
||||||
<Route path="/admin/settings" element={<BasicSettingsPage />} />
|
<Route path="/admin/settings" element={<BasicSettingsPage />} />
|
||||||
<Route path="/admin/license" element={<LicensePage />} />
|
<Route path="/admin/license" element={<LicensePage />} />
|
||||||
|
<Route path="/admin/version" element={<VersionPage />} />
|
||||||
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
|
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@ -3,23 +3,75 @@ import { Card } from '../../shared/ui/Card';
|
|||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Input } from '../../shared/ui/Input';
|
import { Input } from '../../shared/ui/Input';
|
||||||
import { apiClient } from '../../shared/api/client';
|
import { apiClient } from '../../shared/api/client';
|
||||||
import { Save, Clock, Info } from 'lucide-react';
|
import { useAuth } from '../../shared/auth/AuthContext';
|
||||||
|
import { Save, Clock, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SystemSettings {
|
||||||
|
session_timeout: number;
|
||||||
|
encryption_key: string;
|
||||||
|
subscriber_id: string;
|
||||||
|
db_config: {
|
||||||
|
host: string;
|
||||||
|
user: string;
|
||||||
|
password?: string;
|
||||||
|
database: string;
|
||||||
|
port: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function BasicSettingsPage() {
|
export function BasicSettingsPage() {
|
||||||
const [settings, setSettings] = useState({
|
const { user: currentUser } = useAuth();
|
||||||
session_timeout: 60
|
const [settings, setSettings] = useState<SystemSettings>({
|
||||||
|
session_timeout: 60,
|
||||||
|
encryption_key: '',
|
||||||
|
subscriber_id: '',
|
||||||
|
db_config: {
|
||||||
|
host: '',
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
database: '',
|
||||||
|
port: '3306'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
const [isDbVerified, setIsDbVerified] = useState(false);
|
||||||
|
const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({
|
||||||
|
security: null,
|
||||||
|
encryption: null,
|
||||||
|
database: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [rotationStatus, setRotationStatus] = useState<{ currentKey: string; affectedCount: number } | null>(null);
|
||||||
|
const [rotating, setRotating] = useState(false);
|
||||||
|
|
||||||
|
// Supervisor Protection States
|
||||||
|
const [isDbConfigUnlocked, setIsDbConfigUnlocked] = useState(false);
|
||||||
|
const [isEncryptionUnlocked, setIsEncryptionUnlocked] = useState(false);
|
||||||
|
const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | null>(null);
|
||||||
|
|
||||||
|
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
||||||
|
const [verifyPassword, setVerifyPassword] = useState('');
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyError, setVerifyError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
|
fetchEncryptionStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get('/system/settings');
|
const res = await apiClient.get('/system/settings');
|
||||||
setSettings(res.data);
|
const data = res.data;
|
||||||
|
if (!data.db_config) {
|
||||||
|
data.db_config = { host: '', user: '', password: '', database: '', port: '3306' };
|
||||||
|
}
|
||||||
|
setSettings(data);
|
||||||
|
setIsDbVerified(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch settings', error);
|
console.error('Failed to fetch settings', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -27,95 +79,416 @@ export function BasicSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const fetchEncryptionStatus = async () => {
|
||||||
setSaving(true);
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/system/settings', settings);
|
const res = await apiClient.get('/system/encryption/status');
|
||||||
alert('설정이 저장되었습니다.');
|
setRotationStatus({
|
||||||
|
currentKey: res.data.current_key,
|
||||||
|
affectedCount: Object.values(res.data.affected_records as Record<string, number>).reduce((a, b) => a + b, 0)
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save failed', error);
|
console.error('Failed to fetch encryption status', error);
|
||||||
alert('저장 중 오류가 발생했습니다.');
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateNewKey = () => {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()';
|
||||||
|
let key = 'smartims_';
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
setSettings({ ...settings, encryption_key: key });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRotateKey = async () => {
|
||||||
|
if (!settings.encryption_key) {
|
||||||
|
alert('새로운 암호화 키를 입력하거나 생성해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMsg = `암호화 키 변경 및 데이터 마이그레이션을 시작합니다.\n- 대상 레코드: 약 ${rotationStatus?.affectedCount || 0}건\n- 소요 시간: 데이터 양에 따라 수 초가 걸릴 수 있습니다.\n\n주의: 절차 진행 중에는 서버 연결이 일시적으로 끊길 수 있으며, 중간에 브라우저를 닫지 마십시오.\n진행하시겠습니까?`;
|
||||||
|
|
||||||
|
if (!confirm(confirmMsg)) return;
|
||||||
|
|
||||||
|
setRotating(true);
|
||||||
|
setSaveResults(prev => ({ ...prev, encryption: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post('/system/encryption/rotate', { new_key: settings.encryption_key });
|
||||||
|
setSaveResults(prev => ({
|
||||||
|
...prev,
|
||||||
|
encryption: { success: true, message: '암호화 키 변경 및 데이터 마이그레이션 완료!' }
|
||||||
|
}));
|
||||||
|
fetchSettings();
|
||||||
|
fetchEncryptionStatus();
|
||||||
|
setTimeout(() => setSaveResults(prev => ({ ...prev, encryption: null })), 5000);
|
||||||
|
} catch (error: any) {
|
||||||
|
setSaveResults(prev => ({
|
||||||
|
...prev,
|
||||||
|
encryption: { success: false, message: error.response?.data?.error || '재암호화 작업 중 오류가 발생했습니다.' }
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setRotating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post('/system/test-db', settings.db_config);
|
||||||
|
setTestResult({ success: true, message: res.data.message });
|
||||||
|
setIsDbVerified(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
setTestResult({ success: false, message: error.response?.data?.error || '접속 테스트에 실패했습니다.' });
|
||||||
|
setIsDbVerified(false);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenVerify = (target: 'database' | 'encryption') => {
|
||||||
|
if (currentUser?.role !== 'supervisor') {
|
||||||
|
alert('해당 권한이 없습니다. 최고관리자(Supervisor)만 접근 가능합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVerifyingTarget(target);
|
||||||
|
setShowVerifyModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifySupervisor = async () => {
|
||||||
|
setVerifying(true);
|
||||||
|
setVerifyError('');
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post('/verify-supervisor', { password: verifyPassword });
|
||||||
|
if (res.data.success) {
|
||||||
|
if (verifyingTarget === 'database') setIsDbConfigUnlocked(true);
|
||||||
|
else if (verifyingTarget === 'encryption') setIsEncryptionUnlocked(true);
|
||||||
|
|
||||||
|
setShowVerifyModal(false);
|
||||||
|
setVerifyPassword('');
|
||||||
|
setVerifyingTarget(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setVerifyError(error.response?.data?.message || '인증에 실패했습니다. 비밀번호를 확인해주세요.');
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSection = async (section: 'security' | 'encryption' | 'database') => {
|
||||||
|
if (section === 'database' && !isDbVerified) {
|
||||||
|
alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: any = {};
|
||||||
|
const successMessage = section === 'database' ? 'DB 설정이 저장되었습니다.' : '설정이 성공적으로 저장되었습니다.';
|
||||||
|
|
||||||
|
if (section === 'security') payload = { session_timeout: settings.session_timeout };
|
||||||
|
else if (section === 'encryption') payload = { encryption_key: settings.encryption_key };
|
||||||
|
else if (section === 'database') {
|
||||||
|
if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return;
|
||||||
|
payload = { db_config: settings.db_config };
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setSaveResults(prev => ({ ...prev, [section]: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post('/system/settings', payload);
|
||||||
|
setSaveResults(prev => ({ ...prev, [section]: { success: true, message: successMessage } }));
|
||||||
|
setTimeout(() => setSaveResults(prev => ({ ...prev, [section]: null })), 3000);
|
||||||
|
if (section !== 'database') fetchSettings();
|
||||||
|
} catch (error: any) {
|
||||||
|
setSaveResults(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: { success: false, message: error.response?.data?.error || '저장 중 오류 발생' }
|
||||||
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div className="p-6">로딩 중...</div>;
|
if (loading) return <div className="p-12 text-center text-slate-500">데이터를 불러오는 중...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container p-6 max-w-4xl mx-auto">
|
<div className="page-container p-6 max-w-5xl mx-auto">
|
||||||
<div className="mb-6">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-slate-900">시스템 관리 - 기본 설정</h1>
|
<div>
|
||||||
<p className="text-slate-500 mt-1">플랫폼의 기본 운영 환경을 설정합니다.</p>
|
<h1 className="text-2xl font-bold text-slate-900">시스템 관리 - 기본 설정</h1>
|
||||||
|
<p className="text-slate-500 mt-1">플랫폼의 보안 및 인프라 환경을 섹션별로 관리합니다.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
|
{/* Section 1: Security & Session */}
|
||||||
|
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||||
<Card className="p-6">
|
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
|
||||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||||
<Clock size={20} className="text-amber-500" />
|
<Clock size={20} className="text-blue-600" />
|
||||||
보안 설정
|
보안 및 세션
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
<div>
|
<div className="p-6">
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">세션 타임아웃</label>
|
<label className="block text-sm font-medium text-slate-700 mb-3">세션 자동 로그아웃 시간</label>
|
||||||
<div className="flex items-center gap-2 flex-wrap text-sm text-slate-600">
|
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
className="!w-20 text-center"
|
||||||
max="24"
|
value={Math.floor((settings.session_timeout || 60) / 60)}
|
||||||
className="!w-auto !mb-0"
|
onChange={e => {
|
||||||
style={{ width: '70px', textAlign: 'center' }}
|
const h = parseInt(e.target.value) || 0;
|
||||||
value={Math.floor((settings.session_timeout || 10) / 60)}
|
setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) });
|
||||||
onChange={e => {
|
}}
|
||||||
const newHours = parseInt(e.target.value) || 0;
|
/>
|
||||||
const currentTotal = settings.session_timeout || 10;
|
<span className="font-medium text-slate-900 mx-1">시간</span>
|
||||||
const currentMinutes = currentTotal % 60;
|
|
||||||
setSettings({ ...settings, session_timeout: (newHours * 60) + currentMinutes });
|
|
||||||
}}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
<span className="mr-2 whitespace-nowrap text-slate-700 font-medium">시간</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
className="!w-auto !mb-0"
|
|
||||||
style={{ width: '70px', textAlign: 'center' }}
|
|
||||||
value={(settings.session_timeout || 10) % 60}
|
|
||||||
onChange={e => {
|
|
||||||
const newMinutes = parseInt(e.target.value) || 0;
|
|
||||||
const currentTotal = settings.session_timeout || 10;
|
|
||||||
const currentHours = Math.floor(currentTotal / 60);
|
|
||||||
setSettings({ ...settings, session_timeout: (currentHours * 60) + newMinutes });
|
|
||||||
}}
|
|
||||||
placeholder="10"
|
|
||||||
/>
|
|
||||||
<span className="whitespace-nowrap text-slate-700 font-medium">분</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-500 whitespace-nowrap ml-1">동안 활동이 없으면 자동으로 로그아웃됩니다.</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1 pl-1">
|
<div className="flex items-center gap-1">
|
||||||
( 총 {settings.session_timeout || 10}분 / 최소 5분 ~ 최대 24시간 )
|
<Input
|
||||||
</p>
|
type="number"
|
||||||
|
className="!w-20 text-center"
|
||||||
|
value={(settings.session_timeout || 60) % 60}
|
||||||
|
onChange={e => {
|
||||||
|
const m = parseInt(e.target.value) || 0;
|
||||||
|
setSettings({ ...settings, session_timeout: (Math.floor(settings.session_timeout / 60) * 60) + m });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-slate-900 mx-1">분</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
{saveResults.security && (
|
||||||
|
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.security.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{saveResults.security.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
|
||||||
|
{saveResults.security.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={fetchSettings}>취소</Button>
|
||||||
|
<Button size="sm" onClick={() => handleSaveSection('security')} disabled={saving} icon={<Save size={14} />}>설정 저장</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
{/* Section 2: Encryption Master Key & Rotation (Supervisor Protected) */}
|
||||||
<Button
|
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
|
||||||
onClick={handleSave}
|
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
|
||||||
disabled={saving}
|
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||||
icon={<Save size={18} />}
|
<Key size={20} className="text-red-600" />
|
||||||
>
|
데이터 암호화 마스터 키 관리
|
||||||
{saving ? '저장 중...' : '설정 저장'}
|
</h2>
|
||||||
</Button>
|
{!isEncryptionUnlocked && (
|
||||||
</div>
|
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('encryption')} icon={<Lock size={14} />}>
|
||||||
|
조회 / 변경
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isEncryptionUnlocked && (
|
||||||
|
<div className="flex items-center gap-2 text-red-700 bg-red-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-red-200">
|
||||||
|
<Unlock size={14} /> 검증 완료: 최고관리자 모드
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEncryptionUnlocked ? (
|
||||||
|
<div className="animate-in fade-in duration-500">
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 font-bold block mb-1">현재 활성 키</span>
|
||||||
|
<code className="bg-white p-2 rounded border border-slate-200 block truncate font-mono">{rotationStatus?.currentKey || '-'}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 font-bold block mb-1">영향 레코드</span>
|
||||||
|
<div className="text-blue-600 font-bold flex items-center gap-2 mt-1">
|
||||||
|
<Database size={16} /> {rotationStatus?.affectedCount || 0} 건
|
||||||
|
<Button variant="secondary" size="sm" className="ml-2 h-7 text-[10px]" onClick={fetchEncryptionStatus} icon={<RefreshCcw size={12} />}>조회</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 max-w-xl">
|
||||||
|
<Input
|
||||||
|
value={settings.encryption_key}
|
||||||
|
onChange={e => setSettings({ ...settings, encryption_key: e.target.value })}
|
||||||
|
placeholder="새로운 키 입력"
|
||||||
|
className="font-mono flex-1"
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" onClick={generateNewKey} icon={<RefreshCcw size={14} />}>자동 생성</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
{saveResults.encryption && (
|
||||||
|
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.encryption.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{saveResults.encryption.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
|
||||||
|
{saveResults.encryption.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setIsEncryptionUnlocked(false)}>닫기</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRotateKey}
|
||||||
|
disabled={rotating || !settings.encryption_key}
|
||||||
|
icon={<RefreshCcw size={14} className={rotating ? 'animate-spin' : ''} />}
|
||||||
|
>
|
||||||
|
{rotating ? '재암호화 진행 중...' : '변경 및 마이그레이션 저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
|
||||||
|
<Lock className="mx-auto mb-3 opacity-20" size={48} />
|
||||||
|
<p className="text-sm font-medium">보안을 위해 암호화 설정은 최고관리자 인증 후 접근 가능합니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 3: Database Infrastructure (Supervisor Protected) */}
|
||||||
|
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
|
||||||
|
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<ShieldAlert size={20} className="text-indigo-600" />
|
||||||
|
데이터베이스 인프라 구성
|
||||||
|
</h2>
|
||||||
|
{!isDbConfigUnlocked && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('database')} icon={<Lock size={14} />}>
|
||||||
|
조회 / 변경
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isDbConfigUnlocked && (
|
||||||
|
<div className="flex items-center gap-2 text-indigo-700 bg-indigo-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-indigo-200">
|
||||||
|
<Unlock size={14} /> 검증 완료: 최고관리자 모드
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDbConfigUnlocked ? (
|
||||||
|
<div className="animate-in fade-in duration-500">
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg flex gap-4 items-start">
|
||||||
|
<AlertCircle className="text-indigo-600 shrink-0" size={20} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-indigo-900">물리적 인프라 수동 관리 모드</p>
|
||||||
|
<p className="text-xs text-indigo-700 leading-relaxed mt-1">
|
||||||
|
이 섹션의 설정을 잘못 변경하면 플랫폼 전체 접속이 불가능해질 수 있습니다.<br />
|
||||||
|
반드시 접속 테스트 성공을 확인한 후에 저장하십시오.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-slate-700">DB 호스트 주소</label>
|
||||||
|
<Input value={settings.db_config.host} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, host: e.target.value } }); setIsDbVerified(false); }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-slate-700">사용자 계정</label>
|
||||||
|
<Input value={settings.db_config.user} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, user: e.target.value } }); setIsDbVerified(false); }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-slate-700">비밀번호</label>
|
||||||
|
<Input type="password" value={settings.db_config.password} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-slate-700">스키마 이름 (Database)</label>
|
||||||
|
<Input value={settings.db_config.database} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, database: e.target.value } }); setIsDbVerified(false); }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-slate-700">접속 포트 (Port)</label>
|
||||||
|
<Input type="number" value={settings.db_config.port} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, port: e.target.value } }); setIsDbVerified(false); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-50 border border-slate-100 rounded-lg flex justify-between items-center">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{testResult && (
|
||||||
|
<span className={`flex items-center gap-1 ${testResult.success ? 'text-green-700' : 'text-red-700'}`}>
|
||||||
|
{testResult.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />} {testResult.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!testResult && <span className="text-slate-500 italic">변경 시 반드시 접속 테스트를 수행하십시오.</span>}
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleTestConnection} disabled={testing} icon={<Server size={14} />}>
|
||||||
|
{testing ? '검증 중...' : '연결 테스트'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 bg-slate-100/30 border-t border-slate-100 flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
{testResult && !testResult.success && <span className="text-xs text-red-600 font-bold">⚠️ 연결 실패 시 변경된 정보를 저장할 수 없습니다.</span>}
|
||||||
|
{saveResults.database && (
|
||||||
|
<div className={`text-sm font-medium ${saveResults.database.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{saveResults.database.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setIsDbConfigUnlocked(false)}>닫기</Button>
|
||||||
|
<Button size="sm" className="bg-indigo-600" onClick={() => handleSaveSection('database')} disabled={saving || !isDbVerified} icon={<Save size={14} />}>정보 저장</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
|
||||||
|
<Lock className="mx-auto mb-3 opacity-20" size={48} />
|
||||||
|
<p className="text-sm font-medium">보안을 위해 인프라 설정은 최고관리자 인증 후 접근 가능합니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Verification Modal */}
|
||||||
|
{showVerifyModal && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||||
|
<Card className="w-full max-w-md shadow-2xl border-indigo-200 overflow-hidden">
|
||||||
|
<div className={`p-6 text-white ${verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'}`}>
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<ShieldAlert size={20} /> 최고관리자 권한 확인 ({verifyingTarget === 'encryption' ? '암호화 키' : '인프라 설정'})
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/80 text-sm mt-1">민감 설정 접근을 위해 본인 확인이 필요합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="p-3 bg-red-50 border border-red-100 rounded text-xs text-red-800 leading-relaxed font-bold">
|
||||||
|
{verifyingTarget === 'encryption'
|
||||||
|
? "※ 경고: 암호화 키 변경은 시스템 내 모든 민감 데이터를 다시 암호화하는 중대한 작업입니다. 성공 시 기존 데이터를 새 키로 대체하며, 실패 시 데이터 유실의 위험이 있습니다."
|
||||||
|
: "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">최고관리자 비밀번호</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={verifyPassword}
|
||||||
|
onChange={e => setVerifyPassword(e.target.value)}
|
||||||
|
placeholder="비밀번호를 입력하세요"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleVerifySupervisor()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{verifyError && <p className="text-xs text-red-600 mt-2 font-bold">{verifyError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => { setShowVerifyModal(false); setVerifyError(''); setVerifyPassword(''); setVerifyingTarget(null); }}>취소</Button>
|
||||||
|
<Button className={verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'} onClick={handleVerifySupervisor} disabled={verifying}>
|
||||||
|
{verifying ? '인증 중...' : '인증 및 조회'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { Card } from '../../shared/ui/Card';
|
|||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Input } from '../../shared/ui/Input';
|
import { Input } from '../../shared/ui/Input';
|
||||||
import { apiClient } from '../../shared/api/client';
|
import { apiClient } from '../../shared/api/client';
|
||||||
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon } from 'lucide-react';
|
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw } from 'lucide-react';
|
||||||
import type { User } from '../../shared/auth/AuthContext';
|
import type { User } from '../../shared/auth/AuthContext';
|
||||||
import './UserManagementPage.css'; // Will create CSS separately or inline styles initially. Let's assume global css or create specific.
|
import { useAuth } from '../../shared/auth/AuthContext';
|
||||||
|
import './UserManagementPage.css';
|
||||||
|
|
||||||
interface UserFormData {
|
interface UserFormData {
|
||||||
id: string;
|
id: string;
|
||||||
@ -14,10 +15,11 @@ interface UserFormData {
|
|||||||
department: string;
|
department: string;
|
||||||
position: string;
|
position: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
role: 'admin' | 'user';
|
role: 'supervisor' | 'admin' | 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserManagementPage() {
|
export function UserManagementPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@ -68,7 +70,7 @@ export function UserManagementPage() {
|
|||||||
const handleOpenEdit = (user: User) => {
|
const handleOpenEdit = (user: User) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
password: '', // Password empty by default on edit
|
password: '',
|
||||||
name: user.name,
|
name: user.name,
|
||||||
department: user.department || '',
|
department: user.department || '',
|
||||||
position: user.position || '',
|
position: user.position || '',
|
||||||
@ -92,28 +94,20 @@ export function UserManagementPage() {
|
|||||||
|
|
||||||
const formatPhoneNumber = (value: string) => {
|
const formatPhoneNumber = (value: string) => {
|
||||||
const cleaned = value.replace(/\D/g, '');
|
const cleaned = value.replace(/\D/g, '');
|
||||||
if (cleaned.length <= 3) {
|
if (cleaned.length <= 3) return cleaned;
|
||||||
return cleaned;
|
else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
|
||||||
} else if (cleaned.length <= 7) {
|
else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
|
||||||
return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
|
|
||||||
} else {
|
|
||||||
return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
// Update
|
|
||||||
const payload: any = { ...formData };
|
const payload: any = { ...formData };
|
||||||
if (!payload.password) delete payload.password; // Don't send empty password
|
if (!payload.password) delete payload.password;
|
||||||
|
|
||||||
await apiClient.put(`/users/${formData.id}`, payload);
|
await apiClient.put(`/users/${formData.id}`, payload);
|
||||||
alert('수정되었습니다.');
|
alert('수정되었습니다.');
|
||||||
} else {
|
} else {
|
||||||
// Create
|
|
||||||
if (!formData.password) return alert('비밀번호를 입력하세요.');
|
if (!formData.password) return alert('비밀번호를 입력하세요.');
|
||||||
await apiClient.post('/users', formData);
|
await apiClient.post('/users', formData);
|
||||||
alert('등록되었습니다.');
|
alert('등록되었습니다.');
|
||||||
@ -121,9 +115,30 @@ export function UserManagementPage() {
|
|||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Submit failed', error);
|
alert(`오류: ${error.response?.data?.error || error.message}`);
|
||||||
const errorMsg = error.response?.data?.error || error.message || '저장 중 오류가 발생했습니다.';
|
}
|
||||||
alert(`오류: ${errorMsg}`);
|
};
|
||||||
|
|
||||||
|
const getRoleBadge = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'supervisor':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-100 text-amber-700 ring-1 ring-amber-200">
|
||||||
|
<ShieldCheck size={10} className="mr-1" /> 최고관리자
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'admin':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-100 text-indigo-700 ring-1 ring-indigo-200">
|
||||||
|
<Shield size={10} className="mr-1" /> 관리자
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-600 ring-1 ring-slate-200">
|
||||||
|
<UserIcon size={10} className="mr-1" /> 사용자
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -137,10 +152,10 @@ export function UserManagementPage() {
|
|||||||
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden shadow-sm border-slate-200">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm text-left text-slate-600">
|
<table className="w-full text-sm text-left">
|
||||||
<thead className="text-xs text-slate-700 uppercase bg-slate-50 border-b border-slate-200">
|
<thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">아이디 / 권한</th>
|
<th className="px-6 py-4">아이디 / 권한</th>
|
||||||
<th className="px-6 py-4">이름</th>
|
<th className="px-6 py-4">이름</th>
|
||||||
@ -150,39 +165,44 @@ export function UserManagementPage() {
|
|||||||
<th className="px-6 py-4 text-center">관리</th>
|
<th className="px-6 py-4 text-center">관리</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
{users.map((user) => (
|
{loading ? (
|
||||||
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
<tr>
|
||||||
<td className="px-6 py-4">
|
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">
|
||||||
<div className="font-medium text-slate-900">{user.id}</div>
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium mt-1 ${user.role === 'admin' ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-600'
|
<RefreshCcw size={24} className="animate-spin text-indigo-500" />
|
||||||
}`}>
|
<span>사용자 데이터를 불러오는 중...</span>
|
||||||
{user.role === 'admin' ? <Shield size={10} className="mr-1" /> : <UserIcon size={10} className="mr-1" />}
|
</div>
|
||||||
{user.role === 'admin' ? '관리자' : '사용자'}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 font-medium">{user.name}</td>
|
</tr>
|
||||||
|
) : users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="text-slate-900">{user.department || '-'}</div>
|
<div className="font-bold text-slate-900">{user.id}</div>
|
||||||
<div className="text-slate-500 text-xs">{user.position}</div>
|
<div className="mt-1">{getRoleBadge(user.role)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">{user.phone || '-'}</td>
|
<td className="px-6 py-4 font-semibold text-slate-800">{user.name}</td>
|
||||||
<td className="px-6 py-4 text-slate-500">
|
<td className="px-6 py-4">
|
||||||
{user.last_login ? new Date(user.last_login).toLocaleString() : '접속 기록 없음'}
|
<div className="text-slate-900 font-bold">{user.department || '-'}</div>
|
||||||
|
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-400 text-[11px]">
|
||||||
|
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center gap-1">
|
||||||
<button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded" onClick={() => handleOpenEdit(user)} title="수정">
|
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(user)}>
|
||||||
<Edit2 size={16} />
|
<Edit2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded" onClick={() => handleDelete(user.id)} title="삭제">
|
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(user.id)}>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{users.length === 0 && !loading && (
|
{!loading && users.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">등록된 사용자가 없습니다.</td>
|
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">등록된 사용자가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -194,46 +214,44 @@ export function UserManagementPage() {
|
|||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
<Card className="w-full max-w-md shadow-2xl animate-in fade-in zoom-in duration-200">
|
<Card className="w-full max-w-md shadow-2xl border-slate-200 overflow-hidden">
|
||||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||||
<h2 className="text-lg font-bold text-slate-800">
|
<h2 className="text-lg font-bold text-slate-800">
|
||||||
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
|
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600">
|
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
|
||||||
<X size={20} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">아이디 <span className="text-red-500">*</span></label>
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">아이디 <span className="text-red-500">*</span></label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.id}
|
value={formData.id}
|
||||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||||
disabled={isEditing} // ID cannot be changed on edit
|
disabled={isEditing}
|
||||||
placeholder="로그인 아이디"
|
placeholder="로그인 아이디 입력"
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
||||||
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
placeholder={isEditing ? "(변경시에만 입력)" : "비밀번호"}
|
placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
|
||||||
required={!isEditing}
|
required={!isEditing}
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">이름 <span className="text-red-500">*</span></label>
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">이름 <span className="text-red-500">*</span></label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
@ -241,28 +259,41 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">권한</label>
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">권한</label>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500 focus:border-transparent"
|
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all outline-none"
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'user' | 'admin' })}
|
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||||
|
disabled={
|
||||||
|
// 1. 현재 관리자(admin)는 최고관리자(supervisor)의 권한을 바꿀 수 없음
|
||||||
|
(isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor' && currentUser?.role !== 'supervisor') ||
|
||||||
|
// 2. 현재 관리자(admin)는 자기 자신의 권한을 'supervisor'로 올릴 수 없음 (애초에 옵션에서 걸러지겠지만 안전 장치)
|
||||||
|
(formData.role === 'supervisor' && currentUser?.role !== 'supervisor')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="user">일반 사용자</option>
|
<option value="user">일반 사용자</option>
|
||||||
<option value="admin">시스템 관리자</option>
|
<option value="admin">관리자</option>
|
||||||
|
{/* 최고관리자(supervisor) 옵션은 오직 최고관리자만 부여 가능 */}
|
||||||
|
{(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && (
|
||||||
|
<option value="supervisor">최고 관리자 (Supervisor)</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
{currentUser?.role !== 'supervisor' && (
|
||||||
|
<p className="text-[10px] text-slate-400 mt-1">* 최고관리자 권한 부여는 최고관리자만 가능합니다.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">부서</label>
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">부서</label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.department}
|
value={formData.department}
|
||||||
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">직위</label>
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">직위</label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.position}
|
value={formData.position}
|
||||||
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
||||||
@ -271,18 +302,18 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">핸드폰 번호</label>
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">핸드폰 번호</label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
||||||
placeholder="예: 010-1234-5678"
|
placeholder="010-0000-0000"
|
||||||
maxLength={13}
|
maxLength={13}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 flex justify-end gap-2">
|
<div className="pt-4 flex justify-end gap-2">
|
||||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>취소</Button>
|
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||||
<Button type="submit" icon={<Check size={16} />}>{isEditing ? '저장' : '등록'}</Button>
|
<Button type="submit" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '저장' : '전송'}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
207
src/platform/pages/VersionPage.tsx
Normal file
207
src/platform/pages/VersionPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -60,6 +60,7 @@ body {
|
|||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|||||||
@ -69,8 +69,14 @@ export interface Manual {
|
|||||||
|
|
||||||
export const assetApi = {
|
export const assetApi = {
|
||||||
getAll: async (): Promise<Asset[]> => {
|
getAll: async (): Promise<Asset[]> => {
|
||||||
const response = await apiClient.get<DBAsset[]>('/assets');
|
try {
|
||||||
return response.data.map(mapDBToAsset);
|
const response = await apiClient.get<DBAsset[]>('/assets');
|
||||||
|
if (!response.data || !Array.isArray(response.data)) return [];
|
||||||
|
return response.data.map(mapDBToAsset);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API Error in getAll:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getById: async (id: string): Promise<Asset> => {
|
getById: async (id: string): Promise<Asset> => {
|
||||||
|
|||||||
@ -25,3 +25,18 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||||
|
// Avoid infinite loops if we are already on login page or checking session
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
if (currentPath !== '/login' && !error.config.url.includes('/check')) {
|
||||||
|
console.warn('Session expired or security error. Redirecting to login.');
|
||||||
|
// Brute force redirect for simplicity in MVP
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { apiClient, setCsrfToken } from '../api/client';
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: 'admin' | 'user';
|
role: 'supervisor' | 'admin' | 'user';
|
||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@ -25,25 +25,82 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Check for existing session on mount
|
// Check for existing session on mount and periodically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let lastActivity = Date.now();
|
||||||
|
let timeoutMs = 3600000; // Default 1 hour
|
||||||
|
|
||||||
const checkSession = async () => {
|
const checkSession = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/check');
|
const response = await apiClient.get('/check');
|
||||||
if (response.data.isAuthenticated) {
|
if (response.data.isAuthenticated) {
|
||||||
setUser(response.data.user);
|
setUser(response.data.user);
|
||||||
if (response.data.csrfToken) {
|
if (response.data.csrfToken) setCsrfToken(response.data.csrfToken);
|
||||||
setCsrfToken(response.data.csrfToken);
|
if (response.data.sessionTimeout) timeoutMs = response.data.sessionTimeout;
|
||||||
}
|
} else {
|
||||||
|
// Safety: only redirect if we were previously logged in
|
||||||
|
setUser(prev => {
|
||||||
|
if (prev) {
|
||||||
|
setCsrfToken(null);
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Session check failed:', error);
|
setUser(prev => {
|
||||||
|
if (prev) {
|
||||||
|
setCsrfToken(null);
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateActivity = () => {
|
||||||
|
lastActivity = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Activity Listeners
|
||||||
|
window.addEventListener('mousemove', updateActivity);
|
||||||
|
window.addEventListener('keydown', updateActivity);
|
||||||
|
window.addEventListener('scroll', updateActivity);
|
||||||
|
window.addEventListener('click', updateActivity);
|
||||||
|
|
||||||
checkSession();
|
checkSession();
|
||||||
}, []);
|
|
||||||
|
// Check activity status every 30 seconds
|
||||||
|
const activityInterval = setInterval(() => {
|
||||||
|
// Functional check to avoid stale user closure
|
||||||
|
setUser(current => {
|
||||||
|
if (current) {
|
||||||
|
const idleTime = Date.now() - lastActivity;
|
||||||
|
if (idleTime >= timeoutMs) {
|
||||||
|
console.log('Idle timeout reached. Checking session...');
|
||||||
|
checkSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Fallback polling every 5 minutes (for secondary tabs etc)
|
||||||
|
const pollInterval = setInterval(checkSession, 300000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', updateActivity);
|
||||||
|
window.removeEventListener('keydown', updateActivity);
|
||||||
|
window.removeEventListener('scroll', updateActivity);
|
||||||
|
window.removeEventListener('click', updateActivity);
|
||||||
|
clearInterval(activityInterval);
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
};
|
||||||
|
}, []); // Removed [user] to prevent infinite loop
|
||||||
|
|
||||||
const login = async (id: string, password: string) => {
|
const login = async (id: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -103,6 +103,10 @@
|
|||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
@ -181,26 +185,39 @@
|
|||||||
background-color: var(--sokuree-bg-main);
|
background-color: var(--sokuree-bg-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Top Header - White Theme */
|
/* Top Header - White Theme */
|
||||||
.top-header {
|
.top-header {
|
||||||
height: 64px;
|
height: 64px;
|
||||||
background-color: var(--sokuree-bg-card);
|
background-color: var(--sokuree-bg-card);
|
||||||
/* White Header */
|
|
||||||
border-bottom: 1px solid var(--sokuree-border-color);
|
border-bottom: 1px solid var(--sokuree-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
/* Tabs at bottom */
|
padding: 0 2.5rem;
|
||||||
padding: 0 2rem;
|
|
||||||
color: var(--sokuree-text-primary);
|
color: var(--sokuree-text-primary);
|
||||||
|
justify-content: flex-end;
|
||||||
|
/* Align to right */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-tabs {
|
.header-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 1.5rem;
|
||||||
|
/* Better gap between tabs */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--sokuree-text-secondary);
|
color: var(--sokuree-text-secondary);
|
||||||
border-bottom: 3px solid transparent;
|
border-bottom: 3px solid transparent;
|
||||||
@ -211,20 +228,17 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
top: 1px;
|
top: 1px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item:hover {
|
.tab-item:hover {
|
||||||
color: var(--sokuree-brand-primary);
|
color: var(--sokuree-brand-primary);
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item.active {
|
.tab-item.active {
|
||||||
color: var(--sokuree-brand-primary);
|
color: var(--sokuree-brand-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border-bottom-color: var(--sokuree-brand-primary);
|
border-bottom-color: var(--sokuree-brand-primary);
|
||||||
/* Blue underline */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../../shared/auth/AuthContext';
|
import { useAuth } from '../../shared/auth/AuthContext';
|
||||||
import { useSystem } from '../../shared/context/SystemContext';
|
import { useSystem } from '../../shared/context/SystemContext';
|
||||||
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield } from 'lucide-react';
|
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info } from 'lucide-react';
|
||||||
import type { IModuleDefinition } from '../../core/types';
|
import type { IModuleDefinition } from '../../core/types';
|
||||||
import './MainLayout.css';
|
import './MainLayout.css';
|
||||||
|
|
||||||
@ -16,6 +16,15 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
const { modules } = useSystem();
|
const { modules } = useSystem();
|
||||||
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
|
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
|
||||||
|
|
||||||
|
const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false;
|
||||||
|
|
||||||
|
const checkRole = (requiredRoles: string[]) => {
|
||||||
|
if (!user) return false;
|
||||||
|
// Supervisor possesses all rights
|
||||||
|
if (user.role === 'supervisor') return true;
|
||||||
|
return requiredRoles.includes(user.role);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleModule = (moduleId: string) => {
|
const toggleModule = (moduleId: string) => {
|
||||||
setExpandedModules(prev =>
|
setExpandedModules(prev =>
|
||||||
prev.includes(moduleId)
|
prev.includes(moduleId)
|
||||||
@ -38,7 +47,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
|
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{/* Module: System Management (Platform Core) */}
|
{/* Module: System Management (Platform Core) */}
|
||||||
{user?.role === 'admin' && (
|
{isAdmin && (
|
||||||
<div className="module-group">
|
<div className="module-group">
|
||||||
<button
|
<button
|
||||||
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
|
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
|
||||||
@ -65,6 +74,10 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
<Shield size={18} />
|
<Shield size={18} />
|
||||||
<span>모듈/라이선스 관리</span>
|
<span>모듈/라이선스 관리</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/admin/version" className={`nav-item ${location.pathname.includes('/admin/version') ? 'active' : ''}`}>
|
||||||
|
<Info size={18} />
|
||||||
|
<span>버전 정보</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -75,8 +88,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
const moduleKey = mod.moduleName.split('-')[0];
|
const moduleKey = mod.moduleName.split('-')[0];
|
||||||
if (!isModuleActive(moduleKey)) return null;
|
if (!isModuleActive(moduleKey)) return null;
|
||||||
|
|
||||||
// Check roles
|
// Check roles with hierarchy
|
||||||
if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null;
|
if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
|
||||||
|
|
||||||
const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
|
const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
|
||||||
const isExpanded = expandedModules.includes(mod.moduleName);
|
const isExpanded = expandedModules.includes(mod.moduleName);
|
||||||
@ -188,7 +201,9 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath));
|
const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath));
|
||||||
if (!activeModule) return null;
|
if (!activeModule) return null;
|
||||||
|
|
||||||
const topRoutes = activeModule.routes.filter(r => r.position === 'top');
|
const topRoutes = location.pathname.includes('/settings')
|
||||||
|
? []
|
||||||
|
: activeModule.routes.filter(r => r.position === 'top');
|
||||||
|
|
||||||
return topRoutes.map(route => (
|
return topRoutes.map(route => (
|
||||||
<Link
|
<Link
|
||||||
@ -201,14 +216,25 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Legacy manual override check (can be removed if Asset Settings is fully migrated to route config) */}
|
{/* Asset Settings Specific Tabs */}
|
||||||
{location.pathname.includes('/asset/settings') && (
|
{location.pathname.startsWith('/asset/settings') && (
|
||||||
<>
|
<div className="header-tabs">
|
||||||
{/* Keeping this just in case, but ideally should be managed via route config now?
|
{[
|
||||||
Actually settings tabs are usually sub-pages of settings, not top-level module nav.
|
{ id: 'basic', label: '기본 설정' },
|
||||||
Leaving for safety.
|
{ id: 'category', label: '카테고리 관리' },
|
||||||
*/}
|
{ id: 'location', label: '설치 위치' },
|
||||||
</>
|
{ id: 'status', label: '자산 상태' },
|
||||||
|
{ id: 'maintenance', label: '정비 구분' }
|
||||||
|
].map(tab => (
|
||||||
|
<Link
|
||||||
|
key={tab.id}
|
||||||
|
to={`/asset/settings?tab=${tab.id}`}
|
||||||
|
className={`tab-item ${(new URLSearchParams(location.search).get('tab') || 'basic') === tab.id ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
BIN
temp_auth.js
Normal file
BIN
temp_auth.js
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user