From daf68d98fab69b48ec833569d58f67ec4f1eb2f7 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Tue, 19 May 2026 01:25:21 -0400 Subject: [PATCH] feat: add user profile and admin management features --- vps-monitor/.pids | 4 +- .../backend/__pycache__/main.cpython-313.pyc | Bin 33944 -> 42692 bytes vps-monitor/backend/main.py | 175 +++++++++++- vps-monitor/frontend/src/App.jsx | 26 +- vps-monitor/frontend/src/api/client.js | 39 +++ .../frontend/src/components/AdminPage.jsx | 259 ++++++++++++++++++ .../frontend/src/components/Header.jsx | 39 ++- .../frontend/src/components/ProfilePage.jsx | 127 +++++++++ 8 files changed, 646 insertions(+), 23 deletions(-) create mode 100644 vps-monitor/frontend/src/components/AdminPage.jsx create mode 100644 vps-monitor/frontend/src/components/ProfilePage.jsx diff --git a/vps-monitor/.pids b/vps-monitor/.pids index 698ed21..4e28a64 100644 --- a/vps-monitor/.pids +++ b/vps-monitor/.pids @@ -1,2 +1,2 @@ -7942 -8002 +8423 +8473 diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc index a1830c657e2dc05fcbe6107b6636e43303cf9b38..327310135d2651ffb61d1b666b65a4429ffaf553 100644 GIT binary patch delta 14941 zcma)i3v^V+k?6g5z8cL>e<6*;l@LfHgd{)$1PC!m0wj@vVsreN1IY!zVbDcB7RTMgLdDcFrpgH%(L98RnuxMQ>>2m1$R01Q=F9>rITVLuvuK zDn{4wP`b^Gu2XV27fJoN>S}2VL+xN4w?fBl&NitX1%PoIq^+!Wr?UXoeVeph>Sz*3 zm$R$)rI>=Gyqv{xjW$c2(vC(!sq6g;Q?{LPd88e6Jp1cvQp2?4~z|SER;wqok3nHE8>ZBYdRa}_6S)7X)H~a6Y%crlRUU7EY=9EkNF(O5yk;!cBdA5NiUFEo>CiIdDjLpSL|@FRq>~n#4vD+3>F9gb zqNY})+=^fUi|vR*&<=loFzVcAxjCcdnH@?u+Zo47>oZ#1#AFQ}SZXtojaX*v?IvX3 z%-EyEqzyq60!3^rA?t^B-h~xibamD?MgFds{5#e&=41!fFA#Va;t=%0UtD05^_iWD zz`KA&Cq15B-SWvS`j7>JJ@9wm2mr=x&brVA;F&IkPap6(*o*{ZFM?0zb^xmp3}9g& z0=5_jP?Aty>v5O&n7qs7_n-EWq1bxgJq%<~qi=XPtkYt-l9+E z7w}s8czzkLV^Ggv27M`iHUBsb=Py?qy2obdjYU<{RRv$us9eW+`t3#48N_nR*9Rp;>ij{sTlV|u<3$xFBD?%P zFVr9b9WTxkDl2K1t&o?Y$6PvQE8LLi$UhkH8)E#}tEXE`R#pZ4Vq3e^YEO`iJ&#iB z{K_PQn7OKx@>yytT$;mXm-Q)j_wO-f{_>J7Evj#4ZK)G(a*kzC zexuH?WuxYeCIQPEcr0&JVY$W7YNGRE0lic5YX_03U`w{|f-dxjPw_&lgVg&6O?v`gG=!b|KUNXrkC>EHOKr zG{Nf;hjQ9ADKef%YzRP9GZY*d^~12AMifqB+OQ2CFU($G5Cypim3NTfB!Gk<(?40- z&9^2dFsg^KxP3uy095Z15Go6!atDcD1)qrl8i+jNJ0&yDUx12VO&_j&gAaY;K$zFT z#kFfw27+TCpGClibQ$Z_O21!3EDnf#9>9X>H3XR{WW7&7^}itr41&IFzo3r7_N8Ur z1^i!A*RmGcT4u9(g?^z5Xz+1qIIdriST>-CmsIrcm#qz{yT_`e_MPpmeac1^xAyjQ zCAaY2?d`CwVIPalVhb4pE(BL5wmnmI+UJ!4`z%s0;V(cbeddgVXkla4fjkHGA8_D! z{Kzn|EOX}UE5nzDXNue3R=s{IV(y+b?+u&xM$G#!s(!h+{Gxwi?dQ&26+X~#hU_ba zR|-G!XF~JKbq=-arrO}h))BT#&e7%7zu?c&jOC6!*+3B0%3il`NG4_YVd~u5H|W^8 zt*5u6Z+jPrt+c@^-Kgs+We*Eo)@U4G$SMl>%kznAZImzBKB`bk%pv#aC4` z4F|&Jff@b4TxP+Hx`6ros18{{-0Ft*i3bP9#*Zd~jXzOEPxKimUy)C(rTT0y-_Iv@ z68NEho;oV>vV?eeEvaxOdv)}Nt7ugP&Z9oTC{ai-$Oi^#6!b=!EmXltYM4_^_n2kh zimg6!+C_%sAuL7t8iGb7stbBgdVQz8c5PI5Ty}v*``P?OHU80IPk>;GFRFD9dtmX& z8u)h?=8A>zTlnQ-l&@)P22jWtkV2o5@f~^HFSe~`aT9<^aOJ*t>Bi1$J z9lz9B=PZR+LYG35{)lDClp}0eHfyN~TWTVf+8KTAT)F-0wO_Bjxc17XOPgjhOT(F^ zGwPP>9stTp2%gbsJj{t|l^DmZ&VwG~PX-j&vb+JdtccEUR#TxOkG|WvzGxqxV$)z! zBmu2izp6n)f3diw87$iFSYX1!kz@oULI_kNqFeR4M&ugNGg>3YVyzlc5rVE~S&@0hI5<-X9c=zlDt#Sv4<m1bH2uTIC3Q#~GX z-pWX0{)EM>7;?lF;1592J-w}2uW9^$SY*Q3`KWfpb!J%hf_@2sF@mv)GVbIwC^JBx z+EQ2Xt52-PijNTdd3ttB9&crmw;|$32=eJ)T01)b3X0JTkKYH1IpPW^P9(Wkadi-t-jG-}V~X`Gjxd*F)ssS_E+b zS~vDcDV{G8CFl~h5F+I>{dX3;NbLc;J>sRa6LqxU+|PXECvG}&^QP?gQQmM0Tc;9 ztp`Gw5l|b^?}3=g4df|wqPJ4sbq3T_Hu!=_F5Tp zaN8^BkN1lZLgmu&9{ry6+d1wO_q;~rx|VTVCXgND*Q$>4WqqNRZU(_LV7)&zTA??;LNxqcdD=yf8N5xbWm1h?WbNM>1=!Ya^L! z?qn~Xvn`%0ICf8?$}!J#DwCQ1W%ufOXmH!$_`Al4!7;0IgmsSeko!*lqRFN7#FT^j zF0H+~X1tBo_crr|)Z4o@R1flwS?XM)o;tKfbu2D&qa}+!sAITw?|%`*JOqj~0B_|< zZe*{Jv?Ayr@qwL)yNx}}O7P4|sB#iFwvs?)Z?52Kr-K#cbaa0{b*!}MVN{SK6Z>}M>kRlyhs%QdnS1ushIP)j-?vFMs@Dvvil_HCDV3PP8P^2 zf-tR~0vv=9sF>`qp~S^?_=&rP)brdrj>Q#BGh?a=2__7R*8tgX;LndeqF!n_Q}N{5 zh;hmImOI9LdaJ*EwLW{I;f3aBo2SaB)q zQ+-!g-B#DqCwnWinYtO1>NX-%=bkkU%+TQ0fdDvBR@~+H02@DlLNO|g$fOCR`y+jO zkGPoeTa@GnsXR!NuZU;EX>E1BRu9_zJV(#hi*#L0KHZjSGxYNkkISr_4nfKC_zW68 zoMXb=1$*Ey7y_OB@G?WPsLTx!F?~Ja14Al{I8Smd)1$i+FsWlD4zC+d zs%iD$o!k(=0v}MuH~Vz`u<&Qj=pwws`HCddmrlr zFD)=*B`W!=_>qq#v2ek=qgv%G6BT?X?M9~O1fv}?2(`=rEB2b1@56{&fwi>=m_b&e zGIoeiD~gYqi>My2L(q&XQ6?G;FvT&2wxb=d48vTcn7ke|&p9V;eu7ZkjXYO?8F~>Sm1fvD2sw$I*EMXUdxxn#nAl zQ5S!Z37F3prr|#FquSmw6|~l?Zq*ywc->fzvV|~ZR2RQ4i1LGJQANTLya^eA)sTa! z*y4rkCH_ll>Rus-+oSRe^^pFBcmwv6Z?$61AF!EJoBe_a z&Ah6k;8lawK-((^d_m%sS&%g9BCe5JR|5nBrXeMaQc_nCHPwzSjwMZ#R~+e?({3dd zIs{xHSV+Y1m}L)Ps&_ML(kRrD&mgz}AfCpHX-hVX+){I^$Ugn@qT8b-wJYE79B>_tJ#hc^-ZI}QKTD-8N9UBs(Tzp{)8Jnb^J*z z7~0V1;pc2Cd9P}pY6Hm3f^>MZht3@~@+!1TEUmMpB3Gr^$J2T#w>-@#X~FzylS>Vz zP#-r+te7xL8NubixJ$7}Ctw9Jh)yow$5)Rm#@R=~f*8EF4l5Qbr3u;~uNT7KSD=1u zee$BKw|&>nkfZg#b=B2UBevMP$wus~82~IhbC*eNVCtb!RE2p@REww)?uUG*<)J~C zV*kgEtEfQHTtzuzw*m2-dONf(MD;iZQbU^8%;ieoc5RUGwAb~wl#`{@WKf-UB&il_Nu|2X#GN+Vo$(Vc(wiDh0&7G?^Tzn zwGi6TKem)(W+K#67T8pZKW*mBSrb{$wN7d#{j?`ys(?e3$vTlWvGwwy@vZTVWzBuT_$;J#VPj=q z?76Q|Wf?xua+x`osxDX0>)@Ul?w_;rkY=W2Q^eXbYuz5UhPFqnoo}xUTe~i5*nWde zoLz7)mn*d0&oN{fexD7c3DvxXD-oyEFLvCqRm|E}{nWPV`s(X}8%=K(zGb^<3){M9 zZTrHueG%IMIKfRBUhKgaBNwM~ruM&F8nM+QO8;Wl1C!2VzGnrt_n?FQhI<;5Q9I9> zG};eL;LO1>{N4&3`|-&0Er+^QRNdUQ(k*ZqaOrRp&~n+?bo(hv9CTX@I40s2r5 zUK^nVM|rjx8WFn&0jpx$lhK17 zJb@p7HAOJ*jR~Q&e2_c@znH;kaDl8ofxbF;1+KBT9e!U`k{CTX1a%8Xyme@)@Ogob z4B3m*$9fkvmgM8m_5AdWp&LR7MS>eih@#}~6p!#LBxE-s|AM7oBe;tI??hrLe=Pk6 z07#%M(5z1EVFv=#5#=smfmgu6f&%o*wGlWM4~`JB;m+uZogs&7_D%#G8BkGBcEY%y6z zKNEXJ-@$%V7?1vqh(!g%i)nvwIN)D!0x&=v7|+*_0Nr<||9Il>_)l4;e;6+3U#I^* zy!61s^b-1BplB!-SOqpaY+{}U1Y7&V@xdG71Bbgm0&=`I?HySse2N^me9Mae0j7vN zhQs`rEL1&O>0TfTyXP6kY1)E9X%(R`8$$vJ65^2uC?lRXKt_0!H8MjO6mWUGGGQ?d z8G|-In*QSG^PeW_W`9-aolh1O1v&$~FmDM-bf33Hc{P)>fQ2Yy!7$#(2Av2VK9Bw4--}UH%Jpj$N#8^84g$=Xq|A&#S>*+j zatDcih+r1MFA$tTz-G$;C1vH)R-Z{fh|=rf7lRjIzW(p@H_t8-x)uX{H_5`)Qr#Uw zHZTsZ8@$D50Eh3V*xLuW_`xBcdvK7jeal2$g7j4gjv@FF)tuYE1QVldh*8~97o?I# zJp_d!n9x)FpoyM4R}wPAchnjqsrAEe8Hp!wmf&?3v0|P21k5=5nGb>cFGwJbo*i;| z10FZQc#BjZ$VKp92=Wn}Mz94zD*`-hC>F60OJ7FtB>++FsEZapVGPwFrVKHrBeILY z_V7bS7xJ2`uwpxcE(E&}^dUHa;2?q{2$+!#V5uDeOAkMRC1&9+VreJN2Xn&{`1u@y zDFin_7dZB-9r8MUzXbrUJw`{PW_0}{o}r;(8Gh#?lh?4CnSl4P#LOk$zQQFc&I|ll zGLrUx9n;FqU@^`2nh^X{vlG>@eRMXfw*6@bOoOupjQ%l(Ex|+Sd5B&i0^&$9CEN{4%OV5pe z)RE8c<|kV2bNEIXSI!GqoL|KWn$H%VFPt$HM|c|w4s`I#TYOKQzR%uBU!qja7tvSG zuLk?PGe6`R9Fj{7jkHP}XE^>C1ix2cHmp{4VJ8M@3c z&a2@4uAy`uuJUd3T27xek^AiF$tNNOD`v7*gmty|bx?tPKuRPjPw){VeA&BMxpUb$ zb1NI~>56&nT&8(m1@Cu_mU#`lAI9$i5^hF@DyYpWY)b*fl{~vs`rrH1i delta 8049 zcmai23v^W1b$$2ESEC;S4H6QcK?q5Mkc2D={Q&_2>koQ}p9Y4}%#$=Qni=2shL5m4 za`3`&z~yskLK@;=o48@EwzfM-96L@8F?Q?N?jj*=@C#|(x@)!gvnouw>QJZcIrq(s zq_KfnnmzZObMCq4bMLw5>B@WJ*(b%U+nJd*0X{XKrSJXj&Qn?W;?J$8%FeHK2ojOf zy5@Bj&;mZTbrp8br}H}(&;|TCy=!6TBD$#4K^^=#qib)0Vl)r_Fzs*fW<)c3LrmdY#zk zRl17%ccbMqKm8tddEOj0Pt1sx)rwMKfw0#|t6kMaLS0;tKHyp~?WrbTC=#Uk1%kA| zRa9rtg=j6OE(GeLY1BGSbpUnoH0nxDEduJ2Y1H~CCl>>G=`?Z!rdi6q!q3*5N(5r)ts`HQyxG$pqix`PF*L}f?OS!Ed%OGPF*k6yK-FfU2`E@HFZRf zo1R;kU0Q2=Kx*KE?XElsaHG^DH8+ZMgKIXoO&(M{8VVV1FB5szVw}(4@7%6DuqbD6_NK@>=Cs6Lp|!KZA3DbJzFg`G+1u4#Gg2mYTU ze{r|eBlR|lQlF~Tf z8MC)0XKzhNO55%+d;5&p+Y&Z5Z;g9W>8icQe515u#{BItKZcQL-I-h+x8vO{FiL|n zH0%ToFQbOGnHu_BEAOGjEV*WAaV4VAu`6N6LSdmG1^H3f?JB>gE;s7hOOiWbS3+Ck zo`kl0%oiok3|n?5X15L{XWuU--WeL)2@O5Iga+T}iI!#S(uPf_JbsOs4}Wn5fepR4{Jy3EqP6kS6jqB9&h42RRkPPNWq ztJ7a62KImHi(}4=10tErp2_+dsf@X^zfWe>gOq0Q2UXexBihVza~9iMa9D*?)KIkB2up0kF%pEExL1$!@AXe<1(1#=2&+fg)S`bMN7AX9;&BRnBo&&WR7r7y7& zw9R9qvyax+q9AS;x)K3Rqm2j+2)cbZ;;T9{ldCpx$#q{LxgKd>Wm7jYx|up>qwZ2S z7~yN;ifLzJjcFn1LmEOqd=oxo&N|wy%k*&{%I($cV(zllcUL2!8icLzy`2F7p~%WP z(gSd`N7p6Ab**n5KxvfJJ^o6=b|4#J5C_{3c#d|&((^_LnZuO)2WMjzHS>t9x_v`* z1RjRid-;D&^4aFHX7h}UIjLavQp?K8r-=<|D3r$~p9 zOsImlp(AdEEfDhf+z~~l3Jn4MLae7`Cs~&aq-La{fDDt9ieZe_a&0qISv@{o6IFnJ zG4@gE$E2_shU4PkE~j-$0}bH(F*fO(+lZlY8o4qWgfXsnK+z0{MtF9>M~|14Te?52 z&~c!@6kEM)rx>k)A)Pt}d|OZLeSQ?s0;g%(>IZO61VA(SB4c3%9Kw_TWruA3AjCy= z3x#B1{v^tLArvvQKK-w&nm^Gs{75$R|?=K4f-o+v0BP z-q_dQvE{)Y@Kv9KEKAsP2$MC_-wZF6aYZ$>PY%-4Aov@0q;?~zioH`CAmmBbu=4pL zRMN{2uhXE3l`Aw7;fmv`CTPX!`Wo>gfz{P_M|GooGB=9BOl}!Hi6b5jEEjYZfMyKJ z2UWhoAv#?26!Q2+#x)bsS)=c@S$OgxV^j{R{BY3cKX6}{Z~g^J86q@5e+VP`BYdHWQ9Z(o2>cYi1S3c-UlYyY z3xiER#_`JtXg>W3fMyJb6qUY$&)jnBl;xBM`&DEm&8I)b$1-la6^7jMr13W0c&_pi za4pQ${`cf(vGNV7fz+_`9p2t6IR8zAw-Ejo;qL(MSgMpmiYkvys|)-l#A>jG@^WCM zH7H(-P>0aOHg`G(khj5^_9Z4t-~rK0GNmC(-$lZE2wa7Ix>n#kcD5IDvz3>K3r_V(bSw6pN68HYkX_E2@6`4eaY z{#0O}?ObXmO=kqb!#%da zrsram`A0HK7RlHs%F~pb+?B@qsRJj7{W*-#0&y^dz1y>p?eXUjI}i$stx{U0AxH-C z4D-(bNp#W?wEMBF&^vir+50v!lRGTu^g8Wl?;F*7grb=9%GDe*hm#5O9)aaSwUvp|Gl%M?K1D z_y9e@sZb#mPe7&5prC?q8UQNZ7dXb-0joCLS2M3NtL`i8U@NH>gj0|p1yA^d@O!gh zO+VgrtmT>||7`U&%lsQzh1c^7ug~$`G8wXMlY$}5#`1@1*x{j8QpjEzs)sFdYsg-Z z%rU6J;2Pk2y9B7a*{z5@AF_lL{uvNziA3NH#yUXQ`^OvxPrJZ2)hsd;30v4nubn-- zYrY8*WiYWX9$LgEM(ym;I$N7jGD&9KB!k&WO`>GM#X#7KzsWos1`JYKf-2c~p3~V2 zzVDQO2gIC&#~;FU4xT9fQ-cR=>XJsv)66d~AQ#yaa($F1?wddz&+G8PvGfo7Jwa7* z_@SYIOV+C@6f-~7Lr?)+rvv~RN1oMZk#7WoX7P^7-hGfmt`!qWdCGa}jNB5#ivf8u15d7U=sve3c2{e$LaEna9oyw`XP*{=jgxw&#~wy;rTh zEMuf}b`^+>XH!w=sw8#FrPL z(&oVPi|mCFM-JC)<(i=qYQ)X558O&^ZIf46;?7CE!9cst5_yBnYc{mDvtb$f2{4nD z6~>ws%Pns`bAkUdd=>ofCqy7am|bwD;I<_eJ$Rlh$3=R<0sKwm8q zeos7n7JSpdu6zM-E+cn6%-8d0P5-s6`oidiz1M8jY|Y^sIA7}Fzij8*3r`uH2LN$7 zf?nX-Fy?J~a+Sm$v4JsiyhUCNlYT+i@WU(k+ppKaa+c=CUVM0xIP*{`=J{g$Jn;CS z%GCJ0{is9y2f{vnv^aVm*u8WSPIVw~^RVTDj$x`RsRu&ptNfG?>6D*|aTMY~{Hlq( z1oFLutqMJgOPFXY_xKB>pxc^NJ_!549~|M=xq`I-&q_SpR@nz{su=H55dIZm??g{P z+deq)uZ9Il?`bjcQa#@J_)_sd3482uXW>j=Z(_jce^{Ge{j5rEMbPHmhLxG z)ga!-j^CUpDXfBqiZ;Rw`XyUf{>jp4iYc$b$}`$%Zd5bQZ$U_!M}LJjngR6S>t<$o zLqXN!56aXHgDj-|Za&v=uIXBKbL?+U9=e|!TUc3i)!p4d zi`T$9uJQlotdCv+eTA+!O&;h*v|c{=qzoBLUs>gcQ~NA=a{ z23V{iR&-{-c%Q7CIa^ZIb8mN2Uk=&LvkRlix5Jy;aW=jp(iryu-ONaJb2GAjjPNqT zPY_;5cm*MXFy+5Z_dmt;S5fGP2tN{pCP9C5#1}_B`8F8o$#=jfxdF!DxMZlt^2)ht&u#$5- zTCfq}UTKzL51j9?pQ0Hw9RZ86mKUd2j`@AQfPBD1D$}B;cgYZuXc7!p6HiWeZa}jub%fr!0gg*h$%wbqHsA{IM zeLg>>mB?@)BQrWA1B<#9=%t_+fhJb}hcoTQQ6GYYumgb?%)L0m8l|^m3XXUg<5xGl z+=K%9oq7~GrxBh5fQvpH*0S)zo(hC!iYnoNU;oDhy@ayY5I#is6hO0vg415$^f#~r z$i0KW_m34*fDIh)q$u_=ydTia`n^pT14T20BPzePc>SeVJF#%pLFjOZr-g5h)b}}S z3p68i6=9fZE?9S7K%%98>#96mQ8?oOuz-SUjzb{x$GH>lB zY{mJ9+3V-q#9PJ8`^=rTS!6Rgx%#$%zbIWjDdKRlL=a6+R6K%eXDz aFUl>@Nt3zk`Da&>j99^i=S3qnjQ None: CREATE INDEX IF NOT EXISTS idx_vps_stats ON vps_stats(vps_id, ts DESC) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS login_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + username TEXT NOT NULL, + ip TEXT NOT NULL, + success INTEGER NOT NULL, + reason TEXT NOT NULL DEFAULT '' + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_login_logs_ts + ON login_logs(ts DESC) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + conn.execute(""" + INSERT OR IGNORE INTO settings (key, value) VALUES ('registration_open', 'false') + """) init_db() @@ -210,6 +242,14 @@ def update_vps(vps_id: str, data: dict) -> bool: return cur.rowcount > 0 +# ─── Settings helpers ──────────────────────────────────────────────────────── + +def _get_setting(key: str) -> str: + with get_db() as conn: + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else "" + + # ─── Auth helpers ───────────────────────────────────────────────────────────── def create_token(username: str, role: str) -> str: @@ -237,6 +277,27 @@ def get_current_user( return user +def require_admin(current_user: Annotated[dict, Depends(get_current_user)]) -> dict: + if current_user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Accès réservé aux administrateurs") + return current_user + + +def _get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _log_login(username: str, ip: str, success: bool, reason: str) -> None: + with get_db() as conn: + conn.execute( + "INSERT INTO login_logs (ts, username, ip, success, reason) VALUES (?, ?, ?, ?, ?)", + (int(time.time()), username, ip, 1 if success else 0, reason), + ) + + # ─── App ────────────────────────────────────────────────────────────────────── app = FastAPI(title="VPS Monitor Backend", version="1.0.0") @@ -400,18 +461,24 @@ def auth_status(): @app.post("/api/auth/register", status_code=201) def register(body: RegisterRequest): - """Enregistre le premier utilisateur (admin). Fermé ensuite.""" - if len(load_users()) > 0: - raise HTTPException( - status_code=403, - detail="L'enregistrement public est désactivé. Seul l'admin peut créer des comptes." - ) + """Enregistre un nouvel utilisateur. Ouvert uniquement si aucun utilisateur n'existe + ou si l'admin a activé les inscriptions.""" + users = load_users() + if len(users) > 0: + if _get_setting("registration_open") != "true": + raise HTTPException( + status_code=403, + detail="L'enregistrement public est désactivé. Seul l'admin peut créer des comptes." + ) if not body.username.strip() or len(body.password) < 6: raise HTTPException(status_code=422, detail="Mot de passe trop court (6 caractères min.)") + if any(u["username"] == body.username.strip() for u in users): + raise HTTPException(status_code=409, detail="Ce nom d'utilisateur est déjà pris") + role = "admin" if len(users) == 0 else "user" user = { "username": body.username.strip(), "password": _bcrypt.hashpw(body.password.encode(), _bcrypt.gensalt()).decode(), - "role": "admin", + "role": role, } add_user(user) token = create_token(user["username"], user["role"]) @@ -419,12 +486,15 @@ def register(body: RegisterRequest): @app.post("/api/auth/login") -def login(body: LoginRequest): +def login(body: LoginRequest, request: Request): """Authentifie un utilisateur et retourne un JWT.""" + ip = _get_client_ip(request) users = load_users() user = next((u for u in users if u["username"] == body.username), None) if not user or not _bcrypt.checkpw(body.password.encode(), user["password"].encode()): + _log_login(body.username, ip, False, "Identifiants incorrects") raise HTTPException(status_code=401, detail="Identifiants incorrects") + _log_login(user["username"], ip, True, "") token = create_token(user["username"], user["role"]) return {"access_token": token, "token_type": "bearer", "role": user["role"]} @@ -434,6 +504,93 @@ def me(current_user: Annotated[dict, Depends(get_current_user)]): return {"username": current_user["username"], "role": current_user["role"]} +@app.post("/api/auth/change-password") +def change_password( + body: ChangePasswordRequest, + current_user: Annotated[dict, Depends(get_current_user)], +): + """Permet à l'utilisateur connecté de changer son mot de passe.""" + if not _bcrypt.checkpw(body.old_password.encode(), current_user["password"].encode()): + raise HTTPException(status_code=400, detail="Ancien mot de passe incorrect") + if len(body.new_password) < 6: + raise HTTPException(status_code=422, detail="Nouveau mot de passe trop court (6 caractères min.)") + new_hash = _bcrypt.hashpw(body.new_password.encode(), _bcrypt.gensalt()).decode() + with get_db() as conn: + conn.execute( + "UPDATE users SET password = ? WHERE username = ?", + (new_hash, current_user["username"]), + ) + return {"status": "ok"} + + +# ─── Routes Admin ───────────────────────────────────────────────────────────── + +@app.get("/api/admin/settings") +def admin_get_settings(_: Annotated[dict, Depends(require_admin)]): + """Retourne les paramètres d'administration.""" + with get_db() as conn: + rows = conn.execute("SELECT key, value FROM settings").fetchall() + return {row["key"]: row["value"] for row in rows} + + +@app.put("/api/admin/settings/{key}") +def admin_update_setting( + key: str, + body: SettingUpdateRequest, + _: Annotated[dict, Depends(require_admin)], +): + """Met à jour un paramètre d'administration.""" + allowed_keys = {"registration_open"} + if key not in allowed_keys: + raise HTTPException(status_code=400, detail="Clé de paramètre inconnue") + with get_db() as conn: + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + (key, body.value), + ) + return {"status": "ok"} + + +@app.get("/api/admin/login-logs") +def admin_login_logs( + limit: int = 100, + offset: int = 0, + _: Annotated[dict, Depends(require_admin)] = None, +): + """Retourne les tentatives de connexion enregistrées.""" + limit = max(1, min(limit, 500)) + offset = max(0, offset) + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM login_logs ORDER BY ts DESC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + total = conn.execute("SELECT COUNT(*) FROM login_logs").fetchone()[0] + return { + "total": total, + "logs": [ + { + "id": row["id"], + "ts": datetime.fromtimestamp(row["ts"], tz=timezone.utc).isoformat(), + "username": row["username"], + "ip": row["ip"], + "success": bool(row["success"]), + "reason": row["reason"], + } + for row in rows + ], + } + + +@app.get("/api/admin/users") +def admin_list_users(_: Annotated[dict, Depends(require_admin)]): + """Liste les utilisateurs (sans mots de passe).""" + return [ + {"username": u["username"], "role": u["role"]} + for u in load_users() + ] + + # ─── Routes VPS ─────────────────────────────────────────────────────────────── @app.get("/api/vps") diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index e22ee06..9b49d8a 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -7,6 +7,8 @@ import AddVpsModal from './components/AddVpsModal' import EditVpsModal from './components/EditVpsModal' import StatsModal from './components/StatsModal' import LoginPage from './components/LoginPage' +import ProfilePage from './components/ProfilePage' +import AdminPage from './components/AdminPage' const INTERVAL_OPTIONS = [ { label: '10 s', value: 10_000 }, @@ -20,6 +22,8 @@ const INTERVAL_OPTIONS = [ export default function App() { const [token, setTokenState] = useState(() => getToken()) const [username, setUsername] = useState(null) + const [role, setRole] = useState(null) + const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin' const [isFirstUser, setIsFirstUser] = useState(false) const [authChecked, setAuthChecked] = useState(false) @@ -63,6 +67,8 @@ export default function App() { setToken(null) setTokenState(null) setUsername(null) + setRole(null) + setPage('main') } window.addEventListener('auth:expired', onExpired) return () => window.removeEventListener('auth:expired', onExpired) @@ -71,12 +77,13 @@ export default function App() { const handleAuthenticated = (accessToken, role, user) => { setToken(accessToken) setTokenState(accessToken) - // Récupère le username depuis le payload JWT (base64) try { const payload = JSON.parse(atob(accessToken.split('.')[1])) setUsername(payload.sub) + setRole(payload.role ?? role ?? 'user') } catch { setUsername(user ?? 'user') + setRole(role ?? 'user') } } @@ -84,6 +91,8 @@ export default function App() { setToken(null) setTokenState(null) setUsername(null) + setRole(null) + setPage('main') setVpsList([]) setLoading(true) } @@ -111,12 +120,13 @@ export default function App() { return () => clearInterval(id) }, [refresh, token, refreshInterval]) - // Extrait le username du token stocké au rechargement de page + // Extrait le username et le rôle du token stocké au rechargement de page useEffect(() => { if (token && !username) { try { const payload = JSON.parse(atob(token.split('.')[1])) setUsername(payload.sub) + setRole(payload.role ?? 'user') } catch { /* ignore */ } } }, [token, username]) @@ -191,6 +201,15 @@ export default function App() { const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0) const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0) + // ─── Pages profil / admin ─────────────────────────────────────────────── + if (page === 'profile') { + return setPage('main')} /> + } + + if (page === 'admin') { + return setPage('main')} /> + } + return (
setShowAddVps(true)} refreshing={refreshing} username={username} + role={role} onLogout={handleLogout} + onProfile={() => setPage('profile')} + onAdmin={() => setPage('admin')} refreshInterval={refreshInterval} onIntervalChange={handleIntervalChange} intervalOptions={INTERVAL_OPTIONS} diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js index 6916bce..0dacf45 100644 --- a/vps-monitor/frontend/src/api/client.js +++ b/vps-monitor/frontend/src/api/client.js @@ -113,3 +113,42 @@ export async function fetchVpsStats(vpsId, duration = 600) { const res = await fetch(`${BASE}/vps/${vpsId}/stats?duration=${duration}`, { headers: authHeaders() }) return handleResponse(res) } + +// ─── Profile ────────────────────────────────────────────────────────────────── + +export async function changePassword(oldPassword, newPassword) { + const res = await fetch(`${BASE}/auth/change-password`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }), + }) + return handleResponse(res) +} + +// ─── Admin ──────────────────────────────────────────────────────────────────── + +export async function getAdminSettings() { + const res = await fetch(`${BASE}/admin/settings`, { headers: authHeaders() }) + return handleResponse(res) +} + +export async function setAdminSetting(key, value) { + const res = await fetch(`${BASE}/admin/settings/${key}`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ value }), + }) + return handleResponse(res) +} + +export async function getLoginLogs(limit = 100, offset = 0) { + const res = await fetch(`${BASE}/admin/login-logs?limit=${limit}&offset=${offset}`, { + headers: authHeaders(), + }) + return handleResponse(res) +} + +export async function getAdminUsers() { + const res = await fetch(`${BASE}/admin/users`, { headers: authHeaders() }) + return handleResponse(res) +} diff --git a/vps-monitor/frontend/src/components/AdminPage.jsx b/vps-monitor/frontend/src/components/AdminPage.jsx new file mode 100644 index 0000000..c2c0cc0 --- /dev/null +++ b/vps-monitor/frontend/src/components/AdminPage.jsx @@ -0,0 +1,259 @@ +import { useState, useEffect, useCallback } from 'react' +import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X } from 'lucide-react' +import { getAdminSettings, setAdminSetting, getLoginLogs } from '../api/client' + +const PAGE_SIZE = 50 + +function ToggleRow({ label, description, enabled, onChange, loading }) { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ) +} + +export default function AdminPage({ onBack }) { + // ─── Settings ──────────────────────────────────────────────────────────── + const [settings, setSettings] = useState(null) + const [settingsLoading, setSettingsLoading] = useState(true) + const [settingsError, setSettingsError] = useState(null) + const [toggleLoading, setToggleLoading] = useState(false) + + const loadSettings = useCallback(async () => { + setSettingsLoading(true) + setSettingsError(null) + try { + const data = await getAdminSettings() + setSettings(data) + } catch (err) { + setSettingsError(err.message) + } finally { + setSettingsLoading(false) + } + }, []) + + useEffect(() => { loadSettings() }, [loadSettings]) + + const toggleRegistration = async () => { + if (!settings) return + const newValue = settings.registration_open === 'true' ? 'false' : 'true' + setToggleLoading(true) + try { + await setAdminSetting('registration_open', newValue) + setSettings(prev => ({ ...prev, registration_open: newValue })) + } catch (err) { + setSettingsError(err.message) + } finally { + setToggleLoading(false) + } + } + + // ─── Login logs ────────────────────────────────────────────────────────── + const [logs, setLogs] = useState([]) + const [logsTotal, setLogsTotal] = useState(0) + const [logsPage, setLogsPage] = useState(0) + const [logsLoading, setLogsLoading] = useState(true) + const [logsError, setLogsError] = useState(null) + const [filterUser, setFilterUser] = useState('') + const [filterSuccess, setFilterSuccess] = useState('all') // 'all' | 'true' | 'false' + + const loadLogs = useCallback(async (page = 0) => { + setLogsLoading(true) + setLogsError(null) + try { + const data = await getLoginLogs(PAGE_SIZE, page * PAGE_SIZE) + setLogs(data.logs) + setLogsTotal(data.total) + setLogsPage(page) + } catch (err) { + setLogsError(err.message) + } finally { + setLogsLoading(false) + } + }, []) + + useEffect(() => { loadLogs(0) }, [loadLogs]) + + const filteredLogs = logs.filter(log => { + const matchUser = filterUser === '' || log.username.toLowerCase().includes(filterUser.toLowerCase()) + const matchSuccess = filterSuccess === 'all' || String(log.success) === filterSuccess + return matchUser && matchSuccess + }) + + const totalPages = Math.ceil(logsTotal / PAGE_SIZE) + + return ( +
+
+ + +
+
+ +
+

Administration

+
+ + {/* ── Section Paramètres ── */} +
+

Paramètres

+

Configuration globale de l'application.

+ + {settingsError && ( +
+ {settingsError} +
+ )} + + {settingsLoading + ?

Chargement…

+ : ( +
+ +
+ ) + } +
+ + {/* ── Section Logs de connexion ── */} +
+
+
+

Tentatives de connexion

+

{logsTotal} entrée{logsTotal !== 1 ? 's' : ''} au total

+
+
+ {/* Filtre utilisateur */} + setFilterUser(e.target.value)} + className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-indigo-500 transition-colors w-44" + /> + {/* Filtre succès */} + + +
+
+ + {logsError && ( +
+ {logsError} +
+ )} + + {logsLoading && logs.length === 0 + ?

Chargement…

+ : filteredLogs.length === 0 + ?

Aucune entrée.

+ : ( +
+ + + + + + + + + + + + {filteredLogs.map(log => ( + + + + + + + + ))} + +
Date / HeureUtilisateurAdresse IPRésultatDétail
+ {new Date(log.ts).toLocaleString('fr-FR')} + {log.username}{log.ip} + {log.success + ? ( + + Succès + + ) : ( + + Échec + + ) + } + {log.reason || '—'}
+
+ ) + } + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {logsPage + 1} / {totalPages} + + +
+ )} +
+
+
+ ) +} diff --git a/vps-monitor/frontend/src/components/Header.jsx b/vps-monitor/frontend/src/components/Header.jsx index ce6114e..d1fa9bc 100644 --- a/vps-monitor/frontend/src/components/Header.jsx +++ b/vps-monitor/frontend/src/components/Header.jsx @@ -1,6 +1,6 @@ -import { Monitor, LogOut, Timer } from 'lucide-react' +import { Monitor, LogOut, Timer, User, ShieldCheck } from 'lucide-react' -export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, username, onLogout, refreshInterval, onIntervalChange, intervalOptions }) { +export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, username, role, onLogout, onProfile, onAdmin, refreshInterval, onIntervalChange, intervalOptions }) { return (
@@ -58,14 +58,33 @@ export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, us {username && ( - + <> + {role === 'admin' && ( + + )} + + + )}
diff --git a/vps-monitor/frontend/src/components/ProfilePage.jsx b/vps-monitor/frontend/src/components/ProfilePage.jsx new file mode 100644 index 0000000..0c2d745 --- /dev/null +++ b/vps-monitor/frontend/src/components/ProfilePage.jsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { KeyRound, ArrowLeft, Check } from 'lucide-react' +import { changePassword } from '../api/client' + +export default function ProfilePage({ username, onBack }) { + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [newPassword2, setNewPassword2] = useState('') + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + setError(null) + setSuccess(false) + + if (newPassword !== newPassword2) { + setError('Les nouveaux mots de passe ne correspondent pas.') + return + } + if (newPassword.length < 6) { + setError('Le nouveau mot de passe doit contenir au moins 6 caractères.') + return + } + + setLoading(true) + try { + await changePassword(oldPassword, newPassword) + setSuccess(true) + setOldPassword('') + setNewPassword('') + setNewPassword2('') + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+ + +
+
+ +
+
+

Mon profil

+

{username}

+
+
+ +
+

Changer le mot de passe

+ + {success && ( +
+ + Mot de passe mis à jour avec succès. +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+
+ + setOldPassword(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ +
+ + setNewPassword2(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors" + /> +
+ + +
+
+
+
+ ) +}