From 8b2589b6fa0715395e3c86abaef081e58e74fdeb Mon Sep 17 00:00:00 2001 From: choibk Date: Sat, 24 Jan 2026 17:17:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=EA=B0=95=ED=99=94,=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20=EB=B2=84=EC=A0=84=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20(v0.2.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용 - 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축 - 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화 - 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5) - 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화 --- auth_head.js | Bin 0 -> 17580 bytes auth_v0.js | Bin 0 -> 17588 bytes auth_v1.js | Bin 0 -> 17588 bytes docs/DIRECTORY_STRUCTURE.md | 71 +++ docs/ENVIRONMENT_SETUP.md | 45 ++ history.txt | Bin 0 -> 21502 bytes package.json | 2 +- server/fix_admin.js | 36 ++ server/index.js | 53 +- server/middleware/authMiddleware.js | 22 +- server/middleware/csrfMiddleware.js | 1 + server/package.json | 4 +- server/routes/auth.js | 134 ++--- server/routes/system.js | 204 ++++++- server/utils/cryptoUtil.js | 111 ++++ src/modules/asset/pages/AssetListPage.css | 19 +- src/modules/asset/pages/AssetListPage.tsx | 39 +- src/modules/asset/pages/AssetRegisterPage.css | 43 ++ src/modules/asset/pages/AssetRegisterPage.tsx | 375 +++++++------ src/modules/asset/pages/AssetSettingsPage.css | 94 ++-- src/modules/asset/pages/AssetSettingsPage.tsx | 236 ++++++-- src/pages/auth/LoginPage.css | 44 +- src/pages/auth/LoginPage.tsx | 7 + src/platform/App.tsx | 2 + src/platform/pages/BasicSettingsPage.tsx | 525 +++++++++++++++--- src/platform/pages/UserManagementPage.tsx | 157 +++--- src/platform/pages/VersionPage.tsx | 207 +++++++ src/platform/styles/global.css | 1 + src/shared/api/assetApi.ts | 10 +- src/shared/api/client.ts | 15 + src/shared/auth/AuthContext.tsx | 71 ++- src/widgets/layout/MainLayout.css | 32 +- src/widgets/layout/MainLayout.tsx | 52 +- temp_auth.js | Bin 0 -> 17580 bytes 34 files changed, 2042 insertions(+), 570 deletions(-) create mode 100644 auth_head.js create mode 100644 auth_v0.js create mode 100644 auth_v1.js create mode 100644 docs/DIRECTORY_STRUCTURE.md create mode 100644 docs/ENVIRONMENT_SETUP.md create mode 100644 history.txt create mode 100644 server/fix_admin.js create mode 100644 server/utils/cryptoUtil.js create mode 100644 src/modules/asset/pages/AssetRegisterPage.css create mode 100644 src/platform/pages/VersionPage.tsx create mode 100644 temp_auth.js diff --git a/auth_head.js b/auth_head.js new file mode 100644 index 0000000000000000000000000000000000000000..49acaff6df5d8e7af49420b827e57d8d8e152bdb GIT binary patch literal 17580 zcmeI4`)^do702i2O8p-esggHMvJTHSL@gpV1S#LVQhwtRvZRl5at6T>gx}+ z?mN+PtX10R$j8*7yw}_vapG>I<&~(}7sMS=x*loU3}0x(E8UGEJ-Zsw3m;lnG_3ao zVN)MQpK2}~p4SzAy$U~6-nL`+bHoWQ8%C?5_t*IBym$;n%Td^#f(P=R*QXc0jGtau zC^)_r`Lruq;1~MjBh$<^J%wNE`rFp1x4PTZckq7~pB)HhPni4|o=D!kuq6KXqJ|!5 zYjyQk4+4;vBI^>9sa zHZ&J~2Ih8L2h6$uQtxkuTe=Q4YM4Qs3wxUNQe#epHB#a`_<-fH?u^!#8n-TN)&uf< zsVnx!h&iohbQl)HsBK4jdq-~)FVQ7NgB{pAUq_m~r?q(xFW2=Q3qQ~|V8JOY1YUrp zagWja!UZn&^d7Fm5p?x9(!x{VZR^>tJ{u9V?CESx{EbHG@(e=Hq&om3aVpt5d z612KB1{(1w&Uhx7uS(*Nb)RPb7Jk!Q=!9cn1AQZnsPpx!a8=T|9&SVl{Vn`Ge688v z2!_eL$<>}{-O^_>{w4k{#V1B9x|#EMLwDQy7>Boo)q!xrFW~dgOh({e;o*@mhPN(P z>~(#tm)m;U#AuL7`Jo`Xln*rvYr(?mQZ$KeXoT4zc<%^LllGf*c=O zboASTN+g*3&w=Kr`xVi%uls(e$sdj&LAVJu+(Wg;5xAbOe>Xe~-$@Uar46g$f&77K z(3~LD><{?@gTHNI4JPg`r%43#w-_0!76;Vt>`mz z?04j24Bxcsf%F}Zy)Ox(E8m{^mcI1$))^;i@P@+cFzOceHz@~J*%E9pTgb+OM_-Yw z$xFCDDPf*6!)4-_n4f8K(fqdIlCT?y_lKg;a7mmt`C}2=vPYBu+}K)Dr1KT=M2T~@ z(&uBk^Rv<`ogs7SoH zOszAIwh%cvluTWN%|q;pN_-6#k3T@>cyTnjE>DYc_*pzme=MOTMdRD!?+cnwMg&K& zAS}-1HfcSBD50TNKZ)LS6eR^;KhvjaeGaFTSh6?w;CDq4k!vr~L!?`e>m=KBKI{uy z%LnkCmN5~nYOKZ=@p6!l{<%Ft_U ztHG=qAlo!&4HLWhy^_hmt$BC%|&X`Sx*sZziz*JQKO zWUG^zqSf`UREJnSb}IWglBej)iHR|e88T<9sX?ORjuDsws8&#}p=dmg!YDqg&Zyr4Fx8)VrUovlIayTR*sQ9r1q9_nvF zRZuVdRe$$Y6FrXgkkzBa+g^C8Y~fz4xzT?j8y=s2!ClqTi@Hv7(?*!&p2u0(sZ(6z z$qUKQsLZX(DfRe{#z12;k|X)AI=qj=$vNgU8a)pAx!}BLNZn`q)iiodbbB>I4vdD< zgGriOqr_oMlg^s7W>Fko74N-2VDdn2NUXM^(eg-Ad@31Z6>ZXU**F)-rZ+`-mlPa# zqaRnt0QNZ{fc;IGWHdT$X*X69(B=TKAc)3 z=PFT(C*F(4E3^t-mJ(=!ckvapje z*6tsmVA4`%!LgkRDjcGdX@9q-IPRq^Zb%YWFG&{7{(|NG_;uSw#2!bISr^Tv9H6gR z!6DyvyXe;cl-=i=GMmQN`FGj6d=x#Obtt)Y~IJ(b(_@i^jILigbLnbad-aXR^{2ow0vh4 z|L)vKxxj25-X#uw|IHkxl%e_ckSRJV)CJTG~UdX{G#!i&@0ajmV}jMs?ClEKU( z$+fY~SfuEMHSq#1p|YhTzo{J4YT(DBj%s9DbaYQeUPFf0ZS#z<9@Da@Kt9NWa}BmG z5@5{vdWmqcedo?NfSl0^&af~xMfc>5ohJ6|4NK_a9EEi&pa405bMssm*n4$F^gY$R z^^V!GbWK7x(MIw@`b5@A9qPNClA+_8pJ8A}nz`1AwOw!Pz>*#A>Mb~1XT7F}?loBL zqJ2nchDxxYBH=qcr60uk9G-zG&&}tin$n(mKZHByFyCwaGM?xC^G)^dsHAwFpEmI4 zZ9P&>lPtzAX-~VI&sw1N;+PeAjI`@Eji0V9*brS+D6$SH9^_COeiGkM15@YK;JNSO zydZgDcid0iG2%VEPx}q5MyK9+Oa^9}zgUdRdu&N#lO^_)hohmVRebR+?q_QBO!~dV zC7fC*jB^j@snD6F>2nrx-eKY*>f6Ek5ug$%=PGr7o;tIb|-mb zIEogv?Ytyj{{B8U_bgreH`wdtdJLkUTb$J+<&IFUm!4%^kxt{e?cBkY=(DKEe5WtH zaa`Lg7$i3>OQO3_>+~eO!Gh=1;U;eSi9C-)eg+$i*(s*ev8hpAx|UNtOFYagWaHA3 z^t`MmbC_@XHxSKm$UClHjv_z z?JRn9vh~Ul?5_HIj}_Bs8mYgsD_)^~9lUPF#U4LE-$$C19@j&b^rm)(4Ne=sepaf#>g*DYWv<~I01^ns4E)nwFZ zXg*%e^5Wc|&XOhE7rbL>Grtb8-`22#Rj;srjj4W}jt!`%+%5m@l zi9c^W6XUaTU1-j!t>!sXQ|H?=V&{Kem!o%w(YEc(TN9dLaz`GZ&9^z!9RD2V$7CyQ zYt(lLoCDKq0mre6a))wpiXGDVsHyd8diu?aowVlHGmNm4so3VlYf#U} zjb?}H({W;QrHgW*3Guuf@ND~2(w^41DAi?#SXK+?JjcPWp3P5}foIlbPSD&p%khOb z|9e%5od#Kyj#@TE-sQZ5J7rZubn$V2+}dQ8y#0wNvz*s*^+cKPMERZz8MW`iapv~1 zvNk^nNJpwG)1JNx75c7niaRl1&-KxDzT1B2z1vxMJ8gZw9wEG_S4Hie!}Hi;;xhTu f|GNXon{(*=4-VMXK!2Qjr7lXpn#^s`s?`4goN5_i literal 0 HcmV?d00001 diff --git a/auth_v0.js b/auth_v0.js new file mode 100644 index 0000000000000000000000000000000000000000..b87dd46e6803123059daa2bcca8dee9f8660923f GIT binary patch literal 17588 zcmeI4`EL|Q7RT%7mG*xaq?J6`B;#=IhG-WN8-f)A$VU)Di1-XneBlFw2><%-en0i* zxvILWdj{KVBJFBqdb+#n)w{2%;otvS5Bp&+9EIaB2%F(;I0%Qj9_cd(|Ii`_?EMhvSkHo{sb4x=y%vw;!y_1CrT zJJE8iRl4ZN$JC*`*W7J!;%=nnm8jVh#BEW!8fn`IUueWD-5o`Gb~IuTK6I{VSnmqL zhCYlw)m%6{t1J9^6@I9^?Z)osh!b2kjFv_3ukqP=@mLovhhb|19>{xEpF#LCegTW~d!T()+wlA0iVe(^mB6$zOg7`m(8oIBM zCz1ou(O=|%4l@Ioa1zP!bW`I_;`5ydcRNBE>G@uG6E)W;`F_0au11?ZtVLMY!!^NK z(_Hi!m|JljFz5bDy}uc5>AJ2_>lwtEu&Y@wHReQEBPG6r4_F@S&S-t9ajU{+H6YKI zx?+Efn9*uRhhecEwe3)E@90h9CA!3DumgMN>yc*fYHi-b%T;~H!uRzJSa1ppffrzD z++*~faDj^hy@%^?1YJFjwD1&oTY9#m&sv1{Ys(KTg?rMBMg6-hy1v)-nP%Zv42yNG z1g$QOkw!dR30o^t6f5D3kO>YH}MXg!6zXH!^_so{kiGs zw*{3*G54V(%}@7BqGwO{!%))!ID!=6Ce&~b)gDXWdcOYM@GyKQU09SxEQbg338qCe zf>5(TY`wuCj9xUVqU6Q)DGK}yG3eGuNqIRky;W4s!ME5#VIUaSSn@Y%Ma&&;vk zmY*?v)2jQ@dA#k3=WM0V z$8_gw5k>H#JEF21S}8p!90`wo>1ndea{eHEE?&%p+xoNbZH!F%A9}P7f0C$3ytquQ zGmkbGIk_&Gx(1tv*b$ZZ8Y~`vfXwmaXmVYi7U}S{c$nc>LJNw>x5wY-G@q;pj$lDp zoXf3kJ)bRc7%A1K$rd&QnQFysUF6z=e6Bd==xfwIN@vA5$5o(Zi)7e?a3{@J>-;;xT0 zdBY*A0>~ZU$LpvEd?PC0CH|>@%ed-Vho7TKwf49Erqqj>N z-EB(TJerSazOCs)X?c!KPTlj!%~SE>MdStbIUOO(*641HV($%Jzl-`oE%i`;bE<*{ z;jj9;ubSv_tcR=~CEgCgQ)LVHV%?4Y6WQ?i^bGE*o}SmW%}pC&l6xL!VW&=UjVCW8 zL!&acDyP)rI~oIx%}9>qzv}Tm4kzcB(`fWKCWAe-J0<$Y3c+>L&G z<*wxMMpw&&>HDB>L|~p7cUB8O=`j3Cl(87gjD$^7r@D2+AsJRn(lWftPSn)XF&=rf zguXI{&J0@ogCzG(_QCq(^!SRrYm+itdSTYt@IIk;VvGXonU!&K5PpeN0hMgXVy1P~ zR+J;AwXij~*QYgZ2RWad7NlK!ZaPt7HLQ-DUiM4knaS8!BqDn~lz7xfi}`SBjhw4Q zDV}&Y!tbV)|96^;^t_8rH+f0k>%O?NEDWhr_j-Ap`v~t~rIJeAY=M8NFwu3>*P*_%4`TTll}_G^ zT-98YoA

