smart_ims/src/platform/pages/BasicSettingsPage.tsx

552 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react';
import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input';
import { apiClient } from '../../shared/api/client';
import { useAuth } from '../../shared/auth/AuthContext';
import { Save, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock, Clock } from 'lucide-react';
interface SystemSettings {
session_timeout: number;
encryption_key: string;
subscriber_id: string;
gitea_url: string;
gitea_user: string;
gitea_password: string;
db_config: {
host: string;
user: string;
password?: string;
database: string;
port: string;
};
}
export function BasicSettingsPage() {
const { user: currentUser } = useAuth();
const [settings, setSettings] = useState<SystemSettings>({
session_timeout: 60,
encryption_key: '',
subscriber_id: '',
gitea_url: '',
gitea_user: '',
gitea_password: '',
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,
repository: null,
database: null
});
const [testing, setTesting] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [rotationStatus, setRotationStatus] = useState<{ currentKey: string; affectedCount: number } | null>(null);
const [rotating, setRotating] = useState(false);
// Supervisor Protection States
const [isDbConfigUnlocked, setIsDbConfigUnlocked] = useState(false);
const [isEncryptionUnlocked, setIsEncryptionUnlocked] = useState(false);
const [isRepoUnlocked, setIsRepoUnlocked] = useState(false);
const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | 'repository' | null>(null);
const [showVerifyModal, setShowVerifyModal] = useState(false);
const [verifyPassword, setVerifyPassword] = useState('');
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState('');
const fetchedRef = useRef(false);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
fetchSettings();
fetchEncryptionStatus();
}, []);
const fetchSettings = async () => {
try {
const res = await apiClient.get('/system/settings');
const data = res.data;
if (!data.db_config) {
data.db_config = { host: '', user: '', password: '', database: '', port: '3306' };
}
setSettings(data);
setIsDbVerified(true);
} catch (error) {
console.error('Failed to fetch settings', error);
} finally {
setLoading(false);
}
};
const fetchEncryptionStatus = async () => {
try {
const res = await apiClient.get('/system/encryption/status');
setRotationStatus({
currentKey: res.data.current_key,
affectedCount: Object.values(res.data.affected_records as Record<string, number>).reduce((a, b) => a + b, 0)
});
} catch (error) {
console.error('Failed to fetch encryption status', error);
}
};
const generateNewKey = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()';
let key = 'smartims_';
for (let i = 0; i < 24; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
setSettings({ ...settings, encryption_key: key });
};
const handleRotateKey = async () => {
if (!settings.encryption_key) {
alert('새로운 암호화 키를 입력하거나 생성해 주세요.');
return;
}
const confirmMsg = `암호화 키 변경 및 데이터 마이그레이션을 시작합니다.\n- 대상 레코드: 약 ${rotationStatus?.affectedCount || 0}\n- 소요 시간: 데이터 양에 따라 수 초가 걸릴 수 있습니다.\n\n주의: 절차 진행 중에는 서버 연결이 일시적으로 끊길 수 있으며, 중간에 브라우저를 닫지 마십시오.\n진행하시겠습니까?`;
if (!confirm(confirmMsg)) return;
setRotating(true);
setSaveResults(prev => ({ ...prev, encryption: null }));
try {
await apiClient.post('/system/encryption/rotate', { new_key: settings.encryption_key });
setSaveResults(prev => ({
...prev,
encryption: { success: true, message: '암호화 키 변경 및 데이터 마이그레이션 완료!' }
}));
fetchSettings();
fetchEncryptionStatus();
setTimeout(() => setSaveResults(prev => ({ ...prev, encryption: null })), 5000);
} catch (error: any) {
setSaveResults(prev => ({
...prev,
encryption: { success: false, message: error.response?.data?.error || '재암호화 작업 중 오류가 발생했습니다.' }
}));
} finally {
setRotating(false);
}
};
const handleTestConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await apiClient.post('/system/test-db', settings.db_config);
setTestResult({ success: true, message: res.data.message });
setIsDbVerified(true);
} catch (error: any) {
setTestResult({ success: false, message: error.response?.data?.error || '접속 테스트에 실패했습니다.' });
setIsDbVerified(false);
} finally {
setTesting(false);
}
};
const handleOpenVerify = (target: 'database' | 'encryption' | 'repository') => {
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);
else if (verifyingTarget === 'repository') setIsRepoUnlocked(true);
setShowVerifyModal(false);
setVerifyPassword('');
setVerifyingTarget(null);
}
} catch (error: any) {
setVerifyError(error.response?.data?.message || '인증에 실패했습니다. 비밀번호를 확인해주세요.');
} finally {
setVerifying(false);
}
};
const handleSaveSection = async (section: 'security' | 'encryption' | 'database' | 'repository') => {
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 === 'repository') payload = {
gitea_url: settings.gitea_url,
gitea_user: settings.gitea_user,
gitea_password: settings.gitea_password
};
else if (section === 'database') {
if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return;
payload = { db_config: settings.db_config };
}
setSaving(true);
setSaveResults(prev => ({ ...prev, [section]: null }));
try {
await apiClient.post('/system/settings', payload);
setSaveResults(prev => ({ ...prev, [section]: { success: true, message: successMessage } }));
setTimeout(() => setSaveResults(prev => ({ ...prev, [section]: null })), 3000);
if (section !== 'database') fetchSettings();
} catch (error: any) {
setSaveResults(prev => ({
...prev,
[section]: { success: false, message: error.response?.data?.error || '저장 중 오류 발생' }
}));
} finally {
setSaving(false);
}
};
if (loading) return <div className="p-12 text-center text-slate-500"> ...</div>;
return (
<div className="page-container p-6 max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
</div>
<div className="space-y-8">
{/* v0.4.1.0 Info: Session Timeout relocation notice */}
<Card className="p-4 bg-indigo-50 border-indigo-100 flex items-start gap-3">
<Clock className="text-indigo-600 mt-1" size={20} />
<div>
<h3 className="text-sm font-bold text-indigo-900"> (v0.4.1.0)</h3>
<p className="text-xs text-indigo-700 mt-1 leading-relaxed">
<strong> </strong> .<br />
<strong>[ ]</strong> <strong>[ ]</strong> .
</p>
</div>
</Card>
{/* Section 2: Encryption Master Key & Rotation (Supervisor Protected) */}
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Key size={20} className="text-red-600" />
</h2>
{!isEncryptionUnlocked && (
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('encryption')} icon={<Lock size={14} />}>
/
</Button>
)}
{isEncryptionUnlocked && (
<div className="flex items-center gap-2 text-red-700 bg-red-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-red-200">
<Unlock size={14} /> 완료: 최고관리자
</div>
)}
</div>
{isEncryptionUnlocked ? (
<div className="animate-in fade-in duration-500">
<div className="p-6 space-y-6">
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500 font-bold block mb-1"> </span>
<code className="bg-white p-2 rounded border border-slate-200 block truncate font-mono">{rotationStatus?.currentKey || '-'}</code>
</div>
<div>
<span className="text-slate-500 font-bold block mb-1"> </span>
<div className="text-blue-600 font-bold flex items-center gap-2 mt-1">
<Database size={16} /> {rotationStatus?.affectedCount || 0}
<Button variant="secondary" size="sm" className="ml-2 h-7 text-[10px]" onClick={fetchEncryptionStatus} icon={<RefreshCcw size={12} />}></Button>
</div>
</div>
</div>
<div className="flex gap-2 max-w-xl">
<Input
value={settings.encryption_key}
onChange={e => setSettings({ ...settings, encryption_key: e.target.value })}
placeholder="새로운 키 입력"
className="font-mono flex-1"
/>
<Button variant="secondary" onClick={generateNewKey} icon={<RefreshCcw size={14} />}> </Button>
</div>
</div>
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
<div className="flex-1">
{saveResults.encryption && (
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.encryption.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.encryption.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{saveResults.encryption.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIsEncryptionUnlocked(false)}></Button>
<Button
className="bg-red-600 hover:bg-red-700"
size="sm"
onClick={handleRotateKey}
disabled={rotating || !settings.encryption_key}
icon={<RefreshCcw size={14} className={rotating ? 'animate-spin' : ''} />}
>
{rotating ? '재암호화 진행 중...' : '변경 및 마이그레이션 저장'}
</Button>
</div>
</div>
</div>
) : (
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
<Lock className="mx-auto mb-3 opacity-20" size={48} />
<p className="text-sm font-medium"> .</p>
</div>
)}
</Card>
{/* Section 2.5: Gitea Repository Update Settings */}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<RefreshCcw size={20} className="text-emerald-600" />
(Gitea)
</h2>
{!isRepoUnlocked && (
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('repository')} icon={<Lock size={14} />}>
/
</Button>
)}
{isRepoUnlocked && (
<div className="flex items-center gap-2 text-emerald-700 bg-emerald-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-emerald-200">
<Unlock size={14} /> 완료: 최고관리자
</div>
)}
</div>
{isRepoUnlocked ? (
<div className="animate-in fade-in duration-500">
<div className="p-6">
<p className="text-xs text-slate-500 mb-4"> (Gitea) .</p>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> URL (Git )</label>
<Input
value={settings.gitea_url}
onChange={e => setSettings({ ...settings, gitea_url: e.target.value })}
placeholder="https://gitea.example.com/org/repo.git"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">Gitea ID</label>
<Input
name="gitea_username_config"
autoComplete="off"
value={settings.gitea_user}
onChange={e => setSettings({ ...settings, gitea_user: e.target.value })}
placeholder="gitea_update_user"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">Gitea ( Token)</label>
<Input
type="password"
name="gitea_password_config"
autoComplete="new-password"
value={settings.gitea_password}
onChange={e => setSettings({ ...settings, gitea_password: e.target.value })}
placeholder="••••••••"
/>
</div>
</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.repository && (
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.repository.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.repository.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{saveResults.repository.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIsRepoUnlocked(false)}></Button>
<Button size="sm" onClick={() => handleSaveSection('repository')} disabled={saving} 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>
{/* Section 3: Database Infrastructure (Supervisor Protected) */}
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<ShieldAlert size={20} className="text-indigo-600" />
</h2>
{!isDbConfigUnlocked && (
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('database')} icon={<Lock size={14} />}>
/
</Button>
)}
{isDbConfigUnlocked && (
<div className="flex items-center gap-2 text-indigo-700 bg-indigo-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-indigo-200">
<Unlock size={14} /> 완료: 최고관리자
</div>
)}
</div>
{isDbConfigUnlocked ? (
<div className="animate-in fade-in duration-500">
<div className="p-6 space-y-6">
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg flex gap-4 items-start">
<AlertCircle className="text-indigo-600 shrink-0" size={20} />
<div>
<p className="text-sm font-bold text-indigo-900"> </p>
<p className="text-xs text-indigo-700 leading-relaxed mt-1">
.<br />
.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">DB </label>
<Input value={settings.db_config.host} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, host: e.target.value } }); setIsDbVerified(false); }} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> </label>
<Input value={settings.db_config.user} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, user: e.target.value } }); setIsDbVerified(false); }} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"></label>
<Input type="password" value={settings.db_config.password} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} />
</div>
</div>
</div>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> (Database)</label>
<Input value={settings.db_config.database} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, database: e.target.value } }); setIsDbVerified(false); }} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> (Port)</label>
<Input type="number" value={settings.db_config.port} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, port: e.target.value } }); setIsDbVerified(false); }} />
</div>
</div>
</div>
<div className="p-4 bg-slate-50 border border-slate-100 rounded-lg flex justify-between items-center">
<div className="text-sm font-medium">
{testResult && (
<span className={`flex items-center gap-1 ${testResult.success ? 'text-green-700' : 'text-red-700'}`}>
{testResult.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />} {testResult.message}
</span>
)}
{!testResult && <span className="text-slate-500 italic"> .</span>}
</div>
<Button variant="secondary" size="sm" onClick={handleTestConnection} disabled={testing} icon={<Server size={14} />}>
{testing ? '검증 중...' : '연결 테스트'}
</Button>
</div>
</div>
<div className="px-6 py-4 bg-slate-100/30 border-t border-slate-100 flex items-center justify-between">
<div className="flex-1">
{testResult && !testResult.success && <span className="text-xs text-red-600 font-bold"> .</span>}
{saveResults.database && (
<div className={`text-sm font-medium ${saveResults.database.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.database.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIsDbConfigUnlocked(false)}></Button>
<Button size="sm" className="bg-indigo-600" onClick={() => handleSaveSection('database')} disabled={saving || !isDbVerified} icon={<Save size={14} />}> </Button>
</div>
</div>
</div>
) : (
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
<Lock className="mx-auto mb-3 opacity-20" size={48} />
<p className="text-sm font-medium"> .</p>
</div>
)}
</Card>
</div>
{/* Verification Modal */}
{showVerifyModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-300">
<Card className="w-full max-w-md shadow-2xl border-indigo-200 overflow-hidden">
<div className={`p-6 text-white ${verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'}`}>
<h3 className="text-lg font-bold flex items-center gap-2">
<ShieldAlert size={20} /> ({verifyingTarget === 'encryption' ? '암호화 키' : verifyingTarget === 'repository' ? '저장소 설정' : '인프라 설정'})
</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'
? "※ 경고: 암호화 키 변경은 시스템 내 모든 민감 데이터를 다시 암호화하는 중대한 작업입니다. 성공 시 기존 데이터를 새 키로 대체하며, 실패 시 데이터 유실의 위험이 있습니다."
: verifyingTarget === 'repository'
? "※ 주의: 시스템 업데이트 저장소 정보 노출은 원본 소스 코드 유출로 이어질 수 있습니다."
: "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."}
</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>
);
}