릴리즈 v0.3.4: 권한 계층 최적화 및 태그 기반 동적 업데이트 엔진 도입
This commit is contained in:
parent
c6ab665685
commit
107c46191f
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "smartims",
|
||||
"private": true,
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -462,7 +462,7 @@ const getGiteaAuth = async () => {
|
||||
return { url: 'https://gitea.qideun.com/SOKUREE/smart_ims.git', user: null, pass: null };
|
||||
};
|
||||
|
||||
// 5. Get Version Info (Current & Remote)
|
||||
// 5. Get Version Info (Current, Remote & History from Tags)
|
||||
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const packageJsonPath = path.join(__dirname, '../package.json');
|
||||
@ -474,7 +474,6 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
|
||||
let fetchCmd = 'git fetch --tags --force';
|
||||
|
||||
if (auth.user && auth.pass) {
|
||||
// Inject auth into URL
|
||||
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
|
||||
fetchCmd = `git fetch ${authenticatedUrl} --tags --force`;
|
||||
} else {
|
||||
@ -484,33 +483,62 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
|
||||
exec(fetchCmd, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('Git fetch failed:', err);
|
||||
// Mask password in error message
|
||||
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
|
||||
return res.json({
|
||||
current: currentVersion,
|
||||
latest: null,
|
||||
history: [],
|
||||
error: `원격 저장소 동기화 실패: ${sanitizedError || err.message}`
|
||||
});
|
||||
}
|
||||
|
||||
// Get the latest tag (Simplified for cross-platform compatibility)
|
||||
// git describe --tags --abbrev=0 is more robust across shells
|
||||
exec('git describe --tags --abbrev=0', (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('Git describe failed:', err);
|
||||
return res.json({
|
||||
current: currentVersion,
|
||||
latest: null,
|
||||
needsUpdate: false,
|
||||
error: '태그 정보를 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
// Get last 10 tags with their annotation messages and dates
|
||||
// Format: tag|subject|body|date
|
||||
const format = '%(refname:short)|%(contents:subject)|%(contents:body)|%(creatordate:iso8601)';
|
||||
const historyCmd = `git for-each-ref refs/tags --sort=-creatordate --format="${format}" --count=10`;
|
||||
|
||||
exec(historyCmd, (err, stdout, stderr) => {
|
||||
const lines = stdout ? stdout.trim().split('\n') : [];
|
||||
const history = lines.map(line => {
|
||||
const [tag, subject, body, date] = line.split('|');
|
||||
if (!tag) return null;
|
||||
|
||||
// Parse Type from subject: e.g. "[FEATURE] Title"
|
||||
let type = 'patch';
|
||||
let title = subject || '';
|
||||
const typeMatch = (subject || '').match(/^\[(URGENT|FEATURE|FIX|PATCH|HOTFIX)\]\s*(.*)$/i);
|
||||
if (typeMatch) {
|
||||
const rawType = typeMatch[1].toUpperCase();
|
||||
if (rawType === 'URGENT' || rawType === 'HOTFIX') type = 'urgent';
|
||||
else if (rawType === 'FEATURE') type = 'feature';
|
||||
else type = 'fix';
|
||||
title = typeMatch[2];
|
||||
}
|
||||
|
||||
// Parse changes from body (split by newlines and clean up)
|
||||
const changes = (body || '')
|
||||
.split('\n')
|
||||
.map(c => c.trim())
|
||||
.filter(c => c.startsWith('-') || c.startsWith('*'))
|
||||
.map(c => c.replace(/^[-*]\s*/, ''));
|
||||
|
||||
return {
|
||||
version: tag.replace(/^v/, ''),
|
||||
date: date ? date.split(' ')[0] : '',
|
||||
title: title || '업데이트',
|
||||
changes: changes.length > 0 ? changes : [subject || '세부 내역 없음'],
|
||||
type: type
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
const latest = history[0] || null;
|
||||
|
||||
const latestTag = stdout ? stdout.trim() : null;
|
||||
res.json({
|
||||
current: currentVersion,
|
||||
latest: latestTag,
|
||||
needsUpdate: latestTag ? (latestTag.replace(/^v/, '') !== currentVersion.replace(/^v/, '')) : false
|
||||
latest: latest ? `v${latest.version}` : null,
|
||||
needsUpdate: latest ? (latest.version !== currentVersion.replace(/^v/, '')) : false,
|
||||
latestInfo: latest,
|
||||
history: history
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,7 +16,8 @@ interface AssetBasicInfoProps {
|
||||
|
||||
export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'supervisor';
|
||||
// User role is now allowed to edit assets
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'supervisor' || user?.role === 'user';
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState(asset);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
@ -72,7 +73,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
||||
<Button size="sm" onClick={handleSave} icon={<Save size={16} />}>저장</Button>
|
||||
</>
|
||||
) : (
|
||||
isAdmin && <Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>수정</Button>
|
||||
canEdit && <Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>수정</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" icon={<Printer size={16} />}>출력</Button>
|
||||
</div>
|
||||
@ -344,7 +345,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
||||
<div className="consumables-section">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="section-title text-lg font-bold">관련 소모품 관리</h3>
|
||||
{isAdmin && <Button size="sm" variant="secondary" icon={<Plus size={14} />}>소모품 추가</Button>}
|
||||
{canEdit && <Button size="sm" variant="secondary" icon={<Plus size={14} />}>소모품 추가</Button>}
|
||||
</div>
|
||||
<table className="doc-table w-full text-center border-collapse border border-slate-300">
|
||||
<thead>
|
||||
|
||||
@ -21,7 +21,8 @@ export function AssetListPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'supervisor';
|
||||
// User role is now allowed to register assets
|
||||
const canRegister = user?.role === 'admin' || user?.role === 'supervisor' || user?.role === 'user';
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
@ -280,7 +281,7 @@ export function AssetListPage() {
|
||||
필터
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}>엑셀 다운로드</Button>
|
||||
{isAdmin && (
|
||||
{canRegister && (
|
||||
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}>자산 등록</Button>
|
||||
)}
|
||||
|
||||
|
||||
@ -58,7 +58,8 @@ export function BasicSettingsPage() {
|
||||
// Supervisor Protection States
|
||||
const [isDbConfigUnlocked, setIsDbConfigUnlocked] = useState(false);
|
||||
const [isEncryptionUnlocked, setIsEncryptionUnlocked] = useState(false);
|
||||
const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | null>(null);
|
||||
const [isRepoUnlocked, setIsRepoUnlocked] = useState(false);
|
||||
const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | 'repository' | null>(null);
|
||||
|
||||
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
||||
const [verifyPassword, setVerifyPassword] = useState('');
|
||||
@ -154,7 +155,7 @@ export function BasicSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenVerify = (target: 'database' | 'encryption') => {
|
||||
const handleOpenVerify = (target: 'database' | 'encryption' | 'repository') => {
|
||||
if (currentUser?.role !== 'supervisor') {
|
||||
alert('해당 권한이 없습니다. 최고관리자(Supervisor)만 접근 가능합니다.');
|
||||
return;
|
||||
@ -171,6 +172,7 @@ export function BasicSettingsPage() {
|
||||
if (res.data.success) {
|
||||
if (verifyingTarget === 'database') setIsDbConfigUnlocked(true);
|
||||
else if (verifyingTarget === 'encryption') setIsEncryptionUnlocked(true);
|
||||
else if (verifyingTarget === 'repository') setIsRepoUnlocked(true);
|
||||
|
||||
setShowVerifyModal(false);
|
||||
setVerifyPassword('');
|
||||
@ -370,57 +372,76 @@ export function BasicSettingsPage() {
|
||||
<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"
|
||||
/>
|
||||
{!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 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>
|
||||
{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 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 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>
|
||||
<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 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>
|
||||
<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) */}
|
||||
@ -529,7 +550,7 @@ export function BasicSettingsPage() {
|
||||
<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' ? '암호화 키' : '인프라 설정'})
|
||||
<ShieldAlert size={20} /> 최고관리자 권한 확인 ({verifyingTarget === 'encryption' ? '암호화 키' : verifyingTarget === 'repository' ? '저장소 설정' : '인프라 설정'})
|
||||
</h3>
|
||||
<p className="text-white/80 text-sm mt-1">민감 설정 접근을 위해 본인 확인이 필요합니다.</p>
|
||||
</div>
|
||||
@ -537,7 +558,9 @@ export function BasicSettingsPage() {
|
||||
<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>
|
||||
|
||||
@ -12,10 +12,20 @@ interface VersionInfo {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface UpdateEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
title: string;
|
||||
changes: string[];
|
||||
type: 'feature' | 'fix' | 'urgent' | 'patch';
|
||||
}
|
||||
|
||||
interface RemoteVersion {
|
||||
current: string;
|
||||
latest: string | null;
|
||||
needsUpdate: boolean;
|
||||
latestInfo: UpdateEntry | null;
|
||||
history: UpdateEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@ -60,7 +70,10 @@ export function VersionPage() {
|
||||
const handleUpdate = async () => {
|
||||
if (!remoteInfo?.latest) return;
|
||||
|
||||
if (!confirm(`시스템을 ${remoteInfo.latest} 버전으로 업데이트하시겠습니까?\n업데이트 중에는 시스템이 일시적으로 중단될 수 있습니다.`)) {
|
||||
const info = remoteInfo.latestInfo;
|
||||
const changelog = info ? `\n\n[주요 변경 내역]\n${info.changes.map(c => `- ${c}`).join('\n')}` : '';
|
||||
|
||||
if (!confirm(`시스템을 v${info?.version || remoteInfo.latest} 버전으로 업데이트하시겠습니까?${changelog}\n\n업데이트 중에는 시스템이 일시적으로 중단될 수 있습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -97,7 +110,7 @@ export function VersionPage() {
|
||||
};
|
||||
|
||||
// Client/Frontend version fixed at build time
|
||||
const frontendVersion = '0.3.2';
|
||||
const frontendVersion = '0.3.3';
|
||||
const buildDate = '2026-01-24';
|
||||
|
||||
// Check if update is needed based on frontend version vs remote tag
|
||||
@ -123,24 +136,49 @@ export function VersionPage() {
|
||||
|
||||
{/* Update Alert Banner - Based on Frontend Version comparison */}
|
||||
{needsUpdate && !updateResult && (
|
||||
<div className="mb-8 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 text-amber-600 rounded-lg">
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-amber-900">새로운 시스템 업데이트가 가능합니다!</h4>
|
||||
<p className="text-sm text-amber-700">현재 플랫폼 버전: v{frontendVersion} → 최신 배포 버전: <span className="font-bold">{remoteInfo?.latest}</span></p>
|
||||
<div className={`mb-8 p-0 border rounded-xl overflow-hidden animate-in fade-in slide-in-from-top-4 duration-500 shadow-lg ${remoteInfo?.latestInfo?.type === 'urgent' ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50'}`}>
|
||||
<div className="p-5 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg mt-0.5 ${remoteInfo?.latestInfo?.type === 'urgent' ? 'bg-red-100 text-red-600' : 'bg-amber-100 text-amber-600'}`}>
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className={`font-black text-lg ${remoteInfo?.latestInfo?.type === 'urgent' ? 'text-red-900' : 'text-amber-900'}`}>
|
||||
{remoteInfo?.latestInfo?.type === 'urgent' ? '🚨 긴급 보안/시스템 업데이트 발견' : '✨ 새로운 업데이트가 가능합니다'}
|
||||
</h4>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${remoteInfo?.latestInfo?.type === 'urgent' ? 'bg-red-600 text-white' : 'bg-amber-600 text-white'}`}>
|
||||
{remoteInfo?.latestInfo?.type || 'patch'}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${remoteInfo?.latestInfo?.type === 'urgent' ? 'text-red-700' : 'text-amber-700'}`}>
|
||||
현재 버전: v{frontendVersion} → 최신 버전: <span className="font-black underline underline-offset-4">{remoteInfo?.latest}</span>
|
||||
</p>
|
||||
{remoteInfo?.latestInfo && (
|
||||
<div className={`mt-3 p-3 rounded-lg border text-xs leading-relaxed ${remoteInfo?.latestInfo?.type === 'urgent' ? 'bg-white/50 border-red-100 text-red-800' : 'bg-white/50 border-amber-100 text-amber-800'}`}>
|
||||
<p className="font-bold mb-1">[{remoteInfo.latestInfo.title}]</p>
|
||||
<ul className="space-y-1 opacity-80">
|
||||
{remoteInfo.latestInfo.changes.slice(0, 3).map((c, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5">
|
||||
<span className="mt-1 w-1 h-1 rounded-full bg-current opacity-50 shrink-0"></span>
|
||||
<span>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
{remoteInfo.latestInfo.changes.length > 3 && <li>...외 {remoteInfo.latestInfo.changes.length - 3}건</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
disabled={updating}
|
||||
className={`px-6 py-3 rounded-xl font-black text-base transition-all shadow-md active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2 whitespace-nowrap min-w-[160px] ${remoteInfo?.latestInfo?.type === 'urgent' ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-amber-600 text-white hover:bg-amber-700'}`}
|
||||
>
|
||||
{updating ? <RefreshCw size={20} className="animate-spin" /> : null}
|
||||
{updating ? '업데이트 중...' : '지금 업데이트'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
disabled={updating}
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition-colors shadow-sm disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{updating ? <RefreshCw size={16} className="animate-spin" /> : null}
|
||||
{updating ? '업데이트 중...' : '지금 업데이트'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -248,106 +286,37 @@ export function VersionPage() {
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
version: '0.3.2',
|
||||
date: '2026-01-24',
|
||||
title: '일반 사용자(User) 권한 가시성 패치',
|
||||
changes: [
|
||||
'일반 사용자 계정 접속 시 사이드바 메뉴가 보이지 않던 권한 필터 오류 수정',
|
||||
'자산 관리 상세 정보에서 일반 사용자의 수정/삭제 버튼 노출 제한 (View-only)',
|
||||
'CCTV 모듈 내 일반 사용자의 제어 권한 제한 재검토'
|
||||
],
|
||||
type: 'fix'
|
||||
},
|
||||
{
|
||||
version: '0.3.1',
|
||||
date: '2026-01-24',
|
||||
title: '업데이트 가용성 및 캐시 정책 강화',
|
||||
changes: [
|
||||
'서버 사이드 No-Cache 헤더 적용으로 업데이트 후 강제 새로고침 현상 해결',
|
||||
'Vite 빌드 아티팩트 서빙 안정화 패치'
|
||||
],
|
||||
type: 'fix'
|
||||
},
|
||||
{
|
||||
version: '0.3.0',
|
||||
date: '2026-01-24',
|
||||
title: '시스템 업데이트 엔진 호환성 및 UI 개선',
|
||||
changes: [
|
||||
'Windows 환경(CMD)에서도 자동 업데이트가 작동하도록 쉘 호환성 패치',
|
||||
'업데이트 성공 시 5초 카운트다운 후 자동 페이지 새로고침 기능 추가',
|
||||
'Gitea 설정 시 브라우저 계정 자동 완성(Auto-fill) 방지 로직 적용'
|
||||
],
|
||||
type: 'fix'
|
||||
},
|
||||
{
|
||||
version: '0.2.8',
|
||||
date: '2026-01-24',
|
||||
title: 'CCTV 모듈 관리 권한 강화',
|
||||
changes: [
|
||||
'Supervisor 계정도 CCTV 추가, 설정, 삭제가 가능하도록 권한 확장',
|
||||
'카메라 드래그 앤 드롭 순서 변경 권한 보완'
|
||||
],
|
||||
type: 'fix'
|
||||
},
|
||||
{
|
||||
version: '0.2.7',
|
||||
date: '2026-01-24',
|
||||
title: '시스템 환경 정보 시각화 및 캐싱 보정',
|
||||
changes: [
|
||||
'백엔드 서비스 엔진 카드에 실제 런타임(Node.js) 및 OS 정보 표시',
|
||||
'버전 정보 조회 시 브라우저 API 캐싱 보정 로직 적용',
|
||||
'플랫폼 업데이트 판단 기준을 프론트엔드 빌드 버전으로 일원화'
|
||||
],
|
||||
type: 'feature'
|
||||
},
|
||||
{
|
||||
version: '0.2.6',
|
||||
date: '2026-01-24',
|
||||
title: '시스템 자동 업데이트 엔진 도입 (Hotfix)',
|
||||
changes: [
|
||||
'Git Tag 기반 시스템 자동 업데이트 관리 모듈 신규 도입',
|
||||
'최고관리자 전용 업데이트 실행 UI 구축'
|
||||
],
|
||||
type: 'fix'
|
||||
},
|
||||
{
|
||||
version: '0.2.1',
|
||||
date: '2026-01-22',
|
||||
title: 'IMS 서비스 코어 성능 최적화',
|
||||
changes: [
|
||||
'API 서버 헬스체크 및 실시간 상태 모니터링 연동',
|
||||
'보안 세션 타임아웃 유동적 처리 미들웨어 도입',
|
||||
'자산 관리 모듈 초기 베타 릴리즈'
|
||||
],
|
||||
type: 'fix'
|
||||
}
|
||||
].map((entry, idx) => (
|
||||
<Card key={entry.version} className={`p-6 border-slate-200 shadow-sm transition-all hover:border-indigo-200 ${idx === 0 ? 'bg-indigo-50/20 border-indigo-100 ring-2 ring-indigo-50/50' : ''}`}>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${entry.type === 'feature' ? 'bg-indigo-600 text-white' : 'bg-slate-200 text-slate-700'}`}>
|
||||
{entry.type}
|
||||
</span>
|
||||
<span className="font-bold text-slate-900 font-mono text-base">v{entry.version}</span>
|
||||
{remoteInfo?.history && remoteInfo.history.length > 0 ? (
|
||||
remoteInfo.history.map((entry, idx) => (
|
||||
<Card key={entry.version} className={`p-6 border-slate-200 shadow-sm transition-all hover:border-indigo-200 ${idx === 0 ? 'bg-indigo-50/20 border-indigo-100 ring-2 ring-indigo-50/50 shadow-indigo-100/50' : ''}`}>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${entry.type === 'feature' ? 'bg-indigo-600 text-white' : entry.type === 'urgent' ? 'bg-red-600 text-white' : 'bg-slate-200 text-slate-700'}`}>
|
||||
{entry.type}
|
||||
</span>
|
||||
<span className="font-bold text-slate-900 font-mono text-base">v{entry.version}</span>
|
||||
</div>
|
||||
<div className="hidden md:block w-px h-4 bg-slate-200 mx-2"></div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-bold text-slate-800">{entry.title}</h4>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-medium px-2 py-1 bg-slate-50 rounded italic">{entry.date}</div>
|
||||
</div>
|
||||
<div className="hidden md:block w-px h-4 bg-slate-200 mx-2"></div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-bold text-slate-800">{entry.title}</h4>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-medium px-2 py-1 bg-slate-50 rounded italic">{entry.date}</div>
|
||||
</div>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
|
||||
{entry.changes.map((change, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-[13px] text-slate-600 leading-relaxed">
|
||||
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500/50 flex-shrink-0 animate-pulse"></div>
|
||||
<span>{change}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
|
||||
{entry.changes.map((change, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-[13px] text-slate-600 leading-relaxed">
|
||||
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500/50 flex-shrink-0 animate-pulse"></div>
|
||||
<span>{change}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400 italic bg-slate-50 rounded-xl border border-dashed border-slate-200">
|
||||
원격 저장소에서 업데이트 내역을 가져오는 중이거나 내역이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -114,7 +114,14 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
const groupedRoutes: Record<string, typeof mod.routes> = {};
|
||||
const ungroupedRoutes: typeof mod.routes = [];
|
||||
|
||||
mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).forEach(route => {
|
||||
mod.routes.filter(r => {
|
||||
const isLabelVisible = !!r.label;
|
||||
const isSidebarPosition = !r.position || r.position === 'sidebar';
|
||||
const isSettingsRoute = r.path.includes('settings');
|
||||
const hasPermission = user?.role !== 'user' || !isSettingsRoute;
|
||||
|
||||
return isLabelVisible && isSidebarPosition && hasPermission;
|
||||
}).forEach(route => {
|
||||
if (route.group) {
|
||||
if (!groupedRoutes[route.group]) groupedRoutes[route.group] = [];
|
||||
groupedRoutes[route.group].push(route);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user