as!s}l>}>h@!Ev6%P0y3m~Ox>3(! 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

as!s}l>}>h@!Ev6%P0y3m~Ox>3(! zmmMNEb<_o1t? zu8&#UZQ*AfcVtU+E;DG}&&YCe+a%NWtex)FhqGwWg|9!iywUHvu1?P+yvf2&##p<5 zfP%KAOoL-L6;wDxC)56ZO>x{yS=^8$u3nNXn*9aK`|<0xlZZVYNoIXCmvVr+)greAc1t*JR^V(hVY6s$_1;x`-^)N7fr{TFOwlF?+Js z#VjiwqKDO2vzqNNEF+t~wsBtu=UxjRv^0716i2(IY4qHS{@wTMQnVzVTeL))Z{^j{ z?W5=`Y;Dh$n$ghq`SAOA>5W)Xro5-@>$)#+YOYBC>h!d&yPGnHIz6oO$Mw>t@|CGm za$nY$4Z+>+vjKSHIqK=AWQiODGMQ%fu*KhZXus+kGs6a-@gtHB{E)rnO z`TB`)v3vKDJHbwa4k)0;??F~!l;w*)AET8~6fOGSF7ub7+MFc+8z4edT zwRBBFKha2XLwZHlOZ&Q=XgOO?9O`^PUKI&SAdSdS*P&d*~bS)RaNbPaOF3u0AOz zO6Fsybf6v2XB|)nam3V(6j>J(4{{(4KZ$RsgQ@ju@Z5iKZjjut zKlY~%88IK;r#%N&qg(GhCJQsopD)JceYT{r$r1<3#nI5yD!%v@_cJwm+I}x_$>%xE zms{s}EUj1Wj#pU+cGWR?45i+lmprNEIRj@^XBttDR^3_5_2`M`q%D4aCwXHyiWYV4 zz9e4${ysPNEPZ=8*zx6h45FV~oYf@du28O*o@rf?PUE@l?7@}jv#82^w=cbMT)V3n zBsVEjqQg+@_9VT*f@jp_CT{wvJdZ?v78{J&EvDPCp;3LhmQy`VJj|QjcXvyh7)Gszxm5(SoW(v~Dr((s8$}{kXI@W0|n$IL&>o%lBgS zev4!KqjK_x(h$yUkv-grS=FR}>o9-n*}?6%Q{yhQPq$lq5iXUwP-+WsJzsQBJ)Qet>2z3waiHCR!MZay#B3447Uf@55LxF`#tiS8!@jbci)|F zSaCD2A;+NxoIR7{k-p!Kar%#!-F$j~Fe$5XiRbaxEnp$$HuymFfv&UFWYlSBK3>i8 z;@qE3lO^02ykluIzY?+E*sy|CueP&HZ-g3AxR!!)jbQE9R3=Vm$P{QPv~_vM9MDnsQ27W0z^sUHA_oJC_~iEC|jJI7FtgBM8rdFQzp zpOx!Eb53nF&zYJ!-<=UV|8t(BcL$=7^KRIA6PjUiM{l}(n?ud<&tZN{w$immeV4#F zFu4|R9Q!DDC>N*LA)Sw!TCZlH-@VvrYkon)2s@pMZC*^?_0`bWi^#kN^?clDcBnob zC)z7rlnYIW=jDKB+n4(mH~kPXApOo0 zOGgTJCX-0s?qlRTqxC(BM~d{;Kj*h?>`gQ~6EzI;yrrz#CZDPNi@K*x%JSbaP#+LS zKIqzYveK&{_t<{ziY|F>2funYKUoHzS(iCMbMGw27vB8uRV8*J zWL`RI*${b`^A7HmRSD6>$Nh0@lWFqyC!)-9Udz=JWxf;TdoE+sDe({4^jP zslH5m@+ws5JIX2U#C$#1N7wm&`=R%4=i%+d_4#^)@S [모듈/라이선스 관리] 메뉴에서 '자산 관리' 모듈이 활성화 상태인지 확인하세요. +3. **구독자 ID 일치**: 라이선스 키 등록 시 사용된 구독자 ID(`SKR-2024-...`)가 [기본 설정]의 구독자 ID와 일치해야 합니다. +4. **브라우저 캐시**: 프론트엔드 빌드 후 변경 사항이 반영되지 않으면 강력 새로고침(`Ctrl + F5`)을 수행하세요. diff --git a/history.txt b/history.txt new file mode 100644 index 0000000000000000000000000000000000000000..cc1234b2eec187eb179945b76b656035cd38df7d GIT binary patch literal 21502 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 0) { + console.log('✅ Admin user updated to Supervisor with password admin123'); + } else { + console.log('⚠️ Admin user not found. Creating new admin...'); + await db.query( + 'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)', + ['admin', hashedPass, '시스템 관리자', 'supervisor', 'IT팀', '관리자'] + ); + console.log('✅ Admin user created as Supervisor with password admin123'); + } + } catch (err) { + console.error('❌ Failed to fix admin user:', err); + } finally { + process.exit(); + } +} + +fixAdmin(); diff --git a/server/index.js b/server/index.js index e3ac82a..b790aef 100644 --- a/server/index.js +++ b/server/index.js @@ -66,12 +66,13 @@ app.use(session({ key: 'smartims_sid', secret: process.env.SESSION_SECRET || 'smartims_session_secret_key', store: sessionStore, - resave: true, // Force save to avoid session loss in some environments + resave: false, saveUninitialized: false, + rolling: false, // Do not automatic rolling (we control it in middleware) cookie: { httpOnly: true, - secure: false, // Set true if using HTTPS - maxAge: null, // Browser session by default + secure: false, // HTTPS 사용 시 true로 변경 필요 + maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정) sameSite: 'lax' } })); @@ -79,13 +80,24 @@ app.use(session({ // Dynamic Session Timeout Middleware app.use(async (req, res, next) => { if (req.session && req.session.user) { + // Skip session extension for background check requests + // These requests are prefixed by /api from the client but might be handled differently in middleware + // Checking both common forms for safety + if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) { + return next(); + } + try { const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60; req.session.cookie.maxAge = timeoutMinutes * 60 * 1000; - // Explicitly save session to ensure store sync - req.session.save(); + // Explicitly save session before moving to next middleware + req.session.save((err) => { + if (err) console.error('Session save error:', err); + next(); + }); + return; } catch (err) { console.error('Session timeout fetch error:', err); } @@ -98,7 +110,10 @@ app.use(csrfProtection); // Request Logger app.use((req, res, next) => { - console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); + const now = new Date(); + // UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성 + const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', ''); + console.log(`[${kstDate}] ${req.method} ${req.url}`); next(); }); @@ -180,14 +195,21 @@ const initTables = async () => { department VARCHAR(100), position VARCHAR(100), phone VARCHAR(255), - role ENUM('admin', 'user') DEFAULT 'user', + role ENUM('supervisor', 'admin', 'user') DEFAULT 'user', last_login TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(usersTableSQL); - console.log('✅ Users Table Created'); + + // Update existing table if needed + try { + await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'"); + } catch (e) { + // Ignore if it fails (e.g. column doesn't exist yet handled by SQL above) + } + console.log('✅ Users Table Initialized with Supervisor role'); // Default Admin const adminId = 'admin'; @@ -197,9 +219,12 @@ const initTables = async () => { const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex'); await db.query( 'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)', - [adminId, hashedPass, '시스템 관리자', 'admin', 'IT팀', '관리자'] + [adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자'] ); - console.log('✅ Default Admin Created (admin / admin123)'); + console.log('✅ Default Admin Created as Supervisor'); + } else { + // Ensure existing admin has supervisor role for this transition + await db.query('UPDATE users SET role = "supervisor" WHERE id = ?', [adminId]); } } @@ -333,8 +358,14 @@ const initTables = async () => { }; initTables(); +const packageJson = require('./package.json'); + app.get('/api/health', (req, res) => { - res.json({ status: 'ok', version: '1.2.0', timestamp: '2026-01-22 21:18' }); + res.json({ + status: 'ok', + version: packageJson.version, + timestamp: new Date().toISOString().replace('T', ' ').split('.')[0] + }); }); // Routes diff --git a/server/middleware/authMiddleware.js b/server/middleware/authMiddleware.js index 98c1436..779c396 100644 --- a/server/middleware/authMiddleware.js +++ b/server/middleware/authMiddleware.js @@ -1,3 +1,15 @@ +const ROLES = { + SUPERVISOR: 'supervisor', + ADMIN: 'admin', + USER: 'user' +}; + +const HIERARCHY = { + [ROLES.SUPERVISOR]: 100, + [ROLES.ADMIN]: 50, + [ROLES.USER]: 10 +}; + const isAuthenticated = (req, res, next) => { if (req.session && req.session.user) { return next(); @@ -5,13 +17,17 @@ const isAuthenticated = (req, res, next) => { return res.status(401).json({ success: false, message: 'Unauthorized' }); }; -const hasRole = (...roles) => { +const hasRole = (requiredRole) => { return (req, res, next) => { if (!req.session || !req.session.user) { return res.status(401).json({ success: false, message: 'Unauthorized' }); } - if (roles.includes(req.session.user.role)) { + const userRole = req.session.user.role; + const userLevel = HIERARCHY[userRole] || 0; + const requiredLevel = HIERARCHY[requiredRole] || 999; + + if (userLevel >= requiredLevel) { return next(); } @@ -19,4 +35,4 @@ const hasRole = (...roles) => { }; }; -module.exports = { isAuthenticated, hasRole }; +module.exports = { isAuthenticated, hasRole, ROLES }; diff --git a/server/middleware/csrfMiddleware.js b/server/middleware/csrfMiddleware.js index 785efeb..550e9e9 100644 --- a/server/middleware/csrfMiddleware.js +++ b/server/middleware/csrfMiddleware.js @@ -28,6 +28,7 @@ const csrfProtection = (req, res, next) => { console.error(`- Path: ${req.path}`); console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`); console.error(`- Session User: ${req.session?.user?.id || 'GUEST'}`); + console.error(`- Session MaxAge: ${req.session?.cookie?.maxAge / 1000 / 60} min`); console.error(`- Token in Session: ${tokenFromSession ? 'EXISTS (' + tokenFromSession.substring(0, 5) + '...)' : 'MISSING'}`); console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`); diff --git a/server/package.json b/server/package.json index bb05b90..af2e6c1 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.1.0", + "version": "0.2.5", "description": "", "main": "index.js", "scripts": { @@ -28,4 +28,4 @@ "devDependencies": { "nodemon": "^3.1.11" } -} +} \ No newline at end of file diff --git a/server/routes/auth.js b/server/routes/auth.js index e7be500..402da7d 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -5,54 +5,14 @@ const crypto = require('crypto'); const { isAuthenticated, hasRole } = require('../middleware/authMiddleware'); const { generateToken } = require('../middleware/csrfMiddleware'); -// --- Crypto Utilities --- -// Use a fixed key for MVP. In production, store this securely in .env -// Key must be 32 bytes for aes-256-cbc -// 'my_super_secret_key_manage_asset' is 32 chars? -// let's use a simpler approach to ensure length on startup or fallback -const SECRET_KEY = process.env.ENCRYPTION_KEY || 'smartims_secret_key_0123456789'; // 32 chars needed -// Ideally use a buffer from hex, but string is okay if 32 chars. -// Let's pad it to ensure stability if env is missing. -const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32); +const cryptoUtil = require('../utils/cryptoUtil'); -const ALGORITHM = 'aes-256-cbc'; - -function encrypt(text) { - if (!text) return text; - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - return iv.toString('hex') + ':' + encrypted; +async function encrypt(text) { + return await cryptoUtil.encrypt(text); } -function decrypt(text) { - if (!text) return text; - // Check if it looks like our encrypted format (hexIV:hexContent) - if (!text.includes(':')) { - return text; // Assume plain text if no separator - } - - try { - const textParts = text.split(':'); - const ivHex = textParts.shift(); - - // IV for AES-256-CBC must be 16 bytes (32 hex characters) - if (!ivHex || ivHex.length !== 32) { - return text; // Invalid IV length, return original - } - - const iv = Buffer.from(ivHex, 'hex'); - const encryptedText = textParts.join(':'); - - const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); - let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; - } catch (e) { - console.error('Decryption failed for:', text, e.message); - return text; // Return original if fail - } +async function decrypt(text) { + return await cryptoUtil.decrypt(text); } function hashPassword(password) { @@ -77,7 +37,7 @@ router.post('/login', async (req, res) => { delete user.password; // Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it - if (user.phone) user.phone = decrypt(user.phone); + if (user.phone) user.phone = await decrypt(user.phone); // Save user to session req.session.user = user; @@ -104,19 +64,30 @@ router.post('/login', async (req, res) => { }); // 1.5. Check Session (New) -router.get('/check', (req, res) => { - if (req.session.user) { - // Ensure CSRF token exists, if not generate one (edge case) - if (!req.session.csrfToken) { - req.session.csrfToken = generateToken(); +router.get('/check', async (req, res) => { + try { + if (req.session.user) { + // Ensure CSRF token exists, if not generate one (edge case) + if (!req.session.csrfToken) { + req.session.csrfToken = generateToken(); + } + + // Fetch session timeout from settings + const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); + const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60; + + res.json({ + isAuthenticated: true, + user: req.session.user, + csrfToken: req.session.csrfToken, + sessionTimeout: timeoutMinutes * 60 * 1000 // Convert to ms + }); + } else { + res.json({ isAuthenticated: false }); } - res.json({ - isAuthenticated: true, - user: req.session.user, - csrfToken: req.session.csrfToken - }); - } else { - res.json({ isAuthenticated: false }); + } catch (err) { + console.error('Check session error:', err); + res.status(500).json({ success: false, message: 'Server error' }); } }); @@ -132,21 +103,52 @@ router.post('/logout', (req, res) => { }); }); +// 1.7 Verify Supervisor (For sensitive settings) +router.post('/verify-supervisor', isAuthenticated, async (req, res) => { + const { password } = req.body; + if (!password) return res.status(400).json({ error: 'Password required' }); + + try { + if (req.session.user.role !== 'supervisor') { + return res.status(403).json({ error: '권한이 없습니다. 최고관리자만 접근 가능합니다.' }); + } + + const hashedPassword = hashPassword(password); + const [rows] = await db.query('SELECT 1 FROM users WHERE id = ? AND password = ?', [req.session.user.id, hashedPassword]); + + if (rows.length > 0) { + res.json({ success: true, message: 'Verification successful' }); + } else { + res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' }); + } + } catch (err) { + console.error('Verify supervisor error:', err); + res.status(500).json({ error: '인증 처리 중 오류가 발생했습니다.' }); + } +}); + // 2. List Users (Admin Only) router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => { try { - // ideally check req.user.role if we had middleware, for now assuming client logic protection + internal/local usage const [rows] = await db.query('SELECT id, name, department, position, phone, role, last_login, created_at, updated_at FROM users ORDER BY created_at DESC'); - const users = rows.map(u => ({ - ...u, - phone: decrypt(u.phone) // Decrypt phone for admin view + if (!rows || rows.length === 0) { + return res.json([]); + } + + // Use Promise.all for safe async decryption + const users = await Promise.all(rows.map(async (u) => { + const decryptedPhone = await decrypt(u.phone); + return { + ...u, + phone: decryptedPhone + }; })); res.json(users); } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); + console.error('Failed to list users:', err); + res.status(500).json({ error: '데이터를 불러오는 중 오류가 발생했습니다.' }); } }); @@ -166,7 +168,7 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => { } const hashedPassword = hashPassword(password); - const encryptedPhone = encrypt(phone); + const encryptedPhone = await encrypt(phone); const sql = ` INSERT INTO users (id, password, name, department, position, phone, role) @@ -213,7 +215,7 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => } if (phone !== undefined) { updates.push('phone = ?'); - params.push(encrypt(phone)); + params.push(await encrypt(phone)); } if (role) { updates.push('role = ?'); diff --git a/server/routes/system.js b/server/routes/system.js index 17a4fb0..0cc8290 100644 --- a/server/routes/system.js +++ b/server/routes/system.js @@ -21,16 +21,72 @@ try { console.error('❌ Error loading public key:', e); } +// Helper to check if a setting key is allowed for general get/post +// This prevents modification of sensitive keys if any +const ALLOWED_SETTING_KEYS = [ + 'subscriber_id', + 'session_timeout', + 'encryption_key', + 'asset_id_rule', + 'asset_categories', + 'asset_locations', + 'asset_statuses', + 'asset_maintenance_types' +]; + +// --- .env File Utilities --- +const envPath = path.join(__dirname, '../.env'); + +const readEnv = () => { + if (!fs.existsSync(envPath)) return {}; + const content = fs.readFileSync(envPath, 'utf8'); + const lines = content.split('\n'); + const env = {}; + lines.forEach(line => { + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); + if (match) { + env[match[1]] = match[2] ? match[2].trim() : ''; + } + }); + return env; +}; + +const writeEnv = (updates) => { + let content = fs.readFileSync(envPath, 'utf8'); + Object.entries(updates).forEach(([key, value]) => { + const regex = new RegExp(`^${key}=.*`, 'm'); + if (regex.test(content)) { + content = content.replace(regex, `${key}=${value}`); + } else { + content += `\n${key}=${value}`; + } + }); + fs.writeFileSync(envPath, content, 'utf8'); +}; + +const mysql = require('mysql2/promise'); + // 0. Server Configuration (Subscriber ID & Session Timeout) router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { try { - const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')"); + const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')"); const settings = {}; rows.forEach(r => settings[r.setting_key] = r.setting_value); + // Include .env DB settings + const env = readEnv(); + res.json({ subscriber_id: settings.subscriber_id || '', - session_timeout: parseInt(settings.session_timeout) || 60 // Default 60 min + session_timeout: parseInt(settings.session_timeout) || 60, + encryption_key: settings.encryption_key || '', + db_config: { + host: env.DB_HOST || '', + user: env.DB_USER || '', + password: env.DB_PASSWORD || '', + database: env.DB_NAME || '', + port: env.DB_PORT || '3306' + } }); } catch (err) { console.error(err); @@ -39,7 +95,7 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { }); router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { - const { subscriber_id, session_timeout } = req.body; + const { subscriber_id, session_timeout, encryption_key, db_config } = req.body; try { if (subscriber_id !== undefined) { @@ -48,7 +104,147 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => if (session_timeout !== undefined) { await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]); } - res.json({ message: 'Settings saved' }); + if (encryption_key !== undefined) { + const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key); + await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]); + } + + // Handle .env DB settings + if (db_config) { + writeEnv({ + DB_HOST: db_config.host, + DB_USER: db_config.user, + DB_PASSWORD: db_config.password, + DB_NAME: db_config.database, + DB_PORT: db_config.port + }); + } + + res.json({ message: 'Settings saved. Server may restart to apply DB changes.' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// --- Crypto & Key Rotation --- +const cryptoUtil = require('../utils/cryptoUtil'); + +// 0.2 Test DB Connection +router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => { + const { host, user, password, database, port } = req.body; + + let conn; + try { + conn = await mysql.createConnection({ + host, + user, + password, + database, + port: parseInt(port) || 3306, + connectTimeout: 5000 + }); + await conn.query('SELECT 1'); + res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } finally { + if (conn) await conn.end(); + } +}); + +// 0.3 Encryption Key Management & Rotation +router.get('/encryption/status', isAuthenticated, hasRole('admin'), async (req, res) => { + try { + const [userRows] = await db.query('SELECT COUNT(*) as count FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"'); + const currentKey = await cryptoUtil.getMasterKey(); + + res.json({ + current_key: currentKey, + affected_records: { + users: userRows[0].count + } + }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch encryption status' }); + } +}); + +router.post('/encryption/rotate', isAuthenticated, hasRole('admin'), async (req, res) => { + const { new_key } = req.body; + if (!new_key) return res.status(400).json({ error: 'New key is required' }); + + const conn = await db.getConnection(); + try { + await conn.beginTransaction(); + + const oldKey = await cryptoUtil.getMasterKey(); + + // 1. Migrate Users Table (phone) + const [users] = await conn.query('SELECT id, phone FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"'); + for (const user of users) { + const decrypted = await cryptoUtil.decrypt(user.phone, oldKey); + const reEncrypted = await cryptoUtil.encrypt(decrypted, new_key); + await conn.query('UPDATE users SET phone = ? WHERE id = ?', [reEncrypted, user.id]); + } + + // 2. Update Master Key in settings (Encrypted for DB storage) + const encryptedKeyForDb = cryptoUtil.encryptMasterKey(new_key); + await conn.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]); + + await conn.commit(); + cryptoUtil.clearCache(); // Force immediate reload of new key + + res.json({ success: true, message: 'Encryption key rotated and data migrated successfully.' }); + } catch (err) { + await conn.rollback(); + console.error('Rotation failed:', err); + res.status(500).json({ error: 'Key rotation failed: ' + err.message }); + } finally { + conn.release(); + } +}); + +// 0-1. Generic Setting Get/Set +router.get('/settings/:key', isAuthenticated, async (req, res) => { + const { key } = req.params; + if (!ALLOWED_SETTING_KEYS.includes(key)) { + return res.status(400).json({ error: 'Invalid setting key' }); + } + + try { + const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = ?", [key]); + if (rows.length === 0) return res.json({ value: null }); + res.json({ value: rows[0].setting_value }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res) => { + const { key } = req.params; + const { value } = req.body; + + if (!ALLOWED_SETTING_KEYS.includes(key)) { + return res.status(400).json({ error: 'Invalid setting key' }); + } + + try { + let stringValue = typeof value === 'string' ? value : JSON.stringify(value); + + // Special handling for encryption_key to protect it in DB + if (key === 'encryption_key') { + stringValue = cryptoUtil.encryptMasterKey(stringValue); + } + + await db.query( + `INSERT INTO system_settings (setting_key, setting_value) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, + [key, stringValue] + ); + res.json({ success: true, message: 'Setting saved' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Database error' }); diff --git a/server/utils/cryptoUtil.js b/server/utils/cryptoUtil.js new file mode 100644 index 0000000..a4f68c9 --- /dev/null +++ b/server/utils/cryptoUtil.js @@ -0,0 +1,111 @@ +const crypto = require('crypto'); +const db = require('../db'); + +const ALGORITHM = 'aes-256-cbc'; +const SYSTEM_INTERNAL_KEY = 'ims_system_l2_internal_protection_key_2026'; // 고정 시스템 키 (2단계 보안용) +let cachedKey = null; + +const cryptoUtil = { + /** + * 내부 보안용 암호화 (마스터 키 보호용) + */ + _internalProcess(text, isEncrypt = true) { + if (!text) return text; + try { + const keyBuffer = crypto.scryptSync(SYSTEM_INTERNAL_KEY, 'ims_salt', 32); + if (isEncrypt) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; + } else { + if (!text.includes(':')) return text; // 평문인 경우 그대로 반환 + const [ivHex, cipherText] = text.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); + let decrypted = decipher.update(cipherText, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + } catch (e) { + return text; + } + }, + + /** + * Get key from DB or Cache + */ + async getMasterKey() { + if (cachedKey) return cachedKey; + try { + const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'encryption_key'"); + if (rows.length > 0 && rows[0].setting_value) { + const rawValue = rows[0].setting_value; + // DB에 저장된 값이 암호화된 형태(iv 포함)라면 복호화하여 사용 + if (rawValue.includes(':')) { + cachedKey = this._internalProcess(rawValue, false); + } else { + cachedKey = rawValue; + } + return cachedKey; + } + } catch (e) { + console.error('CryptoUtil: Failed to fetch key', e); + } + return process.env.ENCRYPTION_KEY || 'smartasset_secret_key_0123456789'; + }, + + /** + * 마스터 키를 DB에 저장하기 전 암호화하는 함수 + */ + encryptMasterKey(plainKey) { + return this._internalProcess(plainKey, true); + }, + + clearCache() { + cachedKey = null; + }, + + /** + * Encrypt with specific key (optional, defaults to master) + */ + async encrypt(text, customKey = null) { + if (!text) return text; + try { + const secret = customKey || await this.getMasterKey(); + const keyBuffer = crypto.scryptSync(secret, 'salt', 32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; + } catch (e) { + console.error('Encryption failed:', e.message); + return text; + } + }, + + /** + * Decrypt with specific key (optional, defaults to master) + */ + async decrypt(text, customKey = null) { + if (!text || !text.includes(':')) return text; + try { + const secret = customKey || await this.getMasterKey(); + const keyBuffer = crypto.scryptSync(secret, 'salt', 32); + const [ivHex, cipherText] = text.split(':'); + if (!ivHex || ivHex.length !== 32) return text; + + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); + let decrypted = decipher.update(cipherText, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (e) { + return text; + } + } +}; + +module.exports = cryptoUtil; diff --git a/src/modules/asset/pages/AssetListPage.css b/src/modules/asset/pages/AssetListPage.css index 6f259ab..fc476ac 100644 --- a/src/modules/asset/pages/AssetListPage.css +++ b/src/modules/asset/pages/AssetListPage.css @@ -6,24 +6,25 @@ height: 100%; } -.page-header { +.page-header-right { display: flex; - justify-content: flex-start; + flex-direction: column; align-items: flex-end; - text-align: left; - /* Explicitly set text align */ + text-align: right; + margin-bottom: 2rem; + width: 100%; } .page-title-text { - font-size: 1.5rem; + font-size: 1.75rem; font-weight: 700; - color: var(--color-text-primary); - margin-bottom: 0.25rem; + color: var(--sokuree-text-primary); + margin-bottom: 0.5rem; } .page-subtitle { - color: var(--color-text-secondary); - font-size: 0.9rem; + color: var(--sokuree-text-secondary); + font-size: 1rem; } .content-card { diff --git a/src/modules/asset/pages/AssetListPage.tsx b/src/modules/asset/pages/AssetListPage.tsx index 64aaecd..b0fea06 100644 --- a/src/modules/asset/pages/AssetListPage.tsx +++ b/src/modules/asset/pages/AssetListPage.tsx @@ -23,6 +23,7 @@ export function AssetListPage() { const [currentPage, setCurrentPage] = useState(1); const [assets, setAssets] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const itemsPerPage = 8; // Fetch Assets @@ -33,10 +34,17 @@ export function AssetListPage() { const loadAssets = async () => { try { setIsLoading(true); + setError(null); const data = await assetApi.getAll(); - setAssets(data); + if (Array.isArray(data)) { + setAssets(data); + } else { + console.error("API returned non-array data:", data); + setAssets([]); + } } catch (error: any) { console.error("Failed to fetch assets:", error); + setError("데이터를 불러오는 중 오류가 발생했습니다. 서버 연결을 확인해 주세요."); } finally { setIsLoading(false); } @@ -98,8 +106,10 @@ export function AssetListPage() { // Let's make it strict if possible, or robust for '설비' vs '설비 자산'. if (asset.category !== currentCategory) { - // Fallback for partial matches if needed, but let's try strict first based on user request. - if (!asset.category.includes(currentCategory)) return false; + // partial match fallback (e.g., '시설' in '시설 자산' or vice versa) + const assetCat = asset.category || ''; + const targetCat = currentCategory || ''; + if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false; } } @@ -240,13 +250,11 @@ export function AssetListPage() { return (

-
-
-

{getPageTitle()}

-

- {currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'} -

-
+
+

{getPageTitle()}

+

+ {currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'} +

@@ -395,9 +403,18 @@ export function AssetListPage() { )) + ) : error ? ( + + +
+ ⚠️ {error} + +
+ + ) : ( - + 데이터가 없습니다. diff --git a/src/modules/asset/pages/AssetRegisterPage.css b/src/modules/asset/pages/AssetRegisterPage.css new file mode 100644 index 0000000..8b4c9d1 --- /dev/null +++ b/src/modules/asset/pages/AssetRegisterPage.css @@ -0,0 +1,43 @@ +/* AssetRegisterPage.css */ + + + +/* Page Header - Right Aligned */ +.page-header-right { + display: flex; + flex-direction: column; + align-items: flex-end; + text-align: right; + margin-bottom: 2rem; + width: 100%; + padding: 0 0.5rem; +} + +.page-title-text { + font-size: 1.75rem; + font-weight: 700; + color: var(--sokuree-text-primary); + margin-bottom: 0.5rem; +} + +.page-subtitle { + font-size: 1rem; + color: var(--sokuree-text-secondary); +} + +/* Bottom Actions Styling */ +.form-actions-footer { + display: flex; + justify-content: center; + align-items: center; + gap: 1.5rem; + margin-top: 3rem; + margin-bottom: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--sokuree-border-color); + width: 100%; +} + +.form-actions-footer .ui-btn { + min-width: 120px; +} \ No newline at end of file diff --git a/src/modules/asset/pages/AssetRegisterPage.tsx b/src/modules/asset/pages/AssetRegisterPage.tsx index 67d7159..fe10a3b 100644 --- a/src/modules/asset/pages/AssetRegisterPage.tsx +++ b/src/modules/asset/pages/AssetRegisterPage.tsx @@ -7,6 +7,7 @@ import { Select } from '../../../shared/ui/Select'; import { ArrowLeft, Save, Upload } from 'lucide-react'; import { getCategories, getLocations, getIDRule } from './AssetSettingsPage'; import { assetApi, type Asset } from '../../../shared/api/assetApi'; +import './AssetRegisterPage.css'; export function AssetRegisterPage() { const navigate = useNavigate(); @@ -51,13 +52,11 @@ export function AssetRegisterPage() { if (part.type === 'separator') return part.value; if (part.type === 'year') return year; if (part.type === 'category') return category ? category.code : 'UNKNOWN'; - if (part.type === 'sequence') return part.value; // In real app, we fetch next seq. here we just show the format pattern e.g. 001 + if (part.type === 'sequence') return part.value; return ''; }).join(''); - // Ideally we would fetch the *actual* next sequence from API here. - // For now we assume '001' as a placeholder or "Generating..." - const finalId = generatedId.replace('001', '001'); // Just keeping the placeholder visible for user confirmation + const finalId = generatedId.replace('001', '001'); setFormData(prev => ({ ...prev, id: finalId })); @@ -87,7 +86,7 @@ export function AssetRegisterPage() { }; const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + if (e) e.preventDefault(); // Validation if (!formData.categoryId || !formData.name || !formData.locationId) { @@ -96,11 +95,9 @@ export function AssetRegisterPage() { } try { - // Map IDs to Names/Codes for Backend const selectedCategory = categories.find(c => c.id === formData.categoryId); const selectedLocation = locations.find(l => l.id === formData.locationId); - // Upload Image if exists let imageUrl = ''; if (formData.image) { const uploadRes = await assetApi.uploadImage(formData.image); @@ -110,7 +107,7 @@ export function AssetRegisterPage() { const payload: Partial = { id: formData.id, name: formData.name, - category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name + category: selectedCategory ? selectedCategory.name : '미지정', model: formData.model, serialNumber: formData.serialNo, location: selectedLocation ? selectedLocation.name : '미지정', @@ -134,196 +131,208 @@ export function AssetRegisterPage() { return (
-
-
-

자산 등록

-

새로운 자산을 시스템에 등록합니다.

-
-
- - -
+
+

자산 등록

+

새로운 자산을 시스템에 등록합니다.

- -
- {/* Basic Info */} -
-

기본 정보

-
+ + +
+ {/* Basic Info Section */} +
+
+

기본 정보

+
- ({ label: c.name, value: c.id }))} + placeholder="카테고리 선택" + required + /> - + - + - + - + - + - {/* Image Upload Field */} -
- -
- {/* Preview Area - Fixed size container */} -
- {formData.imagePreview ? ( - Preview + +
+
+ {formData.imagePreview ? ( + Preview + ) : ( +
+ +
+ )} +
+ +
+ + + + {formData.image ? formData.image.name : '선택된 파일 없음'} + + + + 지원 형식: JPG, PNG, GIF (최대 5MB) + + + - ) : ( -
- -
- )} -
- - {/* Upload Controls - Moved below */} -
- - - - {formData.image ? formData.image.name : '선택된 파일 없음'} - - - - 지원 형식: JPG, PNG, GIF (최대 5MB) - - - +
+ + {/* Management Info Section */} +
+
+

관리 정보

+
+ + + + + +
- - {/* Management Info */} -
-

관리 정보

+ {/* Bottom Action Buttons */} +
+ +
- - - - - - -
diff --git a/src/modules/asset/pages/AssetSettingsPage.css b/src/modules/asset/pages/AssetSettingsPage.css index f70dfe2..6aebf2e 100644 --- a/src/modules/asset/pages/AssetSettingsPage.css +++ b/src/modules/asset/pages/AssetSettingsPage.css @@ -1,45 +1,45 @@ -/* Page Header adjustments for Settings */ -.page-header { +/* Page Header - Right Aligned */ +.page-header-right { display: flex; flex-direction: column; - gap: 1rem; - padding-bottom: 0 !important; - /* Override default bottom padding */ - border-bottom: 1px solid var(--color-border); + align-items: flex-end; + text-align: right; + margin-bottom: 2rem; + width: 100%; } -.header-top { - padding-bottom: 0.5rem; -} - -/* Settings Tabs */ -.settings-tabs { - display: flex; - gap: 1.5rem; - margin-bottom: -1px; - /* Overlap border */ -} - -.settings-tab { - padding: 0.75rem 0.5rem; - font-size: 0.95rem; - font-weight: 500; - color: var(--color-text-secondary); - background: transparent; - border: none; - border-bottom: 3px solid transparent; - cursor: pointer; - transition: all 0.2s; -} - -.settings-tab:hover { - color: var(--color-brand-primary); -} - -.settings-tab.active { - color: var(--color-brand-primary); +.page-title-text { + font-size: 1.75rem; font-weight: 700; - border-bottom-color: var(--color-brand-primary); + color: var(--sokuree-text-primary); + margin-bottom: 0.5rem; +} + +.page-subtitle { + font-size: 1rem; + color: var(--sokuree-text-secondary); +} + +/* Card Header Styles within Settings */ +.card-header { + margin-bottom: 1.5rem; +} + +.card-title { + font-size: 1.1rem; + font-weight: 700; + color: var(--sokuree-text-primary); + margin-bottom: 0.25rem; +} + +.card-desc { + font-size: 0.95rem; + color: var(--sokuree-text-secondary); +} + +/* Local tabs are now hidden and moved to Global TopHeader */ +.settings-tabs { + display: none; } /* Common Layout */ @@ -66,8 +66,8 @@ /* Table Styles */ .table-wrapper { - border: 1px solid var(--color-border); - border-radius: var(--radius-md); + border: 1px solid var(--sokuree-border-color); + border-radius: var(--sokuree-radius-md); overflow: hidden; } @@ -83,13 +83,13 @@ font-weight: 600; text-align: left; padding: 0.75rem 1rem; - border-bottom: 1px solid var(--color-border); + border-bottom: 1px solid var(--sokuree-border-color); } .settings-table td { padding: 0.75rem 1rem; border-bottom: 1px solid #f1f5f9; - color: var(--color-text-primary); + color: var(--sokuree-text-primary); vertical-align: middle; } @@ -144,7 +144,7 @@ .preview-box { background-color: #f1f5f9; padding: 1rem; - border-radius: var(--radius-md); + border-radius: var(--sokuree-radius-md); display: flex; align-items: center; gap: 0.75rem; @@ -179,7 +179,7 @@ min-height: 60px; padding: 0.5rem; border: 2px dashed #e2e8f0; - border-radius: var(--radius-md); + border-radius: var(--sokuree-radius-md); background-color: #fcfcfc; } @@ -282,14 +282,16 @@ height: 32px; /* Match button height (sm size) */ padding: 0 0.5rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); + border: 1px solid var(--sokuree-border-color); + border-radius: var(--sokuree-radius-sm); font-size: 0.875rem; width: 150px; outline: none; transition: border-color 0.2s; + background-color: white; } .custom-text-input:focus { - border-color: var(--color-brand-primary); + border-color: var(--sokuree-brand-primary); + box-shadow: 0 0 0 2px rgba(var(--sokuree-brand-primary-rgb), 0.1); } \ No newline at end of file diff --git a/src/modules/asset/pages/AssetSettingsPage.tsx b/src/modules/asset/pages/AssetSettingsPage.tsx index 1c945a6..513bed0 100644 --- a/src/modules/asset/pages/AssetSettingsPage.tsx +++ b/src/modules/asset/pages/AssetSettingsPage.tsx @@ -3,7 +3,8 @@ import { useSearchParams } from 'react-router-dom'; import { Card } from '../../../shared/ui/Card'; import { Button } from '../../../shared/ui/Button'; import { Input } from '../../../shared/ui/Input'; -import { Plus, Trash2, Edit2, X, Check, GripVertical } from 'lucide-react'; +import { Plus, Trash2, Edit2, X, Check, GripVertical, Save, Loader2 } from 'lucide-react'; +import { apiClient } from '../../../shared/api/client'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; @@ -76,6 +77,7 @@ function SortableRuleItem({ id, children, className }: { id: string; children: R // --------------------- +// Types // Types interface AssetCategory { id: string; @@ -96,7 +98,7 @@ interface AssetStatus { color: string; } -type IDRuleComponentType = 'company' | 'category' | 'year' | 'sequence' | 'separator' | 'custom'; +type IDRuleComponentType = 'company' | 'category' | 'year' | 'month' | 'sequence' | 'separator' | 'custom'; interface IDRuleComponent { id: string; @@ -105,7 +107,13 @@ interface IDRuleComponent { label: string; } -// Initial Mock Data +export interface AssetMaintenanceType { + id: string; + name: string; + color: string; +} + +// Internal values for initial fallback let GLOBAL_CATEGORIES: AssetCategory[] = [ { id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' }, { id: '2', name: '공구', code: 'TOL', menuLink: 'tools' }, @@ -130,7 +138,7 @@ let GLOBAL_STATUSES: AssetStatus[] = [ ]; let GLOBAL_ID_RULE: IDRuleComponent[] = [ - { id: 'r1', type: 'company', value: 'HK', label: '회사약어' }, + { id: 'r1', type: 'company', value: 'SKR', label: '회사약어' }, { id: 'r2', type: 'separator', value: '-', label: '구분자' }, { id: 'r3', type: 'category', value: '', label: '카테고리' }, { id: 'r4', type: 'separator', value: '-', label: '구분자' }, @@ -139,12 +147,6 @@ let GLOBAL_ID_RULE: IDRuleComponent[] = [ { id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' }, ]; -export interface AssetMaintenanceType { - id: string; - name: string; - color: string; -} - let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [ { id: '1', name: '정기점검', color: 'success' }, { id: '2', name: '수리', color: 'danger' }, @@ -170,6 +172,68 @@ export function AssetSettingsPage() { const [maintenanceTypes, setMaintenanceTypes] = useState(GLOBAL_MAINTENANCE_TYPES); const [idRule, setIdRule] = useState(GLOBAL_ID_RULE); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // Initial Load + React.useEffect(() => { + const fetchAllSettings = async () => { + setIsLoading(true); + try { + const keys = ['asset_id_rule', 'asset_categories', 'asset_locations', 'asset_statuses', 'asset_maintenance_types']; + const results = await Promise.all(keys.map(k => apiClient.get(`/system/settings/${k}`))); + + results.forEach((res, idx) => { + const key = keys[idx]; + const val = res.data.value; + if (val) { + const parsed = JSON.parse(val); + if (key === 'asset_id_rule') { + setIdRule(parsed); + GLOBAL_ID_RULE = parsed; + // Update company code input from the rule if available + const comp = parsed.find((p: any) => p.type === 'company'); + if (comp) setCompanyCodeInput(comp.value); + } + if (key === 'asset_categories') { setCategories(parsed); GLOBAL_CATEGORIES = parsed; } + if (key === 'asset_locations') { setLocations(parsed); GLOBAL_LOCATIONS = parsed; } + if (key === 'asset_statuses') { setStatuses(parsed); GLOBAL_STATUSES = parsed; } + if (key === 'asset_maintenance_types') { setMaintenanceTypes(parsed); GLOBAL_MAINTENANCE_TYPES = parsed; } + } + }); + } catch (err) { + console.error('Failed to load settings:', err); + } finally { + setIsLoading(false); + } + }; + fetchAllSettings(); + }, []); + + const handleSaveSettings = async (currentTab: string) => { + setIsSaving(true); + try { + let key = ''; + let value: any = null; + + if (currentTab === 'basic') { key = 'asset_id_rule'; value = idRule; } + else if (currentTab === 'category') { key = 'asset_categories'; value = categories; } + else if (currentTab === 'location') { key = 'asset_locations'; value = locations; } + else if (currentTab === 'status') { key = 'asset_statuses'; value = statuses; } + else if (currentTab === 'maintenance') { key = 'asset_maintenance_types'; value = maintenanceTypes; } + + if (key) { + await apiClient.post(`/system/settings/${key}`, { value }); + alert('설정이 저장되었습니다.'); + } + } catch (err) { + console.error('Save failed:', err); + alert('저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }; + // Form inputs const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryCode, setNewCategoryCode] = useState(''); @@ -445,6 +509,12 @@ export function AssetSettingsPage() { setCustomRuleText(''); }; + const updateCompanyValue = (newVal: string) => { + const updated = idRule.map(part => part.type === 'company' ? { ...part, value: newVal } : part); + setIdRule(updated); + GLOBAL_ID_RULE = updated; + }; + const removeRuleComponent = (id: string) => { const updated = idRule.filter(comp => comp.id !== id); setIdRule(updated); @@ -453,10 +523,12 @@ export function AssetSettingsPage() { const getPreviewId = () => { + const now = new Date(); const mockData = { - company: 'HK', + company: 'HK', // Fallback category: 'FAC', - year: new Date().getFullYear().toString(), + year: now.getFullYear().toString(), + month: String(now.getMonth() + 1).padStart(2, '0'), sequence: '001', separator: '-' }; @@ -466,22 +538,33 @@ export function AssetSettingsPage() { if (part.type === 'company') return part.value; if (part.type === 'category') return mockData.category; if (part.type === 'year') return mockData.year; + if (part.type === 'month') return mockData.month; if (part.type === 'sequence') return part.value; if (part.type === 'separator') return part.value; return ''; }).join(''); }; + if (isLoading) return
+ + 설정을 불러오는 중입니다... +
; + return ( -
-
+
+
+

자산 설정

+

자산 관리 모듈의 기준 정보를 설정합니다.

+
+ +
{activeTab === 'basic' && ( - +

자산 관리번호 생성 규칙

자산 등록 시 자동 생성될 관리번호의 포맷을 조합합니다.

@@ -518,40 +601,57 @@ export function AssetSettingsPage() {

규칙 요소 추가

-
- setCompanyCodeInput(e.target.value.toUpperCase())} - className="custom-text-input" - maxLength={5} - style={{ width: '100px' }} - /> - -
+ { + const val = e.target.value.toUpperCase(); + setCompanyCodeInput(val); + updateCompanyValue(val); + }} + className="custom-text-input" + maxLength={5} + style={{ width: '80px' }} + /> + + + - -
- setCustomRuleText(e.target.value)} - className="custom-text-input" - /> - -
+ + + setCustomRuleText(e.target.value)} + className="custom-text-input" + style={{ marginLeft: '0.5rem', width: '130px' }} + /> +
+ + {/* Bottom Action */} +
+ +
)} {activeTab === 'category' && ( - +

카테고리 관리

자산 유형을 분류하는 카테고리를 관리합니다. (약어는 자산번호 생성 시 사용됩니다)

@@ -666,12 +766,25 @@ export function AssetSettingsPage() {
+ + {/* Bottom Action */} +
+ +
)} {activeTab === 'location' && ( - +

설치 위치 / 보관 장소

자산이 위치할 수 있는 장소를 관리합니다.

@@ -735,12 +848,25 @@ export function AssetSettingsPage() {
+ + {/* Bottom Action */} +
+ +
)} {activeTab === 'status' && ( - +

자산 상태 관리

자산의 상태(운용, 파손, 수리 등)를 정의하고 관리합니다.

@@ -834,12 +960,25 @@ export function AssetSettingsPage() {
+ + {/* Bottom Action */} +
+ +
)} {activeTab === 'maintenance' && ( - +

유지보수 구분 관리

유지보수 이력 등록 시 사용할 작업 구분을 관리합니다.

@@ -923,6 +1062,19 @@ export function AssetSettingsPage() {
+ + {/* Bottom Action */} +
+ +
)} diff --git a/src/pages/auth/LoginPage.css b/src/pages/auth/LoginPage.css index 1a651d9..348fee0 100644 --- a/src/pages/auth/LoginPage.css +++ b/src/pages/auth/LoginPage.css @@ -4,8 +4,8 @@ justify-content: center; height: 100vh; width: 100vw; - background-color: var(--color-bg-base); - background: linear-gradient(135deg, var(--color-bg-sidebar) 0%, var(--color-brand-primary) 100%); + background-color: var(--sokuree-bg-main); + background: linear-gradient(135deg, var(--sokuree-bg-sidebar) 0%, var(--sokuree-brand-primary) 100%); } .login-card { @@ -14,8 +14,8 @@ background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); padding: 3rem; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); + border-radius: var(--sokuree-radius-lg); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); } .login-header { @@ -26,22 +26,22 @@ .brand-logo { display: inline-flex; padding: 1rem; - background-color: var(--color-bg-sidebar); - color: var(--color-text-inverse); - border-radius: var(--radius-md); + background-color: var(--sokuree-bg-sidebar); + color: var(--sokuree-text-inverse); + border-radius: var(--sokuree-radius-md); margin-bottom: 1.5rem; - box-shadow: var(--shadow-md); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } .login-header h1 { font-size: 1.75rem; font-weight: 700; - color: var(--color-text-primary); + color: var(--sokuree-text-primary); margin-bottom: 0.5rem; } .login-header p { - color: var(--color-text-secondary); + color: var(--sokuree-text-secondary); font-size: 0.875rem; } @@ -60,7 +60,7 @@ .form-group label { font-size: 0.875rem; font-weight: 500; - color: var(--color-text-primary); + color: var(--sokuree-text-primary); } .input-wrapper { @@ -72,33 +72,33 @@ .input-icon { position: absolute; left: 1rem; - color: var(--color-text-secondary); + color: var(--sokuree-text-secondary); pointer-events: none; } .form-group input { width: 100%; padding: 0.75rem 1rem 0.75rem 2.75rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); + border: 1px solid var(--sokuree-border-color); + border-radius: var(--sokuree-radius-md); font-size: 0.9rem; transition: all 0.2s; - background-color: var(--color-bg-surface); + background-color: var(--sokuree-bg-card); } .form-group input:focus { outline: none; - border-color: var(--color-brand-primary); - box-shadow: 0 0 0 3px rgba(82, 109, 130, 0.1); + border-color: var(--sokuree-brand-primary); + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1); } .login-btn { margin-top: 1rem; padding: 0.875rem; - background-color: var(--color-bg-sidebar); + background-color: var(--sokuree-bg-sidebar); color: white; font-weight: 600; - border-radius: var(--radius-md); + border-radius: var(--sokuree-radius-md); font-size: 1rem; letter-spacing: 0.025em; transition: all 0.2s; @@ -107,7 +107,7 @@ .login-btn:hover { background-color: #1e293b; transform: translateY(-1px); - box-shadow: var(--shadow-md); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } .error-message { @@ -116,12 +116,12 @@ text-align: center; background-color: rgba(239, 68, 68, 0.1); padding: 0.75rem; - border-radius: var(--radius-sm); + border-radius: var(--sokuree-radius-sm); } .login-footer { margin-top: 2rem; text-align: center; font-size: 0.75rem; - color: var(--color-text-secondary); + color: var(--sokuree-text-secondary); } \ No newline at end of file diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 88fc3d0..dc75932 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -13,6 +13,13 @@ export function LoginPage() { const location = useLocation(); const from = location.state?.from?.pathname || '/asset/dashboard'; + const isExpired = new URLSearchParams(location.search).get('expired') === 'true'; + + useState(() => { + if (isExpired) { + setError('세션이 만료되었습니다. 보안을 위해 다시 로그인해주세요.'); + } + }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/platform/App.tsx b/src/platform/App.tsx index 36bcc36..18c3fe2 100644 --- a/src/platform/App.tsx +++ b/src/platform/App.tsx @@ -14,6 +14,7 @@ import ModuleLoader from './ModuleLoader'; // Platform / System Pages import { UserManagementPage } from './pages/UserManagementPage'; import { BasicSettingsPage } from './pages/BasicSettingsPage'; +import { VersionPage } from './pages/VersionPage'; import { LicensePage } from '../system/pages/LicensePage'; import './styles/global.css'; @@ -45,6 +46,7 @@ export const App = () => { } /> } /> } /> + } /> } /> diff --git a/src/platform/pages/BasicSettingsPage.tsx b/src/platform/pages/BasicSettingsPage.tsx index fbdc26b..f2a8e8c 100644 --- a/src/platform/pages/BasicSettingsPage.tsx +++ b/src/platform/pages/BasicSettingsPage.tsx @@ -3,23 +3,75 @@ 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 { Save, Clock, Info } from 'lucide-react'; +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; + db_config: { + host: string; + user: string; + password?: string; + database: string; + port: string; + }; +} export function BasicSettingsPage() { - const [settings, setSettings] = useState({ - session_timeout: 60 + const { user: currentUser } = useAuth(); + const [settings, setSettings] = useState({ + session_timeout: 60, + encryption_key: '', + subscriber_id: '', + 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, + 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'); - setSettings(res.data); + 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 { @@ -27,95 +79,416 @@ export function BasicSettingsPage() { } }; - const handleSave = async () => { - setSaving(true); + const fetchEncryptionStatus = async () => { try { - await apiClient.post('/system/settings', settings); - alert('설정이 저장되었습니다.'); + const res = await apiClient.get('/system/encryption/status'); + setRotationStatus({ + currentKey: res.data.current_key, + affectedCount: Object.values(res.data.affected_records as Record).reduce((a, b) => a + b, 0) + }); } catch (error) { - console.error('Save failed', error); - alert('저장 중 오류가 발생했습니다.'); + 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') => { + 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 === '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
로딩 중...
; + if (loading) return
데이터를 불러오는 중...
; return ( -
-
-

시스템 관리 - 기본 설정

-

플랫폼의 기본 운영 환경을 설정합니다.

+
+
+
+

시스템 관리 - 기본 설정

+

플랫폼의 보안 및 인프라 환경을 섹션별로 관리합니다.

+
-
- - - -

- - 보안 설정 -

-
-
- -
-
- { - const newHours = parseInt(e.target.value) || 0; - const currentTotal = settings.session_timeout || 10; - const currentMinutes = currentTotal % 60; - setSettings({ ...settings, session_timeout: (newHours * 60) + currentMinutes }); - }} - placeholder="0" - /> - 시간 -
-
- { - const newMinutes = parseInt(e.target.value) || 0; - const currentTotal = settings.session_timeout || 10; - const currentHours = Math.floor(currentTotal / 60); - setSettings({ ...settings, session_timeout: (currentHours * 60) + newMinutes }); - }} - placeholder="10" - /> - -
- 동안 활동이 없으면 자동으로 로그아웃됩니다. +
+ {/* Section 1: Security & Session */} + +
+

+ + 보안 및 세션 +

+
+
+ +
+
+ { + const h = parseInt(e.target.value) || 0; + setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) }); + }} + /> + 시간
-

