567 lines
33 KiB
TypeScript
567 lines
33 KiB
TypeScript
import { useState, useEffect } 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, Clock, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock } 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 [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(() => {
|
||
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') => {
|
||
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' | '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">
|
||
{/* Section 1: Security & Session */}
|
||
<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">
|
||
<Clock size={20} className="text-blue-600" />
|
||
보안 및 세션
|
||
</h2>
|
||
</div>
|
||
<div className="p-6">
|
||
<label className="block text-sm font-medium text-slate-700 mb-3">세션 자동 로그아웃 시간</label>
|
||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||
<div className="flex items-center gap-1">
|
||
<Input
|
||
type="number"
|
||
className="!w-20 text-center"
|
||
value={Math.floor((settings.session_timeout || 60) / 60)}
|
||
onChange={e => {
|
||
const h = parseInt(e.target.value) || 0;
|
||
setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) });
|
||
}}
|
||
/>
|
||
<span className="font-medium text-slate-900 mx-1">시간</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Input
|
||
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>
|
||
</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>
|
||
</div>
|
||
<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={fetchSettings}>취소</Button>
|
||
<Button size="sm" onClick={() => handleSaveSection('repository')} disabled={saving} icon={<Save size={14} />}>계정 저장</Button>
|
||
</div>
|
||
</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' ? '암호화 키' : '인프라 설정'})
|
||
</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>
|
||
);
|
||
}
|