[RELEASE] v0.4.2.0 - License management security, environment isolation, and auto-fill prevention
This commit is contained in:
parent
ea82919bfe
commit
576880c5bd
@ -35,8 +35,13 @@
|
|||||||
- [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리
|
- [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리
|
||||||
- [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리
|
- [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리
|
||||||
- **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256)
|
- **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256)
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.4.2.0`
|
#### 🏷️ Tag: `v0.4.2.0`
|
||||||
|
- [ ] **라이선스 관리 오류 수정**
|
||||||
|
- [ ] **시스템 관리 기본설정 오류 수정**
|
||||||
|
- [ ] **시스템 업데이트 오류 수정**
|
||||||
|
|
||||||
|
|
||||||
|
#### 🏷️ Tag: `v0.4.3.0`
|
||||||
- [ ] **소모품 관리**
|
- [ ] **소모품 관리**
|
||||||
- [ ] 이 기능은 자재/재고 관리 모듈 생성하여 메뉴가 아니라 속성(카테고리)으로 추가할 예정
|
- [ ] 이 기능은 자재/재고 관리 모듈 생성하여 메뉴가 아니라 속성(카테고리)으로 추가할 예정
|
||||||
|
|
||||||
|
|||||||
@ -438,14 +438,12 @@ const initTables = async () => {
|
|||||||
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
|
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
|
||||||
if (existingModules.length === 0) {
|
if (existingModules.length === 0) {
|
||||||
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
||||||
await db.query(insert, ['asset', '자산 관리', true, 'dev']);
|
await db.query(insert, ['asset', '자산 관리', false, null]);
|
||||||
await db.query(insert, ['production', '생산 관리', false, null]);
|
await db.query(insert, ['production', '생산 관리', false, null]);
|
||||||
await db.query(insert, ['cctv', 'CCTV', true, 'dev']);
|
await db.query(insert, ['cctv', 'CCTV', false, null]);
|
||||||
} else {
|
} else {
|
||||||
// One-time update: Rename 'monitoring' code to 'cctv' and ensure it's active
|
// One-time update: Rename 'monitoring' code to 'cctv' (migration)
|
||||||
await db.query("UPDATE system_modules SET code = 'cctv', is_active = 1 WHERE code = 'monitoring'");
|
await db.query("UPDATE system_modules SET code = 'cctv' WHERE code = 'monitoring'");
|
||||||
// Also ensure 'cctv' is active if it already exists but was inactive due to renaming glitches
|
|
||||||
await db.query("UPDATE system_modules SET is_active = 1 WHERE code = 'cctv'");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Tables Initialized');
|
console.log('✅ Tables Initialized');
|
||||||
|
|||||||
@ -278,7 +278,8 @@ router.get('/modules', isAuthenticated, async (req, res) => {
|
|||||||
|
|
||||||
// Get stored subscriber ID
|
// Get stored subscriber ID
|
||||||
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||||
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
|
// Ensure we return null or empty string if not found, DO NOT use any hardcoded fallback
|
||||||
|
const serverSubscriberId = (subRows.length > 0 && subRows[0].setting_value) ? subRows[0].setting_value : '';
|
||||||
|
|
||||||
defaults.forEach(code => {
|
defaults.forEach(code => {
|
||||||
const found = rows.find(r => r.code === code);
|
const found = rows.find(r => r.code === code);
|
||||||
@ -322,7 +323,10 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Module match
|
// 2. Check Module match
|
||||||
if (result.module !== code) {
|
// Allow legacy 'monitoring' licenses to activate 'cctv' module
|
||||||
|
const isMatch = result.module === code || (code === 'cctv' && result.module === 'monitoring');
|
||||||
|
|
||||||
|
if (!isMatch) {
|
||||||
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
|
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,7 +346,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (result.subscriberId !== serverSubscriberId) {
|
if (result.subscriberId !== serverSubscriberId) {
|
||||||
return res.status(403).json({
|
return res.status(400).json({
|
||||||
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
|
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,8 @@ export function LoginPage() {
|
|||||||
placeholder="아이디를 입력하세요"
|
placeholder="아이디를 입력하세요"
|
||||||
required
|
required
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
|
readOnly
|
||||||
|
onFocus={(e) => e.target.readOnly = false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -77,6 +79,8 @@ export function LoginPage() {
|
|||||||
placeholder="비밀번호를 입력하세요"
|
placeholder="비밀번호를 입력하세요"
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
readOnly
|
||||||
|
onFocus={(e) => e.target.readOnly = false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -452,7 +452,14 @@ export function BasicSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium text-slate-700">비밀번호</label>
|
<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); }} />
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
readOnly
|
||||||
|
onFocus={(e) => e.target.readOnly = false}
|
||||||
|
value={settings.db_config.password}
|
||||||
|
onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -528,6 +535,9 @@ export function BasicSettingsPage() {
|
|||||||
<label className="block text-sm font-medium text-slate-700 mb-2">최고관리자 비밀번호</label>
|
<label className="block text-sm font-medium text-slate-700 mb-2">최고관리자 비밀번호</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
readOnly
|
||||||
|
onFocus={(e) => e.target.readOnly = false}
|
||||||
value={verifyPassword}
|
value={verifyPassword}
|
||||||
onChange={e => setVerifyPassword(e.target.value)}
|
onChange={e => setVerifyPassword(e.target.value)}
|
||||||
placeholder="비밀번호를 입력하세요"
|
placeholder="비밀번호를 입력하세요"
|
||||||
|
|||||||
@ -242,6 +242,10 @@ export function UserManagementPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">아이디 <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
|
||||||
|
name="user_id_new"
|
||||||
|
autoComplete="off"
|
||||||
|
readOnly={!isEditing}
|
||||||
|
onFocus={(e) => e.target.readOnly = false}
|
||||||
value={formData.id}
|
value={formData.id}
|
||||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||||
disabled={isEditing}
|
disabled={isEditing}
|
||||||
@ -255,6 +259,10 @@ export function UserManagementPage() {
|
|||||||
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
name="user_password_new"
|
||||||
|
autoComplete="new-password"
|
||||||
|
readOnly={!isEditing}
|
||||||
|
onFocus={(e) => e.target.readOnly = false}
|
||||||
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 })}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function LicensePage() {
|
|||||||
const moduleInfo: Record<string, { title: string, desc: string }> = {
|
const moduleInfo: Record<string, { title: string, desc: string }> = {
|
||||||
'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' },
|
'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' },
|
||||||
'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' },
|
'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' },
|
||||||
'monitoring': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
|
'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscriber Configuration State
|
// Subscriber Configuration State
|
||||||
@ -21,6 +21,13 @@ export function LicensePage() {
|
|||||||
const [inputSubscriberId, setInputSubscriberId] = useState('');
|
const [inputSubscriberId, setInputSubscriberId] = useState('');
|
||||||
const [isConfigSaving, setIsConfigSaving] = useState(false);
|
const [isConfigSaving, setIsConfigSaving] = useState(false);
|
||||||
|
|
||||||
|
// Supervisor Verification State
|
||||||
|
const [isConfigUnlocked, setIsConfigUnlocked] = useState(false);
|
||||||
|
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
||||||
|
const [verifyPassword, setVerifyPassword] = useState('');
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyError, setVerifyError] = useState('');
|
||||||
|
|
||||||
// Initial Load for Subscriber ID
|
// Initial Load for Subscriber ID
|
||||||
useState(() => {
|
useState(() => {
|
||||||
fetchSubscriberId();
|
fetchSubscriberId();
|
||||||
@ -86,7 +93,30 @@ export function LicensePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ... Generator logic omitted for brevity as it's dev-only ...
|
|
||||||
|
const handleOpenVerify = () => {
|
||||||
|
setVerifyError('');
|
||||||
|
setVerifyPassword('');
|
||||||
|
setShowVerifyModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifySupervisor = async (e?: React.FormEvent) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
setVerifying(true);
|
||||||
|
setVerifyError('');
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post('/verify-supervisor', { password: verifyPassword });
|
||||||
|
if (res.data.success) {
|
||||||
|
setIsConfigUnlocked(true);
|
||||||
|
setShowVerifyModal(false);
|
||||||
|
setVerifyPassword('');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setVerifyError(error.response?.data?.message || '인증 실패');
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -106,12 +136,29 @@ export function LicensePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8 overflow-hidden">
|
||||||
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
<Terminal size={20} className="text-slate-600" />
|
<Terminal size={20} className="text-slate-600" />
|
||||||
서버 환경 설정
|
서버 환경 설정
|
||||||
</h3>
|
</h3>
|
||||||
<div className="max-w-xl">
|
{!isConfigUnlocked && (
|
||||||
|
<button
|
||||||
|
onClick={handleOpenVerify}
|
||||||
|
className="text-xs bg-slate-200 hover:bg-slate-300 px-3 py-1 rounded font-bold text-slate-700 flex items-center gap-1 transition"
|
||||||
|
>
|
||||||
|
<Shield size={12} /> 조회 / 변경
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isConfigUnlocked && (
|
||||||
|
<span className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded font-bold flex items-center gap-1">
|
||||||
|
<Shield size={12} /> 최고관리자 권한
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConfigUnlocked ? (
|
||||||
|
<div className="max-w-xl animate-in fade-in duration-300">
|
||||||
<div className="flex items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
@ -128,15 +175,22 @@ export function LicensePage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveConfig}
|
onClick={handleSaveConfig}
|
||||||
disabled={isConfigSaving}
|
disabled={isConfigSaving}
|
||||||
className="bg-slate-800 text-white px-6 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50 h-[42px]"
|
className="bg-slate-800 text-white px-6 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50 h-[42px] whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{isConfigSaving ? '저장 중...' : '설정 저장'}
|
{isConfigSaving ? '저장 중...' : '설정 저장'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
* 라이선스 키에 포함된 ID와 일치해야 활성화됩니다.
|
* 주의: 구독자 ID를 변경하면 기존에 활성화된 모든 모듈의 라이선스 효력이 중지될 수 있습니다.<br />
|
||||||
|
* 반드시 라이선스 키에 포함된 ID와 일치하게 설정하십시오.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center bg-slate-100 rounded border border-slate-200 border-dashed">
|
||||||
|
<p className="text-slate-500 text-sm mb-2">서버 환경 설정은 시스템의 핵심 식별자입니다.</p>
|
||||||
|
<p className="text-slate-400 text-xs">보안을 위해 <strong>최고관리자(Supervisor)</strong> 인증 후 변경할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
@ -260,6 +314,50 @@ export function LicensePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Supervisor Verification Modal */}
|
||||||
|
{showVerifyModal && (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-[60] animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white rounded-lg shadow-2xl w-full max-w-sm overflow-hidden border border-slate-200">
|
||||||
|
<div className="bg-slate-800 p-4 text-white">
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<Shield size={20} /> 최고관리자 인증
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-300 text-xs mt-1">서버 핵심 설정을 변경하려면 인증이 필요합니다.</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleVerifySupervisor} className="p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-bold text-slate-700 mb-2">비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
className="w-full border border-slate-300 rounded px-3 py-2 focus:ring-2 focus:ring-slate-500 outline-none"
|
||||||
|
placeholder="최고관리자 비밀번호 입력"
|
||||||
|
value={verifyPassword}
|
||||||
|
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{verifyError && <p className="text-red-500 text-xs mt-2 font-bold">{verifyError}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowVerifyModal(false); setVerifyPassword(''); }}
|
||||||
|
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded text-sm font-medium"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={verifying}
|
||||||
|
className="px-4 py-2 bg-slate-800 text-white rounded hover:bg-slate-900 text-sm font-bold"
|
||||||
|
>
|
||||||
|
{verifying ? '인증 중...' : '확인'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user