- ( 총 {settings.session_timeout || 10}분 / 최소 5분 ~ 최대 24시간 ) -

+
+ { + const m = parseInt(e.target.value) || 0; + setSettings({ ...settings, session_timeout: (Math.floor(settings.session_timeout / 60) * 60) + m }); + }} + /> + +
+
+
+
+
+ {saveResults.security && ( +
+ {saveResults.security.success ? : } + {saveResults.security.message} +
+ )} +
+
+ +
-
- -
+ {/* Section 2: Encryption Master Key & Rotation (Supervisor Protected) */} + +
+

+ + 데이터 암호화 마스터 키 관리 +

+ {!isEncryptionUnlocked && ( + + )} + {isEncryptionUnlocked && ( +
+ 검증 완료: 최고관리자 모드 +
+ )} +
+ + {isEncryptionUnlocked ? ( +
+
+
+
+ 현재 활성 키 + {rotationStatus?.currentKey || '-'} +
+
+ 영향 레코드 +
+ {rotationStatus?.affectedCount || 0} 건 + +
+
+
+
+ setSettings({ ...settings, encryption_key: e.target.value })} + placeholder="새로운 키 입력" + className="font-mono flex-1" + /> + +
+
+
+
+ {saveResults.encryption && ( +
+ {saveResults.encryption.success ? : } + {saveResults.encryption.message} +
+ )} +
+
+ + +
+
+
+ ) : ( +
+ +

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

+
+ )} +
+ + {/* Section 3: Database Infrastructure (Supervisor Protected) */} + +
+

