From 107c46191fd67c2be331ef42ae68d40538bee2bb Mon Sep 17 00:00:00 2001 From: choibk Date: Sat, 24 Jan 2026 23:37:21 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=A6=88=20v0.3.4:=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EA=B3=84=EC=B8=B5=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=ED=83=9C=EA=B7=B8=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EB=8F=99=EC=A0=81=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- server/package.json | 2 +- server/routes/system.js | 64 ++++-- .../asset/components/AssetBasicInfo.tsx | 7 +- src/modules/asset/pages/AssetListPage.tsx | 5 +- src/platform/pages/BasicSettingsPage.tsx | 121 ++++++----- src/platform/pages/VersionPage.tsx | 203 ++++++++---------- src/widgets/layout/MainLayout.tsx | 9 +- 8 files changed, 221 insertions(+), 192 deletions(-) diff --git a/package.json b/package.json index 2c37f05..ecd6613 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "smartims", "private": true, - "version": "0.3.2", + "version": "0.3.3", "type": "module", "scripts": { "dev": "vite", diff --git a/server/package.json b/server/package.json index b794fd6..984c141 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.3.2", + "version": "0.3.3", "description": "", "main": "index.js", "scripts": { diff --git a/server/routes/system.js b/server/routes/system.js index 5b41cf3..8640a53 100644 --- a/server/routes/system.js +++ b/server/routes/system.js @@ -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 }); }); }); diff --git a/src/modules/asset/components/AssetBasicInfo.tsx b/src/modules/asset/components/AssetBasicInfo.tsx index c7b06ca..a0a5882 100644 --- a/src/modules/asset/components/AssetBasicInfo.tsx +++ b/src/modules/asset/components/AssetBasicInfo.tsx @@ -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) { ) : ( - isAdmin && + canEdit && )} @@ -344,7 +345,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {

관련 소모품 관리

- {isAdmin && } + {canEdit && }
diff --git a/src/modules/asset/pages/AssetListPage.tsx b/src/modules/asset/pages/AssetListPage.tsx index 3ec0e8b..2f850d5 100644 --- a/src/modules/asset/pages/AssetListPage.tsx +++ b/src/modules/asset/pages/AssetListPage.tsx @@ -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([]); @@ -280,7 +281,7 @@ export function AssetListPage() { 필터 - {isAdmin && ( + {canRegister && ( )} diff --git a/src/platform/pages/BasicSettingsPage.tsx b/src/platform/pages/BasicSettingsPage.tsx index 9f1afca..0462313 100644 --- a/src/platform/pages/BasicSettingsPage.tsx +++ b/src/platform/pages/BasicSettingsPage.tsx @@ -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() { 시스템 업데이트 저장소 설정 (Gitea) - -
-

원격 저장소(Gitea)에서 최신 업데이트 태그를 가져오기 위한 주소 및 인증 정보를 설정합니다.

-
-
- - setSettings({ ...settings, gitea_url: e.target.value })} - placeholder="https://gitea.example.com/org/repo.git" - /> + {!isRepoUnlocked && ( + + )} + {isRepoUnlocked && ( +
+ 검증 완료: 최고관리자 모드
-
-
- - setSettings({ ...settings, gitea_user: e.target.value })} - placeholder="gitea_update_user" - /> + )} +
+ {isRepoUnlocked ? ( +
+
+

원격 저장소(Gitea)에서 최신 업데이트 태그를 가져오기 위한 주소 및 인증 정보를 설정합니다.

+
+
+ + setSettings({ ...settings, gitea_url: e.target.value })} + placeholder="https://gitea.example.com/org/repo.git" + /> +
+
+
+ + setSettings({ ...settings, gitea_user: e.target.value })} + placeholder="gitea_update_user" + /> +
+
+ + setSettings({ ...settings, gitea_password: e.target.value })} + placeholder="••••••••" + /> +
+
-
- - setSettings({ ...settings, gitea_password: e.target.value })} - placeholder="••••••••" - /> +
+
+
+ {saveResults.repository && ( +
+ {saveResults.repository.success ? : } + {saveResults.repository.message} +
+ )} +
+
+ +
-
-
-
- {saveResults.repository && ( -
- {saveResults.repository.success ? : } - {saveResults.repository.message} -
- )} + ) : ( +
+ +

보안을 위해 저장소 설정은 최고관리자 인증 후 접근 가능합니다.

-
- - -
-
+ )} {/* Section 3: Database Infrastructure (Supervisor Protected) */} @@ -529,7 +550,7 @@ export function BasicSettingsPage() {

- 최고관리자 권한 확인 ({verifyingTarget === 'encryption' ? '암호화 키' : '인프라 설정'}) + 최고관리자 권한 확인 ({verifyingTarget === 'encryption' ? '암호화 키' : verifyingTarget === 'repository' ? '저장소 설정' : '인프라 설정'})

민감 설정 접근을 위해 본인 확인이 필요합니다.

@@ -537,7 +558,9 @@ export function BasicSettingsPage() {
{verifyingTarget === 'encryption' ? "※ 경고: 암호화 키 변경은 시스템 내 모든 민감 데이터를 다시 암호화하는 중대한 작업입니다. 성공 시 기존 데이터를 새 키로 대체하며, 실패 시 데이터 유실의 위험이 있습니다." - : "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."} + : verifyingTarget === 'repository' + ? "※ 주의: 시스템 업데이트 저장소 정보 노출은 원본 소스 코드 유출로 이어질 수 있습니다." + : "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."}
diff --git a/src/platform/pages/VersionPage.tsx b/src/platform/pages/VersionPage.tsx index efb89ed..d41bf83 100644 --- a/src/platform/pages/VersionPage.tsx +++ b/src/platform/pages/VersionPage.tsx @@ -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 && ( -
-
-
- -
-
-

새로운 시스템 업데이트가 가능합니다!

-

현재 플랫폼 버전: v{frontendVersion} → 최신 배포 버전: {remoteInfo?.latest}

+
+
+
+
+ +
+
+
+

+ {remoteInfo?.latestInfo?.type === 'urgent' ? '🚨 긴급 보안/시스템 업데이트 발견' : '✨ 새로운 업데이트가 가능합니다'} +

+ + {remoteInfo?.latestInfo?.type || 'patch'} + +
+

+ 현재 버전: v{frontendVersion} → 최신 버전: {remoteInfo?.latest} +

+ {remoteInfo?.latestInfo && ( +
+

[{remoteInfo.latestInfo.title}]

+
    + {remoteInfo.latestInfo.changes.slice(0, 3).map((c, i) => ( +
  • + + {c} +
  • + ))} + {remoteInfo.latestInfo.changes.length > 3 &&
  • ...외 {remoteInfo.latestInfo.changes.length - 3}건
  • } +
+
+ )} +
+
-
)} @@ -248,106 +286,37 @@ export function VersionPage() {
- {[ - { - 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) => ( - -
-
- - {entry.type} - - v{entry.version} + {remoteInfo?.history && remoteInfo.history.length > 0 ? ( + remoteInfo.history.map((entry, idx) => ( + +
+
+ + {entry.type} + + v{entry.version} +
+
+
+

{entry.title}

+
+
{entry.date}
-
-
-

{entry.title}

-
-
{entry.date}
-
-
    - {entry.changes.map((change, i) => ( -
  • -
    - {change} -
  • - ))} -
- - ))} +
    + {entry.changes.map((change, i) => ( +
  • +
    + {change} +
  • + ))} +
+ + )) + ) : ( +
+ 원격 저장소에서 업데이트 내역을 가져오는 중이거나 내역이 없습니다. +
+ )}
diff --git a/src/widgets/layout/MainLayout.tsx b/src/widgets/layout/MainLayout.tsx index 8e73e66..9f323de 100644 --- a/src/widgets/layout/MainLayout.tsx +++ b/src/widgets/layout/MainLayout.tsx @@ -114,7 +114,14 @@ export function MainLayout({ modulesList }: MainLayoutProps) { const groupedRoutes: Record = {}; 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);