릴리즈 v0.3.4: 권한 계층 최적화 및 태그 기반 동적 업데이트 엔진 도입

This commit is contained in:
choibk 2026-01-24 23:37:21 +09:00
parent c6ab665685
commit 107c46191f
8 changed files with 221 additions and 192 deletions

View File

@ -1,7 +1,7 @@
{
"name": "smartims",
"private": true,
"version": "0.3.2",
"version": "0.3.3",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.3.2",
"version": "0.3.3",
"description": "",
"main": "index.js",
"scripts": {

View File

@ -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
});
});
});

View File

@ -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>

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>

View File

@ -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);