Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b327c8cf6 | |||
| b853d35f2e | |||
| d2b253e65d | |||
| 53eb326cbe | |||
| afdccb29dc |
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Key, Plus, Copy, CheckCircle, Clock, Search, Users, ChevronRight, ArrowLeft, Trash2, LogOut } from 'lucide-react';
|
import { Key, Plus, Copy, CheckCircle, Clock, Search, Users, ChevronRight, ArrowLeft, Trash2, LogOut, X, Loader2, Lock } from 'lucide-react';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
|
|
||||||
// Axios settings for cookies
|
// Axios settings for cookies
|
||||||
@ -24,6 +24,13 @@ const App = () => {
|
|||||||
const [user, setUser] = useState<any>(null);
|
const [user, setUser] = useState<any>(null);
|
||||||
const [authChecked, setAuthChecked] = useState(false);
|
const [authChecked, setAuthChecked] = useState(false);
|
||||||
const [licenses, setLicenses] = useState<License[]>([]);
|
const [licenses, setLicenses] = useState<License[]>([]);
|
||||||
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
|
const [passwordForm, setPasswordForm] = useState({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
const [passwordChanging, setPasswordChanging] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [copySuccess, setCopySuccess] = useState<number | null>(null);
|
const [copySuccess, setCopySuccess] = useState<number | null>(null);
|
||||||
@ -108,6 +115,32 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||||
|
return alert('새 비밀번호가 일치하지 않습니다.');
|
||||||
|
}
|
||||||
|
if (passwordForm.newPassword.length < 4) {
|
||||||
|
return alert('비밀번호는 4자 이상이어야 합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordChanging(true);
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE}/auth/change-password`, {
|
||||||
|
currentPassword: passwordForm.currentPassword,
|
||||||
|
newPassword: passwordForm.newPassword
|
||||||
|
});
|
||||||
|
alert('비밀번호가 성공적으로 변경되었습니다. 보안을 위해 다시 로그인해 주세요.');
|
||||||
|
handleLogout();
|
||||||
|
setShowPasswordModal(false);
|
||||||
|
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.error || '비밀번호 변경에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setPasswordChanging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
fetchLicenses();
|
fetchLicenses();
|
||||||
@ -197,7 +230,10 @@ const App = () => {
|
|||||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-500 text-xs">
|
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-500 text-xs">
|
||||||
{user?.name?.[0] || 'A'}
|
{user?.name?.[0] || 'A'}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col -space-y-0.5">
|
||||||
<span className="text-sm font-bold text-slate-700">{user?.name}님</span>
|
<span className="text-sm font-bold text-slate-700">{user?.name}님</span>
|
||||||
|
<button onClick={() => setShowPasswordModal(true)} className="text-[10px] text-indigo-500 font-bold hover:underline text-left">비밀번호 변경</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleLogout} className="flex items-center gap-2 text-slate-400 hover:text-red-500 transition-all group">
|
<button onClick={handleLogout} className="flex items-center gap-2 text-slate-400 hover:text-red-500 transition-all group">
|
||||||
<span className="text-xs font-bold">로그아웃</span>
|
<span className="text-xs font-bold">로그아웃</span>
|
||||||
@ -380,6 +416,90 @@ const App = () => {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Password Change Modal */}
|
||||||
|
{showPasswordModal && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white w-full max-w-md rounded-3xl shadow-2xl border border-slate-200 overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-indigo-600 p-2 rounded-xl text-white">
|
||||||
|
<Lock size={18} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold">비밀번호 변경</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasswordModal(false);
|
||||||
|
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-slate-200 rounded-full transition-all text-slate-400"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleChangePassword} className="p-6 space-y-5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider ml-1">현재 비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={passwordForm.currentPassword}
|
||||||
|
onChange={e => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
||||||
|
placeholder="현재 비밀번호 입력"
|
||||||
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl focus:bg-white focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider ml-1">새 비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={passwordForm.newPassword}
|
||||||
|
onChange={e => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||||
|
placeholder="새 비밀번호 입력 (4자 이상)"
|
||||||
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl focus:bg-white focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider ml-1">새 비밀번호 확인</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={passwordForm.confirmPassword}
|
||||||
|
onChange={e => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||||
|
placeholder="새 비밀번호 다시 입력"
|
||||||
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl focus:bg-white focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPasswordModal(false)}
|
||||||
|
className="flex-1 px-4 py-3.5 bg-slate-100 text-slate-600 font-bold rounded-2xl hover:bg-slate-200 transition-all text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={passwordChanging}
|
||||||
|
className="flex-3 bg-slate-900 text-white font-bold py-3.5 px-8 rounded-2xl hover:bg-indigo-600 transition-all shadow-lg flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{passwordChanging ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
<span>변경 중...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>비밀번호 변경하기</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
|
.custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|||||||
@ -17,6 +17,15 @@ Synology NAS 환경에서 통합 운영을 위해 Express 서버가 빌드된
|
|||||||
- `express.static`을 사용하여 `../client/dist` 폴더의 정적 파일을 서빙합니다.
|
- `express.static`을 사용하여 `../client/dist` 폴더의 정적 파일을 서빙합니다.
|
||||||
- SPA(Single Page Application)를 위해 모든 경로를 `index.html`로 폴백(fallback) 시킵니다.
|
- SPA(Single Page Application)를 위해 모든 경로를 `index.html`로 폴백(fallback) 시킵니다.
|
||||||
|
|
||||||
|
## 초기 계정 정보
|
||||||
|
최초 로그인 시 아래 계정을 사용하세요.
|
||||||
|
|
||||||
|
- **ID**: `admin`
|
||||||
|
- **Password**: 보안을 위해 문서에서 관리하지 않습니다 (최초 1회 `admin1234` 접속 후 바로 변경 권장)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 만약 로그인이 되지 않는다면 서버 터미널에서 `node init_db.js`를 실행하여 계정을 생성해 주세요.
|
||||||
|
|
||||||
## 배포 단계 (설치 절차)
|
## 배포 단계 (설치 절차)
|
||||||
|
|
||||||
### 1. 소스 코드 배포
|
### 1. 소스 코드 배포
|
||||||
@ -34,11 +43,37 @@ npm install
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 서버 환경 설정
|
### 3. 서버 환경 설정 (`.env` 파일 설정)
|
||||||
`server/.env` 파일을 NAS 운영 환경에 맞게 수정합니다.
|
`server/.env` 파일을 생성하거나 수정하여 필요한 환경 변수를 설정합니다.
|
||||||
- `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` 설정
|
|
||||||
- `JWT_SECRET`: 보안을 위한 임의의 긴 문자열 입력
|
1. **비밀 키(`JWT_SECRET`) 생성**:
|
||||||
- 포트 번호 확인 (기본 `3006`)
|
보안을 위해 강력한 임의의 문자열을 생성합니다. 터미널(SSH)에서 아래 명령어를 실행하세요:
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
*(출력된 긴 문자열을 복사해 두세요)*
|
||||||
|
|
||||||
|
2. **`.env` 파일 편집**:
|
||||||
|
`server/` 디렉토리 내의 `.env` 파일을 편집합니다:
|
||||||
|
```bash
|
||||||
|
cd ../server
|
||||||
|
vi .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **설정 내용 입력**:
|
||||||
|
아래 형식을 참고하여 정보를 입력합니다:
|
||||||
|
```bash
|
||||||
|
DB_HOST=localhost (또는 MariaDB 10 IP)
|
||||||
|
DB_USER=DB사용자ID
|
||||||
|
DB_PASSWORD=DB비밀번호
|
||||||
|
DB_NAME=smart_ims_license_db
|
||||||
|
DB_PORT=3307 (Synology MariaDB 10 기본 포트)
|
||||||
|
|
||||||
|
# 위에서 생성한 보안 키를 여기에 붙여넣으세요
|
||||||
|
JWT_SECRET=당신의_보안_키_문자열
|
||||||
|
|
||||||
|
PORT=3006
|
||||||
|
```
|
||||||
|
|
||||||
### 4. 의존성 설치 및 실행
|
### 4. 의존성 설치 및 실행
|
||||||
```bash
|
```bash
|
||||||
@ -49,11 +84,19 @@ node server.js
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 5. PM2를 이용한 무중단 운영
|
### 5. PM2를 이용한 무중단 운영
|
||||||
서버가 백그라운드에서 계속 실행되도록 `pm2`를 사용합니다.
|
서버가 백그라운드에서 계속 실행되도록 `pm2`를 사용합니다. 반드시 **server 폴더**에서 실행하세요.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PM2 설치 및 실행
|
# 1. 서버 폴더로 이동
|
||||||
|
cd /volume1/[NAS사용자폴더]/smart_ims_license/server
|
||||||
|
|
||||||
|
# 2. PM2 설치 (이미 설치했다면 생략 가능)
|
||||||
sudo npm install -g pm2
|
sudo npm install -g pm2
|
||||||
|
|
||||||
|
# 3. 서비스 시작
|
||||||
pm2 start server.js --name "license-manager"
|
pm2 start server.js --name "license-manager"
|
||||||
|
|
||||||
|
# 4. 현재 상태 저장 (재부팅 시 자동 복구를 위해 필수)
|
||||||
pm2 save
|
pm2 save
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -74,6 +117,48 @@ NAS가 재부팅될 때 PM2가 자동으로 실행되도록 Synology '작업 스
|
|||||||
```
|
```
|
||||||
5. **확인**을 눌러 저장합니다.
|
5. **확인**을 눌러 저장합니다.
|
||||||
|
|
||||||
## 확인 사항
|
## 확인 사항 및 외부 접속 설정
|
||||||
- Synology 역방향 프록시(Reverse Proxy)를 사용하여 도메인 또는 특정 포트를 연결합니다.
|
|
||||||
- 방화벽 설정에서 해당 포트(3006)가 열려 있는지 확인합니다.
|
### 1. Synology 역방향 프록시(Reverse Proxy) 설정
|
||||||
|
외부 도메인(예: `license.yourdomain.com`)을 통해 HTTPS로 접속하려면 역방향 프록시를 설정해야 합니다.
|
||||||
|
|
||||||
|
1. **제어판 > 로그인 포털 > 고급 > 역방향 프록시**로 이동합니다.
|
||||||
|
2. **생성**을 클릭하고 다음과 같이 입력합니다:
|
||||||
|
- **일반 탭**:
|
||||||
|
- 역방향 프록시 이름: `License Manager`
|
||||||
|
- **원본(소스)**:
|
||||||
|
- 프로토콜: `HTTPS`
|
||||||
|
- 호스트 이름: `도메인 주소` (예: `license.qideun.com`)
|
||||||
|
- 포트: `443`
|
||||||
|
- **대상(대상)**:
|
||||||
|
- 프로토콜: `HTTP`
|
||||||
|
- 호스트 이름: `localhost`
|
||||||
|
- 포트: `3006` (또는 설정한 포트)
|
||||||
|
3. **사용자 지정 머리글 탭**:
|
||||||
|
- **생성 > WebSocket** 클릭 (자동으로 `Upgrade`, `Connection` 헤더 추가됨)
|
||||||
|
4. **확인**을 눌러 저장합니다.
|
||||||
|
5. **보안 > 인증서**: 생성한 도메인에 유효한 인증서(Let's Encrypt 등)가 할당되어 있는지 확인합니다.
|
||||||
|
|
||||||
|
### 2. 방화벽 설정
|
||||||
|
- **제어판 > 보안 > 방화벽**에서 3006 포트(내부 전용) 또는 443 포트(외부 접속용)가 열려 있는지 확인합니다.
|
||||||
|
- 공유기를 사용 중이라면 443 포트에 대한 포트 포워딩 설정이 필요할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시스템 업데이트 방법 (Git)
|
||||||
|
코드 수정이나 기능 추가 시 서버에 최신 버전을 적용하는 방법입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 서버 폴더로 이동 (예시 경로)
|
||||||
|
cd /volume1/web/smart_ims_license
|
||||||
|
|
||||||
|
# 2. 최신 코드 가져오기
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 3. 의존성 및 빌드 업데이트 (필요한 경우)
|
||||||
|
cd server && npm install
|
||||||
|
cd ../client && npm install && npm run build
|
||||||
|
|
||||||
|
# 4. 서비스 재시작
|
||||||
|
pm2 restart license-manager
|
||||||
|
```
|
||||||
|
|||||||
@ -8,7 +8,10 @@
|
|||||||
|
|
||||||
### 로그인
|
### 로그인
|
||||||
- **주소**: `http://[NAS-IP 또는 도메인]:3006`
|
- **주소**: `http://[NAS-IP 또는 도메인]:3006`
|
||||||
- **초기 계정**: `admin` / `^Ocean1472bk`
|
- **초기 계정**: `admin` / `admin1234`
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 보안을 위해 최초 접속 후 우측 상단 사용자 이름 아래의 **'비밀번호 변경'** 버튼을 클릭하여 비밀번호를 반드시 수정해 주시기 바랍니다.
|
||||||
|
|
||||||
### 라이선스 신규 발급
|
### 라이선스 신규 발급
|
||||||
1. 왼쪽 **'라이선스 신규 발급'** 패널에서 정보를 입력합니다.
|
1. 왼쪽 **'라이선스 신규 발급'** 패널에서 정보를 입력합니다.
|
||||||
|
|||||||
BIN
server/.env
BIN
server/.env
Binary file not shown.
@ -56,7 +56,7 @@ async function init() {
|
|||||||
// Seed Initial Admin
|
// Seed Initial Admin
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const adminId = 'admin';
|
const adminId = 'admin';
|
||||||
const adminPw = '^Ocean1472bk';
|
const adminPw = 'admin1234';
|
||||||
const [existing] = await connection.query('SELECT id FROM users WHERE login_id = ?', [adminId]);
|
const [existing] = await connection.query('SELECT id FROM users WHERE login_id = ?', [adminId]);
|
||||||
|
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
|
|||||||
@ -81,6 +81,28 @@ app.post('/api/auth/logout', (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/change-password', authenticateToken, async (req, res) => {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [users] = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
|
||||||
|
if (users.length === 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
const validPassword = await bcrypt.compare(currentPassword, user.password);
|
||||||
|
if (!validPassword) return res.status(400).json({ error: '현재 비밀번호가 일치하지 않습니다.' });
|
||||||
|
|
||||||
|
const hashedPw = await bcrypt.hash(newPassword, 10);
|
||||||
|
await db.query('UPDATE users SET password = ? WHERE id = ?', [hashedPw, userId]);
|
||||||
|
|
||||||
|
res.json({ success: true, message: '비밀번호가 성공적으로 변경되었습니다.' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '비밀번호 변경 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- License Routes ---
|
// --- License Routes ---
|
||||||
// 1. Get All Issued Licenses
|
// 1. Get All Issued Licenses
|
||||||
app.get('/api/licenses', authenticateToken, async (req, res) => {
|
app.get('/api/licenses', authenticateToken, async (req, res) => {
|
||||||
@ -168,7 +190,7 @@ if (fs.existsSync(clientDistPath)) {
|
|||||||
app.use(express.static(clientDistPath));
|
app.use(express.static(clientDistPath));
|
||||||
|
|
||||||
// SPA Fallback: Any route not handled by API should return index.html
|
// SPA Fallback: Any route not handled by API should return index.html
|
||||||
app.get('*', (req, res) => {
|
app.get(/.*/, (req, res) => {
|
||||||
if (!req.path.startsWith('/api')) {
|
if (!req.path.startsWith('/api')) {
|
||||||
res.sendFile(path.join(clientDistPath, 'index.html'));
|
res.sendFile(path.join(clientDistPath, 'index.html'));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user