+ + 데이터베이스 인프라 구성 +

+ {!isDbConfigUnlocked && ( + + )} + {isDbConfigUnlocked && ( +
+ 검증 완료: 최고관리자 모드 +
+ )} +
+ + {isDbConfigUnlocked ? ( +
+
+
+ +
+

물리적 인프라 수동 관리 모드

+

+ 이 섹션의 설정을 잘못 변경하면 플랫폼 전체 접속이 불가능해질 수 있습니다.
+ 반드시 접속 테스트 성공을 확인한 후에 저장하십시오. +

+
+
+ +
+
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, host: e.target.value } }); setIsDbVerified(false); }} /> +
+
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, user: e.target.value } }); setIsDbVerified(false); }} /> +
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} /> +
+
+
+
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, database: e.target.value } }); setIsDbVerified(false); }} /> +
+
+ + { setSettings({ ...settings, db_config: { ...settings.db_config, port: e.target.value } }); setIsDbVerified(false); }} /> +
+
+
+ +
+
+ {testResult && ( + + {testResult.success ? : } {testResult.message} + + )} + {!testResult && 변경 시 반드시 접속 테스트를 수행하십시오.} +
+ +
+
+
+
+ {testResult && !testResult.success && ⚠️ 연결 실패 시 변경된 정보를 저장할 수 없습니다.} + {saveResults.database && ( +
+ {saveResults.database.message} +
+ )} +
+
+ + +
+
+
+ ) : ( +
+ +

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

+
+ )} +
+ + {/* Verification Modal */} + {showVerifyModal && ( +
+ +
+

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

+

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

+
+
+
+ {verifyingTarget === 'encryption' + ? "※ 경고: 암호화 키 변경은 시스템 내 모든 민감 데이터를 다시 암호화하는 중대한 작업입니다. 성공 시 기존 데이터를 새 키로 대체하며, 실패 시 데이터 유실의 위험이 있습니다." + : "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."} +
+
+ + setVerifyPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + onKeyDown={e => e.key === 'Enter' && handleVerifySupervisor()} + autoFocus + /> + {verifyError &&

{verifyError}

} +
+
+
+ + +
+
+
+ )}
); } diff --git a/src/platform/pages/UserManagementPage.tsx b/src/platform/pages/UserManagementPage.tsx index 59b334a..ab493cf 100644 --- a/src/platform/pages/UserManagementPage.tsx +++ b/src/platform/pages/UserManagementPage.tsx @@ -3,9 +3,10 @@ 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 { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon } from 'lucide-react'; +import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw } from 'lucide-react'; import type { User } from '../../shared/auth/AuthContext'; -import './UserManagementPage.css'; // Will create CSS separately or inline styles initially. Let's assume global css or create specific. +import { useAuth } from '../../shared/auth/AuthContext'; +import './UserManagementPage.css'; interface UserFormData { id: string; @@ -14,10 +15,11 @@ interface UserFormData { department: string; position: string; phone: string; - role: 'admin' | 'user'; + role: 'supervisor' | 'admin' | 'user'; } export function UserManagementPage() { + const { user: currentUser } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); @@ -68,7 +70,7 @@ export function UserManagementPage() { const handleOpenEdit = (user: User) => { setFormData({ id: user.id, - password: '', // Password empty by default on edit + password: '', name: user.name, department: user.department || '', position: user.position || '', @@ -92,28 +94,20 @@ export function UserManagementPage() { const formatPhoneNumber = (value: string) => { const cleaned = value.replace(/\D/g, ''); - if (cleaned.length <= 3) { - return cleaned; - } else if (cleaned.length <= 7) { - return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`; - } else { - return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`; - } + if (cleaned.length <= 3) return cleaned; + else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`; + else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - try { if (isEditing) { - // Update const payload: any = { ...formData }; - if (!payload.password) delete payload.password; // Don't send empty password - + if (!payload.password) delete payload.password; await apiClient.put(`/users/${formData.id}`, payload); alert('수정되었습니다.'); } else { - // Create if (!formData.password) return alert('비밀번호를 입력하세요.'); await apiClient.post('/users', formData); alert('등록되었습니다.'); @@ -121,9 +115,30 @@ export function UserManagementPage() { setIsModalOpen(false); fetchUsers(); } catch (error: any) { - console.error('Submit failed', error); - const errorMsg = error.response?.data?.error || error.message || '저장 중 오류가 발생했습니다.'; - alert(`오류: ${errorMsg}`); + alert(`오류: ${error.response?.data?.error || error.message}`); + } + }; + + const getRoleBadge = (role: string) => { + switch (role) { + case 'supervisor': + return ( + + 최고관리자 + + ); + case 'admin': + return ( + + 관리자 + + ); + default: + return ( + + 사용자 + + ); } }; @@ -137,10 +152,10 @@ export function UserManagementPage() {
- +
- - +
+ @@ -150,39 +165,44 @@ export function UserManagementPage() { - - {users.map((user) => ( - - + {loading ? ( + + - + + ) : users.map((user) => ( + - - + + + ))} - {users.length === 0 && !loading && ( + {!loading && users.length === 0 && ( @@ -194,46 +214,44 @@ export function UserManagementPage() { {/* Modal */} {isModalOpen && ( -
- +
+

{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}

-
- + setFormData({ ...formData, id: e.target.value })} - disabled={isEditing} // ID cannot be changed on edit - placeholder="로그인 아이디" + disabled={isEditing} + placeholder="로그인 아이디 입력" required - autoComplete="off" />
-
- + setFormData({ ...formData, name: e.target.value })} @@ -241,28 +259,41 @@ export function UserManagementPage() { />
- + + {currentUser?.role !== 'supervisor' && ( +

* 최고관리자 권한 부여는 최고관리자만 가능합니다.

+ )}
- + setFormData({ ...formData, department: e.target.value })} />
- + setFormData({ ...formData, position: e.target.value })} @@ -271,18 +302,18 @@ export function UserManagementPage() {
- + setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })} - placeholder="예: 010-1234-5678" + placeholder="010-0000-0000" maxLength={13} />
- +
diff --git a/src/platform/pages/VersionPage.tsx b/src/platform/pages/VersionPage.tsx new file mode 100644 index 0000000..8ff83fd --- /dev/null +++ b/src/platform/pages/VersionPage.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect } from 'react'; +import { Card } from '../../shared/ui/Card'; +import { apiClient } from '../../shared/api/client'; +import { Info, Cpu, Database, Server, Hash, Calendar } from 'lucide-react'; + +interface VersionInfo { + status: string; + version: string; + timestamp: string; +} + +export function VersionPage() { + const [healthIcon, setHealthInfo] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchVersion = async () => { + try { + const res = await apiClient.get('/health'); + setHealthInfo(res.data); + } catch (err) { + console.error('Failed to fetch version info', err); + } finally { + setLoading(false); + } + }; + fetchVersion(); + }, []); + + // Frontend version (aligned with package.json) + const frontendVersion = '0.2.5'; + const buildDate = '2026-01-24'; + + return ( +
+
+

시스템 관리 - 버전 정보

+

플랫폼 및 서버의 현재 릴리즈 버전을 확인합니다.

+
+ +
+ {/* Frontend Platform Version */} + +
+ +
+
+
+ +
+
+

Frontend Platform

+

Smart IMS 클라이언트

+
+
+ +
+
+ 현재 버전 + v{frontendVersion} +
+
+ 빌드 일자 + {buildDate} +
+
+ 아키텍처 + React Agentic Architecture +
+
+
+ + {/* Backend Server Version */} + +
+ +
+
+
+ +
+
+

Backend Core

+

IMS 서비스 엔진

+
+
+ +
+
+ API 버전 + + {loading ? 'Checking...' : healthIcon?.version ? `v${healthIcon.version}` : 'N/A'} + +
+
+ 서버 타임스탬프 + + {loading ? '...' : healthIcon?.timestamp || 'Unknown'} + +
+
+ 엔진 상태 + + {loading ? '...' : healthIcon?.status === 'ok' ? 'Running' : 'Offline'} + +
+
+
+
+ {/* Release History Section */} +
+

+ + 업데이트 히스토리 +

+ +
+ {[ + { + version: '0.2.5', + date: '2026-01-24', + title: '플랫폼 보안 모듈 및 관리자 권한 체계 강화', + changes: [ + '최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 적용', + '데이터베이스 및 암호화 마스터 키 자가 관리 엔진 구축', + '사용자 관리 UI 디자인 및 권한 계층 로직 일원화', + '시스템 버전 관리 통합 (v0.2.5)' + ], + type: 'feature' + }, + { + version: '0.2.1', + date: '2026-01-22', + title: 'IMS 서비스 코어 성능 최적화', + changes: [ + 'API 서버 헬스체크 및 실시간 상태 모니터링 연동', + '보안 세션 타임아웃 유동적 처리 미들웨어 도입', + '자산 관리 모듈 초기 베타 릴리즈' + ], + type: 'fix' + } + ].map((entry, idx) => ( + +
+
+ + {entry.type} + + v{entry.version} +
+
+
+

{entry.title}

+
+
{entry.date}
+
+
    + {entry.changes.map((change, i) => ( +
  • +
    + {change} +
  • + ))} +
+
+ ))} +
+
+ + {/* Bottom Integrity Banner */} + +
+

+ Platform Integrity +

+

+ Smart IMS 플랫폼은 데이터 보안과 시스템 안정성을 최우선으로 설계되었습니다. + 모든 모듈은 Sokuree 아키텍처 표준을 준수하며, 암호화 키 관리 시스템(L2 Protection)에 의해 보호되고 있습니다. +

+
+
+ +
+
+
+ ); +} + +// Internal Local Components if not available in shared/ui +function ShieldCheck({ size, className }: { size?: number, className?: string }) { + return ( + + + + + ); +} + +function Box({ size, className }: { size?: number, className?: string }) { + return ( + + + + + + ); +} diff --git a/src/platform/styles/global.css b/src/platform/styles/global.css index 87a993a..33f35f8 100644 --- a/src/platform/styles/global.css +++ b/src/platform/styles/global.css @@ -60,6 +60,7 @@ body { a { text-decoration: none; color: inherit; + cursor: pointer; } button { diff --git a/src/shared/api/assetApi.ts b/src/shared/api/assetApi.ts index 852750b..2aefcbe 100644 --- a/src/shared/api/assetApi.ts +++ b/src/shared/api/assetApi.ts @@ -69,8 +69,14 @@ export interface Manual { export const assetApi = { getAll: async (): Promise => { - const response = await apiClient.get('/assets'); - return response.data.map(mapDBToAsset); + try { + const response = await apiClient.get('/assets'); + if (!response.data || !Array.isArray(response.data)) return []; + return response.data.map(mapDBToAsset); + } catch (err) { + console.error('API Error in getAll:', err); + throw err; + } }, getById: async (id: string): Promise => { diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index efc62f4..88e9a33 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -25,3 +25,18 @@ apiClient.interceptors.request.use((config) => { } return config; }); +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && (error.response.status === 401 || error.response.status === 403)) { + // Avoid infinite loops if we are already on login page or checking session + const currentPath = window.location.pathname; + if (currentPath !== '/login' && !error.config.url.includes('/check')) { + console.warn('Session expired or security error. Redirecting to login.'); + // Brute force redirect for simplicity in MVP + window.location.href = '/login?expired=true'; + } + } + return Promise.reject(error); + } +); diff --git a/src/shared/auth/AuthContext.tsx b/src/shared/auth/AuthContext.tsx index 690f7c4..d5d292f 100644 --- a/src/shared/auth/AuthContext.tsx +++ b/src/shared/auth/AuthContext.tsx @@ -4,7 +4,7 @@ import { apiClient, setCsrfToken } from '../api/client'; export interface User { id: string; name: string; - role: 'admin' | 'user'; + role: 'supervisor' | 'admin' | 'user'; department?: string; position?: string; phone?: string; @@ -25,25 +25,82 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); - // Check for existing session on mount + // Check for existing session on mount and periodically useEffect(() => { + let lastActivity = Date.now(); + let timeoutMs = 3600000; // Default 1 hour + const checkSession = async () => { try { const response = await apiClient.get('/check'); if (response.data.isAuthenticated) { setUser(response.data.user); - if (response.data.csrfToken) { - setCsrfToken(response.data.csrfToken); - } + if (response.data.csrfToken) setCsrfToken(response.data.csrfToken); + if (response.data.sessionTimeout) timeoutMs = response.data.sessionTimeout; + } else { + // Safety: only redirect if we were previously logged in + setUser(prev => { + if (prev) { + setCsrfToken(null); + window.location.href = '/login?expired=true'; + return null; + } + return prev; + }); } } catch (error) { - console.error('Session check failed:', error); + setUser(prev => { + if (prev) { + setCsrfToken(null); + window.location.href = '/login?expired=true'; + return null; + } + return prev; + }); } finally { setIsLoading(false); } }; + + const updateActivity = () => { + lastActivity = Date.now(); + }; + + // Activity Listeners + window.addEventListener('mousemove', updateActivity); + window.addEventListener('keydown', updateActivity); + window.addEventListener('scroll', updateActivity); + window.addEventListener('click', updateActivity); + checkSession(); - }, []); + + // Check activity status every 30 seconds + const activityInterval = setInterval(() => { + // Functional check to avoid stale user closure + setUser(current => { + if (current) { + const idleTime = Date.now() - lastActivity; + if (idleTime >= timeoutMs) { + console.log('Idle timeout reached. Checking session...'); + checkSession(); + } + } + return current; + }); + }, 30000); + + // Fallback polling every 5 minutes (for secondary tabs etc) + const pollInterval = setInterval(checkSession, 300000); + + return () => { + window.removeEventListener('mousemove', updateActivity); + window.removeEventListener('keydown', updateActivity); + window.removeEventListener('scroll', updateActivity); + window.removeEventListener('click', updateActivity); + clearInterval(activityInterval); + clearInterval(pollInterval); + }; + }, []); // Removed [user] to prevent infinite loop const login = async (id: string, password: string) => { try { diff --git a/src/widgets/layout/MainLayout.css b/src/widgets/layout/MainLayout.css index 0166124..27634a5 100644 --- a/src/widgets/layout/MainLayout.css +++ b/src/widgets/layout/MainLayout.css @@ -103,6 +103,10 @@ transition: var(--transition-base); font-weight: 500; font-size: 0.9rem; + text-decoration: none; + cursor: pointer; + position: relative; + z-index: 5; } .nav-item:hover { @@ -181,26 +185,39 @@ background-color: var(--sokuree-bg-main); } +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + /* Top Header - White Theme */ .top-header { height: 64px; background-color: var(--sokuree-bg-card); - /* White Header */ border-bottom: 1px solid var(--sokuree-border-color); display: flex; align-items: flex-end; - /* Tabs at bottom */ - padding: 0 2rem; + padding: 0 2.5rem; color: var(--sokuree-text-primary); + justify-content: flex-end; + /* Align to right */ } .header-tabs { display: flex; - gap: 0.5rem; + gap: 1.5rem; + /* Better gap between tabs */ } .tab-item { - padding: 0.75rem 1.5rem; + padding: 0.75rem 0.5rem; font-weight: 500; color: var(--sokuree-text-secondary); border-bottom: 3px solid transparent; @@ -211,20 +228,17 @@ position: relative; top: 1px; text-decoration: none; + white-space: nowrap; } .tab-item:hover { color: var(--sokuree-brand-primary); - background-color: rgba(0, 0, 0, 0.02); - border-top-left-radius: 4px; - border-top-right-radius: 4px; } .tab-item.active { color: var(--sokuree-brand-primary); font-weight: 700; border-bottom-color: var(--sokuree-brand-primary); - /* Blue underline */ } .header-actions { diff --git a/src/widgets/layout/MainLayout.tsx b/src/widgets/layout/MainLayout.tsx index 84aa2b7..8e73e66 100644 --- a/src/widgets/layout/MainLayout.tsx +++ b/src/widgets/layout/MainLayout.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; import { useAuth } from '../../shared/auth/AuthContext'; import { useSystem } from '../../shared/context/SystemContext'; -import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield } from 'lucide-react'; +import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info } from 'lucide-react'; import type { IModuleDefinition } from '../../core/types'; import './MainLayout.css'; @@ -16,6 +16,15 @@ export function MainLayout({ modulesList }: MainLayoutProps) { const { modules } = useSystem(); const [expandedModules, setExpandedModules] = useState(['asset-management']); + const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false; + + const checkRole = (requiredRoles: string[]) => { + if (!user) return false; + // Supervisor possesses all rights + if (user.role === 'supervisor') return true; + return requiredRoles.includes(user.role); + }; + const toggleModule = (moduleId: string) => { setExpandedModules(prev => prev.includes(moduleId) @@ -38,7 +47,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
@@ -75,8 +88,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) { const moduleKey = mod.moduleName.split('-')[0]; if (!isModuleActive(moduleKey)) return null; - // Check roles - if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null; + // Check roles with hierarchy + if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null; const hasSubMenu = mod.routes.filter(r => r.label).length > 1; const isExpanded = expandedModules.includes(mod.moduleName); @@ -188,7 +201,9 @@ export function MainLayout({ modulesList }: MainLayoutProps) { const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath)); if (!activeModule) return null; - const topRoutes = activeModule.routes.filter(r => r.position === 'top'); + const topRoutes = location.pathname.includes('/settings') + ? [] + : activeModule.routes.filter(r => r.position === 'top'); return topRoutes.map(route => ( - {/* Keeping this just in case, but ideally should be managed via route config now? - Actually settings tabs are usually sub-pages of settings, not top-level module nav. - Leaving for safety. - */} - + {/* Asset Settings Specific Tabs */} + {location.pathname.startsWith('/asset/settings') && ( +
+ {[ + { id: 'basic', label: '기본 설정' }, + { id: 'category', label: '카테고리 관리' }, + { id: 'location', label: '설치 위치' }, + { id: 'status', label: '자산 상태' }, + { id: 'maintenance', label: '정비 구분' } + ].map(tab => ( + + {tab.label} + + ))} +
)}
diff --git a/temp_auth.js b/temp_auth.js new file mode 100644 index 0000000000000000000000000000000000000000..49acaff6df5d8e7af49420b827e57d8d8e152bdb GIT binary patch literal 17580 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 literal 0 HcmV?d00001
아이디 / 권한 이름관리
-
{user.id}
- - {user.role === 'admin' ? : } - {user.role === 'admin' ? '관리자' : '사용자'} - +
+
+ + 사용자 데이터를 불러오는 중... +
{user.name}
-
{user.department || '-'}
-
{user.position}
+
{user.id}
+
{getRoleBadge(user.role)}
{user.phone || '-'} - {user.last_login ? new Date(user.last_login).toLocaleString() : '접속 기록 없음'} + {user.name} +
{user.department || '-'}
+
{user.position}
+
{user.phone || '-'} + {user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'} -
- -
등록된 사용자가 없습니다.