Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0795bf6b05 | |||
| e94db18201 | |||
| 0e62e89c50 | |||
| b8c6e45aaa | |||
| 0b327c8cf6 |
@ -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, Sliders } from 'lucide-react';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
|
|
||||||
// Axios settings for cookies
|
// Axios settings for cookies
|
||||||
@ -10,6 +10,11 @@ const API_BASE = import.meta.env.DEV
|
|||||||
? 'http://localhost:3006/api'
|
? 'http://localhost:3006/api'
|
||||||
: '/api';
|
: '/api';
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface License {
|
interface License {
|
||||||
id: number;
|
id: number;
|
||||||
module_code: string;
|
module_code: string;
|
||||||
@ -24,10 +29,23 @@ 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);
|
||||||
|
|
||||||
|
// Categories State
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [showCategoryModal, setShowCategoryModal] = useState(false);
|
||||||
|
const [newCategory, setNewCategory] = useState({ code: '', name: '' });
|
||||||
|
const [categorySubmitting, setCategorySubmitting] = useState(false);
|
||||||
|
|
||||||
// UI Flow State
|
// UI Flow State
|
||||||
const [currentView, setCurrentView] = useState<'MASTER' | 'DETAIL'>('MASTER');
|
const [currentView, setCurrentView] = useState<'MASTER' | 'DETAIL'>('MASTER');
|
||||||
const [selectedSubscriber, setSelectedSubscriber] = useState<string>('');
|
const [selectedSubscriber, setSelectedSubscriber] = useState<string>('');
|
||||||
@ -108,12 +126,76 @@ 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();
|
||||||
|
fetchCategories();
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${API_BASE}/module-categories`);
|
||||||
|
setCategories(res.data);
|
||||||
|
if (res.data.length > 0 && !formData.moduleCode) {
|
||||||
|
setFormData(prev => ({ ...prev, moduleCode: res.data[0].code }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch categories', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCategory = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newCategory.code || !newCategory.name) return alert('코드와 이름을 모두 입력해주세요.');
|
||||||
|
setCategorySubmitting(true);
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE}/module-categories`, newCategory);
|
||||||
|
setNewCategory({ code: '', name: '' });
|
||||||
|
fetchCategories();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.error || '추가 실패');
|
||||||
|
} finally {
|
||||||
|
setCategorySubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCategory = async (code: string) => {
|
||||||
|
if (!confirm(`'${code}' 모듈을 삭제하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_BASE}/module-categories/${code}`);
|
||||||
|
fetchCategories();
|
||||||
|
} catch (err) {
|
||||||
|
alert('삭제 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchLicenses = async () => {
|
const fetchLicenses = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -197,13 +279,19 @@ 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>
|
||||||
<span className="text-sm font-bold text-slate-700">{user?.name}님</span>
|
<div className="flex flex-col -space-y-0.5">
|
||||||
|
<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>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-px h-4 bg-slate-200" />
|
<div className="w-px h-4 bg-slate-200" />
|
||||||
|
<button onClick={() => setShowCategoryModal(true)} title="모듈 카테고리 설정" className="p-2 hover:bg-slate-100 rounded-full transition-all text-slate-400 hover:text-indigo-600">
|
||||||
|
<Sliders className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
<button onClick={fetchLicenses} className="p-2 hover:bg-slate-100 rounded-full transition-all">
|
<button onClick={fetchLicenses} className="p-2 hover:bg-slate-100 rounded-full transition-all">
|
||||||
<Clock className={`w-5 h-5 text-slate-400 ${loading ? 'animate-spin' : ''}`} />
|
<Clock className={`w-5 h-5 text-slate-400 ${loading ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
@ -223,9 +311,10 @@ const App = () => {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1.5 uppercase">모듈 시스템</label>
|
<label className="block text-[11px] font-bold text-slate-400 mb-1.5 uppercase">모듈 시스템</label>
|
||||||
<select className="w-full text-sm rounded-xl border-slate-200 bg-slate-50 py-2.5 px-3 focus:bg-white focus:ring-2 focus:ring-indigo-500 outline-none transition-all" value={formData.moduleCode} onChange={e => setFormData({ ...formData, moduleCode: e.target.value })}>
|
<select className="w-full text-sm rounded-xl border-slate-200 bg-slate-50 py-2.5 px-3 focus:bg-white focus:ring-2 focus:ring-indigo-500 outline-none transition-all" value={formData.moduleCode} onChange={e => setFormData({ ...formData, moduleCode: e.target.value })}>
|
||||||
<option value="asset">스마트 자산 관리</option>
|
{categories.map(cat => (
|
||||||
<option value="production">스마트 생산 관리</option>
|
<option key={cat.code} value={cat.code}>{cat.name} ({cat.code})</option>
|
||||||
<option value="monitoring">공정 모니터링 (CCTV)</option>
|
))}
|
||||||
|
{categories.length === 0 && <option value="">등록된 모듈 없음</option>}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -380,6 +469,186 @@ const App = () => {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Module Category Management Modal */}
|
||||||
|
{showCategoryModal && (
|
||||||
|
<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-lg rounded-3xl shadow-2xl border border-slate-200 overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col max-h-[80vh]">
|
||||||
|
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-white sticky top-0 z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-indigo-600 p-2 rounded-xl text-white">
|
||||||
|
<Plus size={18} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold">모듈 카테고리 설정</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCategoryModal(false)}
|
||||||
|
className="p-2 hover:bg-slate-100 rounded-full transition-all text-slate-400"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
|
||||||
|
{/* Add New Category */}
|
||||||
|
<form onSubmit={handleAddCategory} className="mb-8 p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-4 px-1">신규 모듈 추가</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[10px] font-bold text-slate-400 uppercase ml-1">모듈 코드 (영문)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="예: asset"
|
||||||
|
value={newCategory.code}
|
||||||
|
onChange={e => setNewCategory({ ...newCategory, code: e.target.value.toLowerCase() })}
|
||||||
|
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[10px] font-bold text-slate-400 uppercase ml-1">표시 이름 (한글)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="예: 자산 관리"
|
||||||
|
value={newCategory.name}
|
||||||
|
onChange={e => setNewCategory({ ...newCategory, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none transition-all text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={categorySubmitting}
|
||||||
|
className="w-full bg-slate-900 text-white font-bold py-2.5 rounded-xl hover:bg-indigo-600 transition-all text-xs flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{categorySubmitting ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||||
|
<span>모듈 추가하기</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Category List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider px-1">등록된 모듈 목록 ({categories.length})</h4>
|
||||||
|
<div className="divide-y divide-slate-100 border border-slate-100 rounded-2xl overflow-hidden">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<div key={cat.code} className="flex items-center justify-between p-4 bg-white hover:bg-slate-50 transition-colors">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-slate-700">{cat.name}</span>
|
||||||
|
<span className="text-[10px] font-mono text-slate-400">{cat.code}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteCategory(cat.code)}
|
||||||
|
className="p-2 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-slate-400 text-xs italic">
|
||||||
|
등록된 모듈이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-50 border-t border-slate-100 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCategoryModal(false)}
|
||||||
|
className="px-6 py-2 bg-slate-900 text-white font-bold rounded-xl text-xs hover:bg-indigo-600 transition-all"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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; }
|
||||||
|
|||||||
@ -21,7 +21,7 @@ Synology NAS 환경에서 통합 운영을 위해 Express 서버가 빌드된
|
|||||||
최초 로그인 시 아래 계정을 사용하세요.
|
최초 로그인 시 아래 계정을 사용하세요.
|
||||||
|
|
||||||
- **ID**: `admin`
|
- **ID**: `admin`
|
||||||
- **Password**: `^Ocean1472bk`
|
- **Password**: 보안을 위해 문서에서 관리하지 않습니다 (최초 1회 `admin1234` 접속 후 바로 변경 권장)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 만약 로그인이 되지 않는다면 서버 터미널에서 `node init_db.js`를 실행하여 계정을 생성해 주세요.
|
> 만약 로그인이 되지 않는다면 서버 터미널에서 `node init_db.js`를 실행하여 계정을 생성해 주세요.
|
||||||
@ -146,19 +146,43 @@ NAS가 재부팅될 때 PM2가 자동으로 실행되도록 Synology '작업 스
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 시스템 업데이트 방법 (Git)
|
## 시스템 업데이트 방법 (Git)
|
||||||
코드 수정이나 기능 추가 시 서버에 최신 버전을 적용하는 방법입니다.
|
|
||||||
|
|
||||||
|
운영 서버(NAS)에서 최신 코드를 반영하고 시스템을 업데이트하는 절차입니다. SSH 터미널 접속 후 다음 과정을 수행하세요.
|
||||||
|
|
||||||
|
### 1. 최신 소스 코드 반영
|
||||||
|
원격 저장소의 변경 사항을 가져와 로컬 코드를 동기화합니다.
|
||||||
```bash
|
```bash
|
||||||
# 1. 서버 폴더로 이동 (예시 경로)
|
# 프로젝트 루트 디렉토리로 이동 (예시 경로)
|
||||||
cd /volume1/web/smart_ims_license
|
cd /volume1/[사용자폴더]/smart_ims_license
|
||||||
|
|
||||||
# 2. 최신 코드 가져오기
|
# 원격 저장소의 최신 내용 업데이트 (강제 동기화)
|
||||||
git pull origin main
|
git fetch --all
|
||||||
|
git reset --hard origin/main
|
||||||
|
```
|
||||||
|
|
||||||
# 3. 의존성 및 빌드 업데이트 (필요한 경우)
|
### 2. 프론트엔드 빌드 (UI 변경 시 필수)
|
||||||
cd server && npm install
|
웹 인터페이스(React) 소스 및 UI(`App.tsx`)가 변경된 경우 다시 빌드하여 `dist` 파일을 갱신해야 합니다.
|
||||||
cd ../client && npm install && npm run build
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
# 4. 서비스 재시작
|
### 3. 백엔드 업데이트 및 서비스 재시작
|
||||||
|
서버 의존성을 확인하고 PM2 프로세스를 재시작하여 변경 내용을 반영합니다.
|
||||||
|
```bash
|
||||||
|
cd ../server
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# PM2 프로세스 재시작
|
||||||
pm2 restart license-manager
|
pm2 restart license-manager
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4. 상태 확인
|
||||||
|
```bash
|
||||||
|
# 서비스가 정상적으로 실행 중인지 확인
|
||||||
|
pm2 list
|
||||||
|
|
||||||
|
# 실행 로그 확인
|
||||||
|
pm2 logs license-manager
|
||||||
|
```
|
||||||
@ -13,6 +13,14 @@
|
|||||||
- **Backend**: Node.js, Express, MySQL (mysql2)
|
- **Backend**: Node.js, Express, MySQL (mysql2)
|
||||||
- **Database**: MariaDB / MySQL
|
- **Database**: MariaDB / MySQL
|
||||||
- **Security**: RSA 암호화 기반 라이선스 키 생성
|
- **Security**: RSA 암호화 기반 라이선스 키 생성
|
||||||
|
- **발급 형식**: `[Base64 페이로드].[RSA 서명값]`
|
||||||
|
- **포함 정보**: 모듈 코드, 구독자 ID, 라이선스 유형, 만료일, 발급일 등
|
||||||
|
- **정의된 모듈 코드**:
|
||||||
|
- `asset`: 자산 관리
|
||||||
|
- `production`: 생산 관리
|
||||||
|
- `material`: 자재/재고 관리
|
||||||
|
- `quality`: 품질 관리
|
||||||
|
- `cctv`: CCTV 관제
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
- `/client`: React 기반 프론트엔드 소스
|
- `/client`: React 기반 프론트엔드 소스
|
||||||
|
|||||||
@ -8,15 +8,26 @@
|
|||||||
|
|
||||||
### 로그인
|
### 로그인
|
||||||
- **주소**: `http://[NAS-IP 또는 도메인]:3006`
|
- **주소**: `http://[NAS-IP 또는 도메인]:3006`
|
||||||
- **초기 계정**: `admin` / `^Ocean1472bk`
|
- **초기 계정**: `admin` / `admin1234`
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 보안을 위해 최초 접속 후 우측 상단 사용자 이름 아래의 **'비밀번호 변경'** 버튼을 클릭하여 비밀번호를 반드시 수정해 주시기 바랍니다.
|
||||||
|
|
||||||
### 라이선스 신규 발급
|
### 라이선스 신규 발급
|
||||||
1. 왼쪽 **'라이선스 신규 발급'** 패널에서 정보를 입력합니다.
|
1. 왼쪽 **'라이선스 신규 발급'** 패널에서 각 항목을 입력하거나 선택합니다.
|
||||||
- **모듈 시스템**: 자산관리, 생산관리, 모니터링 중 선택
|
- **모듈 시스템**: 발급 대상 시스템 선택
|
||||||
- **유형**: SUB(구독형), DEMO(체험판), DEV(영구 프로젝트용)
|
- 자산 관리 (`asset`)
|
||||||
- **구독자 ID**: 고객사 식별 ID (예: `SOKUREE-2024-01`)
|
- 생산 관리 (`production`)
|
||||||
- **만기 일자**: 라이선스 종료일 지정
|
- 자재/재고 관리 (`material`)
|
||||||
2. **'라이선스 발급'** 버튼을 클릭합니다.
|
- 품질 관리 (`quality`)
|
||||||
|
- CCTV 관제 (`cctv`)
|
||||||
|
- **유형 (License Type)**:
|
||||||
|
- `sub`: 정식 구독용 (기본 만료일 365일 자동 설정)
|
||||||
|
- `demo`: 데모 체험용 (기본 만료일 30일 자동 설정)
|
||||||
|
- `dev`: 개발 및 유지보수용 (만료일 제한 없음, 영구)
|
||||||
|
- **구독자 ID (Subscriber ID)**: 플랫폼의 **[서버 환경 설정]** 메뉴에 등록된 ID와 대소문자까지 정확히 일치해야 합니다. (예: `SAMSUNG-01`, `SOKUREE-DEMO`)
|
||||||
|
- **만기 일자 (Expiry Date)**: 라이선스 종료일 지정 (`YYYY-MM-DD` 형식, `dev` 유형은 입력 불필요)
|
||||||
|
2. 모든 정보 확인 후 **'라이선스 발급'** 버튼을 클릭합니다.
|
||||||
|
|
||||||
### 라이선스 관리 및 복사
|
### 라이선스 관리 및 복사
|
||||||
- **검색**: 상단 검색바를 통해 특정 구독자 ID를 조회할 수 있습니다.
|
- **검색**: 상단 검색바를 통해 특정 구독자 ID를 조회할 수 있습니다.
|
||||||
@ -24,6 +35,13 @@
|
|||||||
- **키 복사**: 발급된 라이선스 키의 아이콘을 클릭하여 클립보드에 복사할 수 있습니다.
|
- **키 복사**: 발급된 라이선스 키의 아이콘을 클릭하여 클립보드에 복사할 수 있습니다.
|
||||||
- **삭제**: 필요 없는 라이선스는 쓰레기통 아이콘으로 삭제 가능합니다.
|
- **삭제**: 필요 없는 라이선스는 쓰레기통 아이콘으로 삭제 가능합니다.
|
||||||
|
|
||||||
|
### 모듈 시스템 설정 (카테고리 관리)
|
||||||
|
새로운 모듈이 추가되거나 명칭 변경이 필요한 경우 사용자가 직접 수정할 수 있습니다.
|
||||||
|
1. 우측 상단 사용자명 옆의 **설정(아이콘)** 버튼을 클릭합니다.
|
||||||
|
2. **'모듈 카테고리 설정'** 창에서 신규 모듈 코드(영문)와 이름(한글)을 입력하여 추가합니다.
|
||||||
|
3. 등록된 모듈은 라이선스 발급 시 선택 목록에 즉시 반영됩니다.
|
||||||
|
4. 더 이상 사용하지 않는 모듈은 목록에서 삭제 가능합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 터미널(CLI) 작업 가이드
|
## 2. 터미널(CLI) 작업 가이드
|
||||||
|
|||||||
@ -10,8 +10,8 @@ async function check() {
|
|||||||
database: process.env.DB_NAME || 'license_manager_db',
|
database: process.env.DB_NAME || 'license_manager_db',
|
||||||
port: process.env.DB_PORT || 3307
|
port: process.env.DB_PORT || 3307
|
||||||
});
|
});
|
||||||
const [rows] = await db.query("SELECT * FROM issued_licenses WHERE module_code = 'monitoring'");
|
const [rows] = await db.query("SELECT * FROM issued_licenses WHERE module_code = 'cctv'");
|
||||||
console.log('--- Monitoring Licenses ---');
|
console.log('--- CCTV Licenses ---');
|
||||||
console.log(JSON.stringify(rows, null, 2));
|
console.log(JSON.stringify(rows, null, 2));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -14,7 +14,31 @@ async function init() {
|
|||||||
await connection.query(`CREATE DATABASE IF NOT EXISTS ${dbName}`);
|
await connection.query(`CREATE DATABASE IF NOT EXISTS ${dbName}`);
|
||||||
await connection.query(`USE ${dbName}`);
|
await connection.query(`USE ${dbName}`);
|
||||||
|
|
||||||
// 1. issued_licenses
|
// 1. module_categories table
|
||||||
|
const createCategoriesSql = `
|
||||||
|
CREATE TABLE IF NOT EXISTS module_categories (
|
||||||
|
code VARCHAR(50) PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`;
|
||||||
|
await connection.query(createCategoriesSql);
|
||||||
|
|
||||||
|
// Seed initial categories if none exist
|
||||||
|
const [existingCats] = await connection.query('SELECT COUNT(*) as count FROM module_categories');
|
||||||
|
if (existingCats[0].count === 0) {
|
||||||
|
const initialCats = [
|
||||||
|
['asset', '자산 관리'],
|
||||||
|
['production', '생산 관리'],
|
||||||
|
['material', '자재/재고 관리'],
|
||||||
|
['quality', '품질 관리'],
|
||||||
|
['cctv', 'CCTV 관제']
|
||||||
|
];
|
||||||
|
await connection.query('INSERT INTO module_categories (code, name) VALUES ?', [initialCats]);
|
||||||
|
console.log('✅ Seeded initial module categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. issued_licenses
|
||||||
const createTableSql = `
|
const createTableSql = `
|
||||||
CREATE TABLE IF NOT EXISTS issued_licenses (
|
CREATE TABLE IF NOT EXISTS issued_licenses (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@ -28,6 +52,8 @@ async function init() {
|
|||||||
`;
|
`;
|
||||||
await connection.query(createTableSql);
|
await connection.query(createTableSql);
|
||||||
|
|
||||||
|
// Migration block removed to prevent test data from being uploaded to production
|
||||||
|
/*
|
||||||
try {
|
try {
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT IGNORE INTO smart_ims_license_db.issued_licenses
|
INSERT IGNORE INTO smart_ims_license_db.issued_licenses
|
||||||
@ -39,6 +65,7 @@ async function init() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Skip issued_licenses migration:', e.message);
|
console.log('Skip issued_licenses migration:', e.message);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// 3. users table
|
// 3. users table
|
||||||
const createUsersSql = `
|
const createUsersSql = `
|
||||||
@ -56,7 +83,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) {
|
||||||
|
|||||||
@ -2,13 +2,13 @@ const crypto = require('crypto');
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a signed license key (Base64 Payload + Base64 Signature)
|
* Generate a signed license key (Base64 Payload + Base64 Signature)
|
||||||
* @param {string} moduleCode - 'asset', 'production', 'monitoring'
|
* @param {string} moduleCode - 'asset', 'production', 'material', 'quality', 'cctv'
|
||||||
* @param {string} type - 'dev', 'sub', 'demo'
|
* @param {string} type - 'sub' (정식), 'demo' (데모), 'dev' (개발/영구)
|
||||||
* @param {string} subscriberId - Subscriber ID
|
* @param {string} subscriberId - 구독자 ID (플랫폼 설정과 일치해야 함)
|
||||||
* @param {number} [activationDays] - Optional
|
* @param {number} [activationDays] - 활성화 가능 일수 (선택 사항)
|
||||||
* @param {string} [customExpiryDate] - Optional YYYY-MM-DD
|
* @param {string} [customExpiryDate] - 만료일 (YYYY-MM-DD)
|
||||||
* @param {string|Buffer} privateKey - PEM formatted private key
|
* @param {string|Buffer} privateKey - RSA 개인키 (.pem)
|
||||||
* @returns {string} Signed License Token
|
* @returns {string} Signed License Token ([Payload].[Signature])
|
||||||
*/
|
*/
|
||||||
function generateLicense(moduleCode, type, subscriberId, activationDays = null, customExpiryDate = null, privateKey) {
|
function generateLicense(moduleCode, type, subscriberId, activationDays = null, customExpiryDate = null, privateKey) {
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
|
|||||||
@ -81,6 +81,63 @@ 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: '비밀번호 변경 실패' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Module Category Routes ---
|
||||||
|
app.get('/api/module-categories', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query('SELECT * FROM module_categories ORDER BY created_at ASC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch categories' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/module-categories', authenticateToken, async (req, res) => {
|
||||||
|
const { code, name } = req.body;
|
||||||
|
if (!code || !name) return res.status(400).json({ error: 'Code and Name are required' });
|
||||||
|
try {
|
||||||
|
await db.query('INSERT INTO module_categories (code, name) VALUES (?, ?)', [code.toLowerCase(), name]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') return res.status(400).json({ error: '이미 존재하는 코드입니다.' });
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to add category' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/module-categories/:code', authenticateToken, async (req, res) => {
|
||||||
|
const { code } = req.params;
|
||||||
|
try {
|
||||||
|
await db.query('DELETE FROM module_categories WHERE code = ?', [code]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete category' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- 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) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user