From 86e78247c3cfb2d63a98e5fe621e2adf5c529618 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Mon, 1 Dec 2025 23:30:28 +0200 Subject: [PATCH] updates --- .../__pycache__/auth_routes.cpython-312.pyc | Bin 24671 -> 26704 bytes Backend/src/auth/routes/auth_routes.py | 79 +- .../schemas/__pycache__/auth.cpython-312.pyc | Bin 10168 -> 10221 bytes Backend/src/auth/schemas/auth.py | 1 + .../__pycache__/auth_service.cpython-312.pyc | Bin 26924 -> 27370 bytes Backend/src/auth/services/auth_service.py | 41 +- .../__pycache__/auth.cpython-312.pyc | Bin 7524 -> 7961 bytes Backend/src/security/middleware/auth.py | 12 + Frontend/src/App.tsx | 40 + .../auth/components/AccountantRoute.tsx | 15 +- .../features/auth/components/AdminRoute.tsx | 13 +- .../auth/components/ForgotPasswordModal.tsx | 4 +- .../auth/components/HousekeepingRoute.tsx | 12 +- .../features/auth/components/LoginModal.tsx | 252 ++++- .../auth/components/RegisterModal.tsx | 8 +- .../auth/components/ResetPasswordModal.tsx | 4 +- .../features/auth/components/StaffRoute.tsx | 13 +- .../src/features/auth/hooks/useAntibotForm.ts | 4 +- .../auth/pages/AccountantLoginPage.tsx | 571 ++++++++++ .../features/auth/pages/AdminLoginPage.tsx | 571 ++++++++++ .../auth/pages/HousekeepingLoginPage.tsx | 571 ++++++++++ .../features/auth/pages/StaffLoginPage.tsx | 576 +++++++++++ Frontend/src/features/auth/pages/index.ts | 5 + .../src/features/auth/services/authService.ts | 5 +- .../src/features/content/pages/AboutPage.tsx | 65 +- .../src/features/content/pages/HomePage.tsx | 171 +-- .../rooms/components/BannerCarousel.tsx | 35 +- Frontend/src/pages/HousekeepingLayout.tsx | 134 +-- .../src/pages/accountant/DashboardPage.tsx | 34 + Frontend/src/pages/admin/AuditLogsPage.tsx | 6 +- Frontend/src/pages/admin/DashboardPage.tsx | 10 + Frontend/src/pages/customer/DashboardPage.tsx | 10 + Frontend/src/pages/customer/RoomListPage.tsx | 8 + .../src/pages/housekeeping/DashboardPage.tsx | 975 +++++++++++++----- .../src/pages/staff/ChatManagementPage.tsx | 10 + Frontend/src/pages/staff/DashboardPage.tsx | 10 + Frontend/src/shared/components/Footer.tsx | 27 +- Frontend/src/styles/index.css | 20 + 38 files changed, 3765 insertions(+), 547 deletions(-) create mode 100644 Frontend/src/features/auth/pages/AccountantLoginPage.tsx create mode 100644 Frontend/src/features/auth/pages/AdminLoginPage.tsx create mode 100644 Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx create mode 100644 Frontend/src/features/auth/pages/StaffLoginPage.tsx create mode 100644 Frontend/src/features/auth/pages/index.ts diff --git a/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc b/Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc index 0d30d28dd2692e0ae2e121de295ab11a4c302f83..a53358e2f17519a5404d9f8a4970ab3d3e615e34 100644 GIT binary patch delta 4231 zcmb_feM}qY8Nc%vw(%D>7-JhaY-2DVh6FGK2#Zq)`GSzd?Uptzb+}7nVr+Uohm@G2 zW|KzMY>o6zQ9*wBlHnUZ`xUj7{aeKoF4e_RqE z6)AGqhJx!Xa>cX?T{2nGDu>H^jAVb2k`Ft;_|6~BnOjej=#s-OydbmY_*Uz2qQ$+#z>l&&`8@^V%a9NDNRaypp_ulhq?NkzhOTrSX-#3^Jtvfa3gxr zU_s4g&U~y)Yfj2(?YVsPy2)TA$KBL(qn2<+Smn?3^i=*-qkz zaSE2>khDb#<#1T&!c-nzRD#3M)|2v`1YJzqqRx@hiF|a)>8LHi zo3l^oQu)K3xlERytc-SGxpP9Fk`4bvs#GcVRwj8X1RL05kv^rP%g<5Md09)6xUF4* zwJYw_Zuo(=hQQ@Dq?D|JcBPaNa>Bp`b2$vJhl>6byKzfyJb)?KtAWkudU9$h!!((t zp;zZBr+b4{8l)O>TNaciyBxFR&#BI&VbKRIINEHlk zgCgjwR&>jV$a2S|@i8Ozo5Ib<)EGHN9Fp%M#z-j|#-k78h&yr~am0%g#+5#9$1tbN z6BgQ^G9M%o3c3NFEM8AoRvfNS&d>65a-uM{o^D)HUv-CiU|B6kVeMLa^-_?gC9CHD z!OFBEjr(%m@Uv)%o+!L~xO8+2Zr6$ft;Su^rp)I8D9>Elcl+g$Dx`HO%bl-@A=xfC zPPn4@sNYrDKgh>OUu!?R1xIF~R~vjP6sD>t^~|ABW{6|xK{g&`=5;LdNHW4QiNTS* zVYUw|&Re4KeUX?X9vq1zM#9|C{&_<<$?a!iTx2NBMdC4^ob3nC2n_PWLtHW(l`L2e z*inEn4C7R2ekdVAuqE2#g-ZFmKLgfHN9g zN{`NlJAkDjmcfVFgxco)(5&;jhsofGd5 zC#XFP6Qh=uCU0x)&S~KqEX#9ZicT^V7pF#&Q7$qXWvIR32u^gVfME_SxWw%gIrdR1 zye}MyQPD8Ru>N^nwsKi3Hi>udt78x28oXc8tJmdPzXNw3Rk&(8jzf(a0ig4&8{n4! zLjXDesPepdV<6E zwhm060@#SI_-)oFf!qVGG|=qqs37!DS1mPPkO;>jTx1-Ngj9Ij{#f3X-G}ZE|mXfRQOj$%SHfrr0MG`$S`bXfDr~D+P0@DkS~L+l^Hc3lv>%JTi)5be z$$6vYBMX(W)X!S#FL*BcUh*xp2QPcB`mXq9?we~5ol>0EJg+%zc;1kqngyzPjtcPi zKXh!9=vkHVbP1lWjHg@hbYJ&uIMyc?Q>Xf7i&rg_)n>{9LRmm8uMpjBVokf~sXpEN ze6Lv5C|0e4Uw=o&9~At-K0XxY*~5Y-CAw>GTD0Z%ZwPIPeG%ZMm7wYts{NVjpimtY z8v=X}!|zS-499ch{5@cFQz@sgvK%XGiDFNBtEY;bTyzq;l1zT(Y<{I^D|`CjaYM#d zBiL%r1TL(;ZVO~=or10Nx~*$!lj!tioB_cZ$T&L%XXkZi*HoWqv_0K7YxI2zb@U5X zzgS!%IvPdSYSHOB6*%61rde<{isjW}xetE5O&RYR!MkRrj~`A5PEMS3xba4c&3GdP zX1oy$Hlm;;WAX|n@0qU4@|lg9t$T#6d-##VyvaLfN{J@hl639MiJ7*{mawoT%tyy1 z>0#Xb1M)r_^GQpQ!nJV@*VyBjzOVgP^3C|UFm|I%Gd*fJ@CX9QSUl6BIuh$sy`=a66{SN zsJoyS^lSOxUVeB?C<~5#L_Kz=%BSF_MXmFX43;nU2() zkQcJgfDkU=c@=&k#*-ZTTU6~Iu>BGEj{(5P20ICG76Urr_mXM+rV3~!6@cAovf-@E z3gNyr+>2jYR4mTn8!H*3+4Jb~s;$^Szq&&EGB^QvJl(cBCMVCM(=B_vuK`!OB3}n; z8-{t~P?BZw9Xb$BB*x+_&0azc0h{XgpzsRn3$&>I3}hNj1sb%MfdqImeI>A3ru_@7 z0bWRd-}W}Cx(Ms@h;Ls*zJjzJP1?T#1K@nRx8sBi=kdwf7V=qS>*^%0qIl>>hS33U@Sbsuy3J1b-9&1X1Mxk-j6O>jNCKv*?4M%L$QjQ0Z1!jtsXsdmTB}muTmJ z1h|&&T0aiSztUZb)6R4^*GtCo6&e2u>m}qE@a@1hgJmWMbLqAoolNx}tk0v(y)Nuy zq_^5g=5<9mL*Ps00yT7{ zw@|wPEP&6_A8h`0o|1*j6g{$|A18l#hljj@{;|VFeumULd&o~w|IYhO%QGa3^~cqQ7RNrs_;KSU#2gHyb9I7z~bxl z-+y+EoRls~I18*a$X!6eaL&s|@hP|rOKFl|Q05EqR3D5CF$q6>YAr3KZmu{aFbYy@ zvQ?IfC+!_FI7_hb{Va9iTXpA%I!-5}Ocz_8ZhoMbTo;#-qok-WT$D@HN0#zMr9|Pk ir?Gf3FH5Tljp<8`M1LhGWW|fJEPszO{p#-D$^QeybB9O( delta 2601 zcmZWqdrVu`8NcUV`}%opV}lLYgok9a$!v_B9874l zYuHj%O(nLZI<2yFs1FTkWRN!L)uydlwKP(;z^DRUGBU4Sn!ol(HPuTiRY^N-=ew>G z(%wIQ-}^h?`ObIFU0z1FE+NZb1i{GAk1wi=JiGs*JbdGw^A%wrk$t#R~{*LS$B zdRdQ~lU3PZh9N)ku|0^jAW2Z zvQ9D=(RfLi=AK|Ai)4)zj5t#UGP%xevc-{h~&Yyi9U)Zske_H>CvH$C6md$hwpTF)Slnk}WxV zuCA0-wnkf_hr+rwbqi#7o_kW55T;JkE>Jp-#+j$ML(Dj8WTp<$mA2(F(UpFj64u7v z&ux<`WZN-jR3mwTVq0=YE-KoV;~vnsb&dUTI!4bl=b7>z?v#ags*-lFW%GxoK8o#)1pSi2WP0vw{yCTht@rLV(` z!$-#=I6T@nGSr{8#}bDlabWwt(o1Pe|5);e;dnAK*q@9f;&?Bl7zT(@*pI^$MGo_z zAw;S=K{VC-#k4t_YN$UF3rjc&)-ix_fTsYSrXW_6&VZMU)%eISs>5c=i*}Oo)seS-K0k&&kJ>4eedEKMAnt4qroi(#j?{{ePdJA!uSO~nEF_NQo4L?{h@BJcU&=@+)H(Ean) zU+^b|n}L0_>XpF2^3cI z)a#1aT2F;P7dNz5FjuWDlwS}~utu|B5rB7t(}D*9U#en-Ay~;Rd}~9nbUU|Ltfhv< zQY}?2R%(D34Iw|b=x;3x)p1L96e{4C9QnZ4AZl3hX=%QtIxX;iF4Ul$+R)6}&(Nwx zGlmB!JBY874>uD2S5&^sD98LSb5)`S9|Ch9Krg`e0S;0~^K@1dV@doHsSI>h{2KHV z09^o+;2}uiSU4H(8%!jIBVjTXs7A9y2?UI)Gk{C^G;oe}LM8Vs2S&rVES^Y~4JF3n z5}qM%HHB!v|C-8;Z$M6fW~FvZoI^9@?4twKZ-G|5Sqg9+6w=ngF^uWO>+ByL9Zz5h z|Av&e6!Nct!<*!>mInR;u(RZN%Le0bfdyz&lomg0d>dqdu=2^)_Yi*`XNa+_8NEYxwSA}hci?e?g2>}p;M)N%QAq3ij~wZX z_m70}yF>^Ul1ptS?VqS&Tv=}W5sNO7Py|Ql zRPR6H95XYuC-TqV0F6iw<%e(y87-c&^3#Ui(mnGR;dpjhYieyN@(c| zp-pvaFt;Y|r_^thqd?z@?91>Tgae#XYWJ8~ehK6m(%ex&bM$pI>eUMB_EE z0-RM=I;vQ-L_XhFZ(0EDk!+y9OQeClU0dzBK+vl#TOenSOR^x0#{&vxgppm=aV zH^{$w8fk*k-g@*g>Fo8O+hn9Sp#3j(JFm?4R%`fAAmZQ3^(Pn6r20a_MZxMQ!#`9V z&op<0-UxN9)LDX2N$1nKJ{B1akFJMb#8-lPmTN8y41#(vYJ1hLsr$xI!8ry8z`lvX zJ$>^CDH+rur&2Yz3vG=e6hk+K{0ygZA2~`hT9wQ2jMbIVWqCbgu-!4J{HGklx-x85 M|26#v+!0}dMinS zj(rbJ)gv$0r4bSOam}vavUpIA8qN!YAvT!v;4rqC{qlQ#S-Q+O*Rkwh3EkVjsrP9i=0j`6b(?~}WD7nr zcjb1`krB7-E}@f_1TT6dH*|8{s(mj`O0Ax5x+M77NE#UybZN10KAdsdo%$kO%z+R# zST>**|1j3!M!^}VLB-lB^pG`SA116vpeOaj8Ue=uJ2=S(cQ>y~m$+}|XWR{<(^iEE z#U}KTC7}VMwhriHL5&6@Y~Q6(=mC?~#3t=H;RJyjkXNmlkbNJ^j1Z+gp%q8$0f=Vb zR}B&TY_IVQ(>0-kjolaK&9`Mj8qTK8f&E26xJbH~esJ`{ zY^vI6%V8llyFXeqlHVbC2wrR|RWOm;g0TV@?s3=Wj&lLNDX2n=#|KwZb)HuM*Kwz` z7pCxZ*>|{wwdJcYhu_NI34^45z_5rfDvF%64HW)gRRf9U@JEHK;s!0PgayJPA(s#& z93$Mvm?8@^v?L6rB3=<-1_ykNXL*_rxg!n=QV(EioGz~FYXj)SwtxN&YG$64h zvM?zgBf4>8+!!~c8;RtoDoS7Vu~HgVvqjeakUP1#jYAvfM%%=N;ac$glQ?l$s+HAS;=>7NAHIB z(h>=SD!bJXDi9s0Ffo*LRk!DHsO)>yyB^rlduT70z@}t}XL8yV#VNweB45YA9AZN; zstKc&hWcAIqzCSswn-oKnYYOq_+!37PC>*H7Gmf|_~D)9Jc(8IEZ2$SHe0yDf^S#U zRhyk_Ms2$U#H@ajfE8PV5JxX!0A5=|B+ifY&l)>9&ekF{%%~dZCHA0$h|36Wx{4!w z0S!aJrU*$KB8K6OO(n^LiE)@Ks7L|vd9}4SOckn&xV7}O@PqZJhF@` zo+)nwo`MnA^oSLN1yMxcPP7)$jkt(dM`(Rmz#(F)^0ZSVqyR5G=g#skiGLi}-df-B pKEp2JJ;5)S-v+NSuNy6Z!Fz&ifXC~T^rXGqzprDDeF-MK@E;vN>)rqW diff --git a/Backend/src/auth/schemas/auth.py b/Backend/src/auth/schemas/auth.py index ca777efb..144b6ea4 100644 --- a/Backend/src/auth/schemas/auth.py +++ b/Backend/src/auth/schemas/auth.py @@ -32,6 +32,7 @@ class LoginRequest(BaseModel): password: str rememberMe: Optional[bool] = False mfaToken: Optional[str] = None + expectedRole: Optional[str] = None # Optional role validation for role-specific login endpoints class RefreshTokenRequest(BaseModel): refreshToken: Optional[str] = None diff --git a/Backend/src/auth/services/__pycache__/auth_service.cpython-312.pyc b/Backend/src/auth/services/__pycache__/auth_service.cpython-312.pyc index 44ab9a54a17647fdca6564e3d8b9a9be74f345aa..07bf488cbc8206c7e7e38bca378d8a2ec48704fb 100644 GIT binary patch delta 5335 zcmcgwdr+L!760zHZx;5w3(IRi-YfwU5+EVbkS7VmJjs*VY!<<@%E}PndB#{9s@ZYEQYMo7S-m(y%Gk4F#YDMmQ>?N> zF~~;vxfQeQfp<3iG>Wdu_)#?-Wlj=iaRCaL(eHyY+G&@LZfHDq7xGFXsc# zZ4zew!2nyh4s3z~Gz&s&JYzvgPL&dbQNeFb{8L{^cJ(aa&t&u1#1MRCJaG z3}`h`XlhJjq>9~Ox}LPUb`VF=ACg2Du&=C5WPmlnO-VZNE;PRDi8?RKui(95p1$9(Ak&%|AKLEee88 zi(gCmQaYiLGIaGSUHwD*Ql`}-UCK}_6d6n#34xsUAt>x4XFizAbk&jT6H8r9BB5-= zQ(QHSCQRMK;c#FuIH1sds6B!(icp9!hOn2t=JBiRAAUz3vhR1J+cu;` zxs&e29;&It``Pd6>c^)U0iJTqM06fiGnnQn0xM z+Qv5foZ-8W^dU$HHUvIlD^kS>wFmgrJhfT~HD}3r=>kJbRhFl3Sz2f2 z-ACBt_S}p?NX_xAxE`8ytp%p6xTSv~?4KHpS#v&*SwTDtB2^f-K&18QS$r+geJGQq zBbu~yKjFTF_?R;K;qZuNs=K9_7Rq1C{jcwf>78J$&gLPyE;ldB8rM=$E`o`g$8Fz8 zPuub0^pKdI<92qaww1k8?NS2_JH}D7HXUrQRW9*@R@{EP^cdC@c*Y$t=6c*?R!7>> zbRy=nU({qTm`>2RRj%Uwr|igc9?`T5B|>b2YD*AG1xV1^aVN9aHtWiGiozx-D39SW z$)C70Jqm6c*>${v-B#P90gLQ%?XLe}rZ3EOLUD(2Cn{%+=9)E{h@+)b<7?sbr?v2$ z8h72`+DG;YBbr^pKC(*yj`IfRL2}mym70+~tJz2V)f-a6n74IsZ!jE^C1oJU?wEHw z^VBtwYuTo{?T#?qKxi$+68`~S$!HV%d0ivvWS`c}VZ(KGdR2jKncwEz>K9X)RJm?A zM3rb@pgRcniO=UZPuSDu*S9Q(8xUM2ctIF*ZGm%K8VE%Pg0ZgN#drat(pU!EkfIVE z@zR(@>Ku+q>2qHileYGTqHuZY2@S&aNgj%X24fO%B7<;Y>W)BfJ+~62ci>#rLdwhA z0Tx(LrN0NcjNQGUy?!l{_W}58QaZSfDKw>n+7%k2r6_p-0k7)xK?GjFA7Y;R>LqQ^ zPPw~-p)gb;K9xW)7E=a>Vo~}C>RzeNMoD(O`zE zrIMFrEH}LVr}VNCR$>a&_waDEmp+UFtiUNFxGCHj?CMu*@uMiH*)UI}C)k+=XJ5*Mao{yJ4E!VO=E%KG{~bDbZm3u>uklsQu})zDOoh}u^?nG7cU8d zeL1^lVJErF{;}|Y2V}r1ZV^$uhKtvT@3A))wPo^dS511ZPTHzr#87qzPwWKzGvyKS*>-j@fB^NTY&=4*bVT`qLgXfEhhICkZTZ)X!| zyq#m}DAm1Pw6w#gOZjv_r%E-zPgUrlU#dn2{ZjM6VB+jm=S9-Yo^HNsq6wpPHNsMa z)d(#Jm^OZwn*6(w!FK@9LB0ccS~a5JAq3n7>Q(J!qMxjs0wBSydUHzH}qPW`uTtC=NR-XCPHxS+<01PJF)1EiSs!#HJ?!pBwzcgkL*< ziMDWXumv{OuJn2=%iS;pqNo-CCkobo zyxLE0WEWT8OUBtiODUm=ds_aeB{#F!y1DvW(avq`=(=s2|NGGDL4n%$Biw>;7@-$| zU&RwhJ&(XQ^$6Q|jdy}#n@3?4Qde4m^_wf0`4TyYefZVta`sc;$D%72Wz(3GxfWqLgzih04i@j~r67nj$W>X7k zNU%*WYCRvIMat9j08;!* zcj3Dz{~f}e04XcfMkN;DWiSxw?4wU0*Q9cXX_!8V?Tn#tFfV+=R_@jr7jl>2KUAjt zcrB7sjs$APj~TQ4xP96rcOiEscWWOC&{wK|a3(YUflPcWIG22y_#)UR+B7X2Hu&`$ z_~Xx`N7!Duuob^fQX05Fq_lFVD@M1WDGVd!^V*FRt|F!Fj6}keFO4r3ODpBy=|-fU zV(-cgO0xnuM(F8VU>uL4z;1tF7AMW#v5C-tfu?@+__-Ia7lpINezFU(teDsOP#EyrwIO4 zva!eG?!-y)%;!n}TWnS(#(K1b9AOi^`MDn>XL=Ez8Lt|M$irb}DJAS@y}P~ZjU?l; Rz;U-tu;tx3ep4n_|2Od<+Moac delta 5318 zcmd5=X>c6H6`r2Gw1=)ew7RuAR?=9956QMH`9Q)L%R!4R+1L`u2#_LFIEn~NNJ1(_`t^9V!sX%z zzcP}(e%<}v>({Se_v_Y&=g^5WDC?EXObbUpV?jgk7v0Zfd3kh*H1oSr6*Z8un zWR$p&EynYl${!M(EmaAdjyvCQq93^^=^C8UMmmi^y*Ce&irunME>anM4A4%d!WRkY4M3@vCfg zI(Hov#^nID6a*_?1oTpJTlOtz39;lXMytrmoZq8nV#ysxYssG6T03LeLirRh!sEe9 z$aA?X&>;DHZWU#8+5?qaL75KgKv^#UXvcjN(lSTvZ74wAuveqzLykY7I`XQcBomkg zgJ#8c@^42ywW8YDfP!SRb1hZ=kn^>y9h7ICpvTO1KiT6dY+xo0({>yLkSHW=!+vF0 zmc)U{h^#0E+T?+cTKj9)Vzev%xoZWFc9F*XiUkqiHwU7jkT~WamGOKbNOv(l6;zfzpI(H`;xpgfTJLxsfr&ic+1eN&NXZY3I<4;a5yB3 zLFoe%G_?Y(r*KS%mjbQu&8DDMO_?$up%c zo%x7?Zc0~^MK#fO>k(aw{}v>1sxiImB^#}Q6WOPtkxKGzaWT2Q#!b(~$7;Ohs#G7@ zTkq7<0cwfbQ$}9#7UfV$SE^U#MjFU7MGmAR2X^F?QY|O4QuEdZ zCePOihHNH8z2wRPuN~2+cn~)?|HgXj@?6zSeP>rK%!6}h<}FAygND>7>2iwZmzb5U zhl8z$Gc}Ui#dPHzFK-jAqlx(ShMg?7wB~syGzK4^F zq~cJ?XlcxbxX79mvpQ$ARI-yEbg@j;Hqs8uq(hpxT{Wng$Mu?k@)u_B|K(DZe$!I5 zeaoeqzufM!%PxV3AeUo}ZRc8grqZo)*)3c8^-c8k4K_E1zR^uLKV34isL`C6rRp&+ zdB%{G8Oy#jZnIC6r{V()U6mFc9vYM02%PKeQ|&2r zuxq4(6V*grG^7UwHqDfI)192j5Y~6%1zpvH;7Y3ontrpLqW+S zkNSflPZ)c~{faUX#!{nas=?!rMC8%&2vz98z%dpa8}baufiRXm{=va;bS&Ztg$H+r zqmjnV0-v;Dc~>-uWknnf_~|X@k>)jCM{019LHPXp-%#g-do91Kjd z*|Gf268ci;GD>ntj>z<)5Q-|p_y-^pf{qM&0SOKG2X|^&{0MYFENZ#iIWKp!X|S~Y z6!aejpb6W_C3EMzFKSDykjdqXdXx5a``nDc+GJKx5n;^exHuS=Wc&m)TIJp2K|Co+ z{>fDGharB%`mh|KT{6aDtT;(li<_qxx!RTz(~GoQsWOslT7FYkRddvNUTB>WT3^`q zOY!I8*;T$7!FRs4f2OwoY^|6WkY;M7vsH2;r<~-h@YcVX=SftmvmAF5x|XL!3~hvB zBY!;<6*uq~IL_SxOcsZgpbKro`&*ME5k+Qqj)jI+$DsPabVO zH~nT|RidUl(IY0hcTg5T>VjS$za5G$UW7vAN1&+Q3F1W|pj{#U9bxHd{rQ&e8Tu=9 zC!E!g|UP)Hf!W;Ip?k38LRgwI%5@%ZayDh zw(=inOLtvg2|Cl*QLv?gJ7+4Qm2(xAzCzu(`gX3*rTb$;hrO5MUtfc0 zBKxnt7pbIZeHDEGU9tXMJ-U@tbS*S~m-6BLWNX)!P2W7UcI*K6eE|Ca?gtnGU@wH9 z0u={f2gU^XZ&$(eE~vA^>`I_6H-qd;XTY>E9j}25J9*9l#gxJPwDBJS>QR8l03N5H zWK)2NU;K6RMe>g#pB=PG$XJSd)ZRi7w0o%@>5HsflG z@9PVg(D%qk{)v<$hXQL@ z_-THfPcZt}Cls-9k`_@M4NFn_p8_#y6UALoe+d3jA&R&ORQ`-YQZEGuBX~2^VX5); z0Q~?kyQF>~91dYNW#)#x&|?C~0QfP$ZvhAi)K@nH1A<>?-+d^w_(iQ%0WiHSWI80YLwI`Yxb`-P2E$TZ8* z; dict: + async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None, expected_role: str = None) -> dict: email = email.lower().strip() if email else "" if not email: @@ -206,9 +216,9 @@ class AuthService: # Check if account is locked (reset if lockout expired) if user.locked_until: if user.locked_until > datetime.utcnow(): - remaining_minutes = int((user.locked_until - datetime.utcnow()).total_seconds() / 60) logger.warning(f"Login attempt for locked account: {email} (locked until {user.locked_until})") - raise ValueError(f"Account is temporarily locked due to multiple failed login attempts. Please try again in {remaining_minutes} minute(s).") + # SECURITY: Don't reveal specific lockout duration to prevent timing attacks + raise ValueError("Account is temporarily locked due to multiple failed login attempts. Please try again later.") else: # Lockout expired, reset it user.locked_until = None @@ -230,12 +240,13 @@ class AuthService: user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) logger.warning(f"Account locked due to {user.failed_login_attempts} failed login attempts: {email}") db.commit() - raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") + # SECURITY: Don't reveal specific lockout duration + raise ValueError("Account has been temporarily locked due to multiple failed login attempts. Please try again later.") else: - remaining_attempts = max_attempts - user.failed_login_attempts logger.warning(f"Login attempt with invalid password for user: {email} ({user.failed_login_attempts}/{max_attempts} failed attempts)") db.commit() - raise ValueError(f"Invalid email or password. {remaining_attempts} attempt(s) remaining before account lockout.") + # SECURITY: Don't reveal remaining attempts to prevent enumeration + raise ValueError("Invalid email or password") if user.mfa_enabled: if not mfa_token: @@ -258,12 +269,21 @@ class AuthService: user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration) logger.warning(f"Account locked due to {user.failed_login_attempts} failed attempts (MFA failure): {email}") db.commit() - raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).") + # SECURITY: Don't reveal specific lockout duration + raise ValueError("Account has been temporarily locked due to multiple failed login attempts. Please try again later.") else: - remaining_attempts = max_attempts - user.failed_login_attempts db.commit() - raise ValueError(f"Invalid MFA token. {remaining_attempts} attempt(s) remaining before account lockout.") + # SECURITY: Don't reveal remaining attempts + raise ValueError("Invalid MFA token") + # Validate role if expected_role is provided (for role-specific login endpoints) + if expected_role: + user_role = user.role.name.lower() if user.role and hasattr(user.role, 'name') else None + expected_role_lower = expected_role.lower() + if user_role != expected_role_lower: + logger.warning(f"Role mismatch: user {email} has role {user_role} but expected {expected_role_lower}") + raise ValueError(f"This login endpoint is only for {expected_role_lower} users") + # Reset failed login attempts and unlock account on successful login if user.failed_login_attempts > 0 or user.locked_until: user.failed_login_attempts = 0 @@ -447,7 +467,8 @@ class AuthService: db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id).delete() - expires_at = datetime.utcnow() + timedelta(hours=1) + # SECURITY: Shorter expiration for password reset tokens (15 minutes instead of 1 hour) + expires_at = datetime.utcnow() + timedelta(minutes=15) reset_token_obj = PasswordResetToken( user_id=user.id, token=hashed_token, diff --git a/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc b/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc index ed18b30127bef20f31f07fc9a0ecbd935c77d2af..fe7f2ed7bfcec90285c3e38aff7520267de41967 100644 GIT binary patch delta 939 zcmZ9KUr19?9LMk3AGbMo=W?1k)0-ie(AO(Rz^!weVQa?DKbNKx`zxzG+Ti@dW`9mO3 zEulU0c4yv4Udw9^=WmB3a-sd!x?_`#m=TJSnFlMoB;A%?u*!L7-^<#IrF|v0B+WS_ z39h88MaX3_zxB@bmR+4uZQB?ujgL+xl9z_#lfwx`D-^f}hGOck_hPf%SSp&t>gttdtMgP(AkFhrpL4cZ8{ z4*Te|t3&1U&ynvufgTS=5m6uf{{R#kBXr;gOE6By3Bn}8@F7SB+n`fyH@3Q`(YIo? z9B?rSnb;7^1qGP9brL{pKVY9!g&U47zTu7Sw9M8iI480LwnqsU=_#!`h z?N;>qwoWjOr?M-ZNtt%$ngpb~-Dum}pVQODEFXY^=xrXVQCY(y=XVLWt-QF`{MALN Fe*lQZ*>wN_ delta 722 zcmZ9JPiPZS5XSdyHh-HYt2Gg0wWT0&+XZXD8f$6PXsr+`rP7O*H8iU(Hi^t;1x09% zUPRK_9%|F5k?XH}kzmlV8VEYrVY* zh1b+O)48$dMQTMAG5?hsRU>zlImL+kufuPJYNX_cXj@*4-R~LU

lBha$IP-~Dpp zixxS;0*y%{{aB33we$foA~(~k`XR`pz+s>upX);+FW2?*!C@9U`c=DSEqX+Dqh)!n zO*F^yQQqzop(t%-BP&1W2KO>jTQbX+|eX%_ZA%YIFvY z284mL2x^UL-8RBB52YaQWperj{&kTt$g54m@@1wdJh_p%J7coS$sr`8CDMVNK$2mo zgu`@+;i`%DA7MgKs0U7drwj@kHvO5u4F5=uV;5ft3yY|3T>H$i? z2>{PRLqITWaQXJI-5IA4od)nCbdF)f>3`P|ssMQ&RiPRDo(Ez6P7|UcKkltpw!1b$^lz^${H@(B?ZPxJ)-@z~(BCAo}{Myw(n#zFR~9qKNbFL&dcKd IPa0DH0HgbxL;wH) diff --git a/Backend/src/security/middleware/auth.py b/Backend/src/security/middleware/auth.py index f73bae08..e585cd9a 100644 --- a/Backend/src/security/middleware/auth.py +++ b/Backend/src/security/middleware/auth.py @@ -50,6 +50,18 @@ def get_jwt_secret() -> str: else: logger.warning(error_msg) + # SECURITY: Validate JWT secret entropy (check for predictable patterns) + # Check if secret appears to be randomly generated (not a simple pattern) + if len(set(jwt_secret)) < len(jwt_secret) * 0.3: # Less than 30% unique characters suggests low entropy + error_msg = 'JWT_SECRET appears to have low entropy. Please use a randomly generated secret.' + import logging + logger = logging.getLogger(__name__) + logger.error(error_msg) + if settings.is_production: + raise ValueError(error_msg) + else: + logger.warning(error_msg) + return jwt_secret def get_current_user( diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index cbe8f85e..b2759a29 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -133,6 +133,12 @@ const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage')); const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage')); const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage')); +// Separate login pages for each role +const StaffLoginPage = lazy(() => import('./features/auth/pages/StaffLoginPage')); +const AdminLoginPage = lazy(() => import('./features/auth/pages/AdminLoginPage')); +const HousekeepingLoginPage = lazy(() => import('./features/auth/pages/HousekeepingLoginPage')); +const AccountantLoginPage = lazy(() => import('./features/auth/pages/AccountantLoginPage')); + const NotFoundPage = lazy(() => import('./shared/pages/NotFoundPage')); // Component to track navigation changes - must be inside Router @@ -465,6 +471,40 @@ function App() { /> + {/* Separate Login Pages for Each Role */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {} = ({ children }) => { const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - const { openModal } = useAuthModal(); - - // Open login modal if not authenticated - useEffect(() => { - if (!isLoading && !isAuthenticated) { - openModal('login'); - } - }, [isLoading, isAuthenticated, openModal]); if (isLoading) { return ( @@ -39,9 +30,9 @@ const AccountantRoute: React.FC = ({ ); } - // Don't render children if not authenticated (modal will be shown) + // Don't render children if not authenticated - redirect to login page if (!isAuthenticated) { - return null; // Modal will be shown by AuthModalManager + return ; } // Check if user is accountant diff --git a/Frontend/src/features/auth/components/AdminRoute.tsx b/Frontend/src/features/auth/components/AdminRoute.tsx index a3556cc4..5c5420a7 100644 --- a/Frontend/src/features/auth/components/AdminRoute.tsx +++ b/Frontend/src/features/auth/components/AdminRoute.tsx @@ -1,7 +1,6 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Navigate } from 'react-router-dom'; import useAuthStore from '../../../store/useAuthStore'; -import { useAuthModal } from '../contexts/AuthModalContext'; interface AdminRouteProps { children: React.ReactNode; @@ -19,14 +18,6 @@ const AdminRoute: React.FC = ({ children }) => { const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - const { openModal } = useAuthModal(); - - // SECURITY: Client-side role check - backend must also validate - useEffect(() => { - if (!isLoading && !isAuthenticated) { - openModal('login'); - } - }, [isLoading, isAuthenticated, openModal]); if (isLoading) { return ( @@ -49,7 +40,7 @@ const AdminRoute: React.FC = ({ if (!isAuthenticated) { - return null; // Modal will be shown by AuthModalManager + return ; } diff --git a/Frontend/src/features/auth/components/ForgotPasswordModal.tsx b/Frontend/src/features/auth/components/ForgotPasswordModal.tsx index 62374f45..a525ad37 100644 --- a/Frontend/src/features/auth/components/ForgotPasswordModal.tsx +++ b/Frontend/src/features/auth/components/ForgotPasswordModal.tsx @@ -63,7 +63,9 @@ const ForgotPasswordModal: React.FC = () => { await forgotPassword({ email: data.email }); setIsSuccess(true); } catch (error) { - console.error('Forgot password error:', error); + if (import.meta.env.DEV) { + console.error('Forgot password error:', error); + } } }; diff --git a/Frontend/src/features/auth/components/HousekeepingRoute.tsx b/Frontend/src/features/auth/components/HousekeepingRoute.tsx index fbb51410..85c34813 100644 --- a/Frontend/src/features/auth/components/HousekeepingRoute.tsx +++ b/Frontend/src/features/auth/components/HousekeepingRoute.tsx @@ -1,7 +1,6 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Navigate } from 'react-router-dom'; import useAuthStore from '../../../store/useAuthStore'; -import { useAuthModal } from '../contexts/AuthModalContext'; interface HousekeepingRouteProps { children: React.ReactNode; @@ -9,13 +8,6 @@ interface HousekeepingRouteProps { const HousekeepingRoute: React.FC = ({ children }) => { const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - const { openModal } = useAuthModal(); - - useEffect(() => { - if (!isLoading && !isAuthenticated) { - openModal('login'); - } - }, [isLoading, isAuthenticated, openModal]); if (isLoading) { return ( @@ -29,7 +21,7 @@ const HousekeepingRoute: React.FC = ({ children }) => { } if (!isAuthenticated) { - return null; // Modal will be shown by AuthModalManager + return ; } // Only allow housekeeping role - no admin or staff access diff --git a/Frontend/src/features/auth/components/LoginModal.tsx b/Frontend/src/features/auth/components/LoginModal.tsx index af75f25a..6a0f20cf 100644 --- a/Frontend/src/features/auth/components/LoginModal.tsx +++ b/Frontend/src/features/auth/components/LoginModal.tsx @@ -13,6 +13,7 @@ import Recaptcha from '../../../shared/components/Recaptcha'; import { recaptchaService } from '../../../features/system/services/systemSettingsService'; import { useAntibotForm } from '../hooks/useAntibotForm'; import HoneypotField from '../../../shared/components/HoneypotField'; +import authService from '../services/authService'; const mfaTokenSchema = yup.object().shape({ mfaToken: yup @@ -27,7 +28,7 @@ type MFATokenFormData = yup.InferType; const LoginModal: React.FC = () => { const { closeModal, openModal } = useAuthModal(); - const { login, verifyMFA, isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); + const { isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); const { settings } = useCompanySettings(); const navigate = useNavigate(); @@ -63,26 +64,24 @@ const LoginModal: React.FC = () => { }, }); - // Close modal and redirect to appropriate dashboard on successful authentication + // Close modal and redirect to customer dashboard on successful authentication + // This modal is ONLY for customers - other roles have separate login pages + // This is a safety check in case user navigates back or state changes useEffect(() => { if (!isLoading && isAuthenticated && !requiresMFA && userInfo) { - closeModal(); - - // Redirect to role-specific dashboard const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase(); - if (role === 'admin') { - navigate('/admin/dashboard', { replace: true }); - } else if (role === 'staff') { - navigate('/staff/dashboard', { replace: true }); - } else if (role === 'accountant') { - navigate('/accountant/dashboard', { replace: true }); - } else if (role === 'housekeeping') { - navigate('/housekeeping/dashboard', { replace: true }); - } else { - // Customer or default - go to customer dashboard - navigate('/dashboard', { replace: true }); + // Reject non-customer roles - they should use their dedicated login pages + // This should not happen if onSubmit logic works correctly, but handle it as safety + if (role === 'admin' || role === 'staff' || role === 'accountant' || role === 'housekeeping') { + closeModal(); + navigate(`/${role}/login`, { replace: true }); + return; } + + // Only allow customers - close modal and redirect to dashboard + closeModal(); + navigate('/dashboard', { replace: true }); } }, [isLoading, isAuthenticated, requiresMFA, userInfo, closeModal, navigate]); @@ -125,15 +124,120 @@ const LoginModal: React.FC = () => { } } - await login({ - email: data.email, - password: data.password, - rememberMe: data.rememberMe, - }); + // Call login API directly to check role BEFORE showing success toast + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const response = await authService.login({ + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'customer', // Customer login page only accepts customers + }); - setRecaptchaToken(null); + // Handle MFA requirement + if (response.requires_mfa) { + useAuthStore.setState({ + isLoading: false, + requiresMFA: true, + mfaUserId: response.user_id || null, + pendingCredentials: { + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'customer', + }, + error: null, + }); + setRecaptchaToken(null); + return; + } + + // Check if login was successful + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'Login failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-customer roles - show error and don't authenticate + if (role === 'admin' || role === 'staff' || role === 'accountant' || role === 'housekeeping') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for customers. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset loading state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + }); + + setRecaptchaToken(null); + closeModal(); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is a customer + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for customers + toast.success('Login successful!'); + setRecaptchaToken(null); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Login failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + isAuthenticated: false, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + toast.error(errorMessage); + setRecaptchaToken(null); + } } catch (error) { - console.error('Login error:', error); + if (import.meta.env.DEV) { + console.error('Login error:', error); + } setRecaptchaToken(null); } }; @@ -141,7 +245,103 @@ const LoginModal: React.FC = () => { const onSubmitMFA = async (data: MFATokenFormData) => { try { clearError(); - await verifyMFA(data.mfaToken); + + // Get pending credentials from store + const state = useAuthStore.getState(); + if (!state.pendingCredentials) { + toast.error('No pending login credentials'); + return; + } + + // Call MFA verification API directly to check role before showing success + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const credentials = { + ...state.pendingCredentials, + mfaToken: data.mfaToken, + expectedRole: 'customer', // Customer login page only accepts customers + }; + + const response = await authService.login(credentials); + + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'MFA verification failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-customer roles - show error and don't authenticate + if (role === 'admin' || role === 'staff' || role === 'accountant' || role === 'housekeeping') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for customers. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset auth store state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + closeModal(); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is a customer + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for customers + toast.success('Login successful!'); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'MFA verification failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + requiresMFA: true, // Keep MFA state in case of error + }); + toast.error(errorMessage); + } } catch (error) { console.error('MFA verification error:', error); } @@ -397,7 +597,9 @@ const LoginModal: React.FC = () => { setRecaptchaToken(token)} onError={(error) => { - console.error('reCAPTCHA error:', error); + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } setRecaptchaToken(null); }} theme="light" diff --git a/Frontend/src/features/auth/components/RegisterModal.tsx b/Frontend/src/features/auth/components/RegisterModal.tsx index 56147636..e4f841e9 100644 --- a/Frontend/src/features/auth/components/RegisterModal.tsx +++ b/Frontend/src/features/auth/components/RegisterModal.tsx @@ -134,7 +134,9 @@ const RegisterModal: React.FC = () => { setRecaptchaToken(null); } catch (error) { - console.error('Register error:', error); + if (import.meta.env.DEV) { + console.error('Register error:', error); + } setRecaptchaToken(null); } }; @@ -407,7 +409,9 @@ const RegisterModal: React.FC = () => { setRecaptchaToken(token)} onError={(error) => { - console.error('reCAPTCHA error:', error); + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } setRecaptchaToken(null); }} theme="light" diff --git a/Frontend/src/features/auth/components/ResetPasswordModal.tsx b/Frontend/src/features/auth/components/ResetPasswordModal.tsx index ad0007a8..10227440 100644 --- a/Frontend/src/features/auth/components/ResetPasswordModal.tsx +++ b/Frontend/src/features/auth/components/ResetPasswordModal.tsx @@ -95,7 +95,9 @@ const ResetPasswordModal: React.FC = ({ token }) => { }); setIsSuccess(true); } catch (error) { - console.error('Reset password error:', error); + if (import.meta.env.DEV) { + console.error('Reset password error:', error); + } } }; diff --git a/Frontend/src/features/auth/components/StaffRoute.tsx b/Frontend/src/features/auth/components/StaffRoute.tsx index 76f3680f..1d9c3767 100644 --- a/Frontend/src/features/auth/components/StaffRoute.tsx +++ b/Frontend/src/features/auth/components/StaffRoute.tsx @@ -1,7 +1,6 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Navigate } from 'react-router-dom'; import useAuthStore from '../../../store/useAuthStore'; -import { useAuthModal } from '../contexts/AuthModalContext'; interface StaffRouteProps { children: React.ReactNode; @@ -11,14 +10,6 @@ const StaffRoute: React.FC = ({ children }) => { const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - const { openModal } = useAuthModal(); - - - useEffect(() => { - if (!isLoading && !isAuthenticated) { - openModal('login'); - } - }, [isLoading, isAuthenticated, openModal]); if (isLoading) { return ( @@ -41,7 +32,7 @@ const StaffRoute: React.FC = ({ if (!isAuthenticated) { - return null; // Modal will be shown by AuthModalManager + return ; } diff --git a/Frontend/src/features/auth/hooks/useAntibotForm.ts b/Frontend/src/features/auth/hooks/useAntibotForm.ts index 599d7c9b..41fe9647 100644 --- a/Frontend/src/features/auth/hooks/useAntibotForm.ts +++ b/Frontend/src/features/auth/hooks/useAntibotForm.ts @@ -112,7 +112,9 @@ export const useAntibotForm = (options: UseAntibotFormOptions): UseAntibotFormRe setIsValidating(false); return true; } catch (error) { - console.error('Antibot validation error:', error); + if (import.meta.env.DEV) { + console.error('Antibot validation error:', error); + } setIsValidating(false); return false; } diff --git a/Frontend/src/features/auth/pages/AccountantLoginPage.tsx b/Frontend/src/features/auth/pages/AccountantLoginPage.tsx new file mode 100644 index 00000000..3944d112 --- /dev/null +++ b/Frontend/src/features/auth/pages/AccountantLoginPage.tsx @@ -0,0 +1,571 @@ +import React, { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { X, Eye, EyeOff, LogIn, Loader2, Mail, Lock, Shield, ArrowLeft, Calculator } from 'lucide-react'; +import { useNavigate, Link } from 'react-router-dom'; +import useAuthStore from '../../../store/useAuthStore'; +import { loginSchema, LoginFormData } from '../../../shared/utils/validationSchemas'; +import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext'; +import * as yup from 'yup'; +import { toast } from 'react-toastify'; +import Recaptcha from '../../../shared/components/Recaptcha'; +import { recaptchaService } from '../../../features/system/services/systemSettingsService'; +import { useAntibotForm } from '../hooks/useAntibotForm'; +import HoneypotField from '../../../shared/components/HoneypotField'; +import authService from '../services/authService'; + +const mfaTokenSchema = yup.object().shape({ + mfaToken: yup + .string() + .required('MFA token is required') + .min(6, 'MFA token must be 6 digits') + .max(8, 'MFA token must be 6-8 characters') + .matches(/^\d+$|^[A-Z0-9]{8}$/, 'Invalid token format'), +}); + +type MFATokenFormData = yup.InferType; + +const AccountantLoginPage: React.FC = () => { + const { isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); + const { settings } = useCompanySettings(); + const navigate = useNavigate(); + + const [showPassword, setShowPassword] = useState(false); + + const { + honeypotValue, + setHoneypotValue, + recaptchaToken, + setRecaptchaToken, + validate: validateAntibot, + rateLimitInfo, + } = useAntibotForm({ + formId: 'accountant-login', + minTimeOnPage: 3000, + minTimeToFill: 2000, + requireRecaptcha: false, + maxAttempts: 5, + onValidationError: (errors) => { + errors.forEach((err) => toast.error(err)); + }, + }); + + const { + register: registerMFA, + handleSubmit: handleSubmitMFA, + formState: { errors: mfaErrors }, + } = useForm({ + resolver: yupResolver(mfaTokenSchema), + defaultValues: { + mfaToken: '', + }, + }); + + useEffect(() => { + if (!isLoading && isAuthenticated && !requiresMFA && userInfo) { + const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase(); + + // Safety check - should not happen if onSubmit logic works correctly + if (role !== 'accountant') { + navigate(`/${role}/login`, { replace: true }); + return; + } + + navigate('/accountant/dashboard', { replace: true }); + } + }, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: yupResolver(loginSchema), + defaultValues: { + email: '', + password: '', + rememberMe: false, + }, + }); + + const onSubmit = async (data: LoginFormData) => { + try { + clearError(); + + // Validate antibot protection + const isValid = await validateAntibot(); + if (!isValid) { + return; + } + + // Verify reCAPTCHA if token is provided + if (recaptchaToken) { + try { + const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken); + if (verifyResponse.status === 'error' || !verifyResponse.data.verified) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } catch (error) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } + + // Call login API directly to check role BEFORE showing success toast + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const response = await authService.login({ + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'accountant', // Accountant login page only accepts accountants + }); + + // Handle MFA requirement + if (response.requires_mfa) { + useAuthStore.setState({ + isLoading: false, + requiresMFA: true, + mfaUserId: response.user_id || null, + pendingCredentials: { + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'accountant', + }, + error: null, + }); + setRecaptchaToken(null); + return; + } + + // Check if login was successful + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'Login failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-accountant roles - show error and don't authenticate + if (role !== 'accountant') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for accountants. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset loading state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + }); + + setRecaptchaToken(null); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is accountant + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for accountants + toast.success('Login successful!'); + setRecaptchaToken(null); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Login failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + isAuthenticated: false, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + toast.error(errorMessage); + setRecaptchaToken(null); + } + } catch (error) { + if (import.meta.env.DEV) { + console.error('Login error:', error); + } + setRecaptchaToken(null); + } + }; + + const onSubmitMFA = async (data: MFATokenFormData) => { + try { + clearError(); + + // Get pending credentials from store + const state = useAuthStore.getState(); + if (!state.pendingCredentials) { + toast.error('No pending login credentials'); + return; + } + + // Call MFA verification API directly to check role before showing success + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const credentials = { + ...state.pendingCredentials, + mfaToken: data.mfaToken, + expectedRole: 'accountant', // Accountant login page only accepts accountants + }; + + const response = await authService.login(credentials); + + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'MFA verification failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-accountant roles - show error and don't authenticate + if (role !== 'accountant') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for accountants. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset auth store state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is accountant + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for accountants + toast.success('Login successful!'); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'MFA verification failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + requiresMFA: true, // Keep MFA state in case of error + }); + toast.error(errorMessage); + } + } catch (error) { + if (import.meta.env.DEV) { + console.error('MFA verification error:', error); + } + } + }; + + const handleBackToLogin = () => { + clearMFA(); + clearError(); + }; + + return ( +

+
+
+
+
+ {settings.company_logo_url ? ( + {settings.company_name + ) : ( +
+ +
+ )} +
+

Accountant Login

+

Access your accountant dashboard

+
+ +
+ {requiresMFA ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+
+ +
+ +
+ {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +
+ + + +
+ +
+
+ ) : ( +
+ + + {error && ( +
+ {error} +
+ )} + + {rateLimitInfo && !rateLimitInfo.allowed && ( +
+

Too many login attempts.

+

+ Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()} +

+
+ )} + +
+ +
+
+ +
+ +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + +
+ +
+ setRecaptchaToken(token)} + onError={(error) => { + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } + setRecaptchaToken(null); + }} + theme="light" + size="normal" + /> +
+ + + + )} + +
+ + ← Back to Home + +
+
+
+
+
+ ); +}; + +export default AccountantLoginPage; + diff --git a/Frontend/src/features/auth/pages/AdminLoginPage.tsx b/Frontend/src/features/auth/pages/AdminLoginPage.tsx new file mode 100644 index 00000000..afe0b26b --- /dev/null +++ b/Frontend/src/features/auth/pages/AdminLoginPage.tsx @@ -0,0 +1,571 @@ +import React, { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { X, Eye, EyeOff, LogIn, Loader2, Mail, Lock, Shield, ArrowLeft, Settings } from 'lucide-react'; +import { useNavigate, Link } from 'react-router-dom'; +import useAuthStore from '../../../store/useAuthStore'; +import { loginSchema, LoginFormData } from '../../../shared/utils/validationSchemas'; +import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext'; +import * as yup from 'yup'; +import { toast } from 'react-toastify'; +import Recaptcha from '../../../shared/components/Recaptcha'; +import { recaptchaService } from '../../../features/system/services/systemSettingsService'; +import { useAntibotForm } from '../hooks/useAntibotForm'; +import HoneypotField from '../../../shared/components/HoneypotField'; +import authService from '../services/authService'; + +const mfaTokenSchema = yup.object().shape({ + mfaToken: yup + .string() + .required('MFA token is required') + .min(6, 'MFA token must be 6 digits') + .max(8, 'MFA token must be 6-8 characters') + .matches(/^\d+$|^[A-Z0-9]{8}$/, 'Invalid token format'), +}); + +type MFATokenFormData = yup.InferType; + +const AdminLoginPage: React.FC = () => { + const { isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); + const { settings } = useCompanySettings(); + const navigate = useNavigate(); + + const [showPassword, setShowPassword] = useState(false); + + const { + honeypotValue, + setHoneypotValue, + recaptchaToken, + setRecaptchaToken, + validate: validateAntibot, + rateLimitInfo, + } = useAntibotForm({ + formId: 'admin-login', + minTimeOnPage: 3000, + minTimeToFill: 2000, + requireRecaptcha: false, + maxAttempts: 5, + onValidationError: (errors) => { + errors.forEach((err) => toast.error(err)); + }, + }); + + const { + register: registerMFA, + handleSubmit: handleSubmitMFA, + formState: { errors: mfaErrors }, + } = useForm({ + resolver: yupResolver(mfaTokenSchema), + defaultValues: { + mfaToken: '', + }, + }); + + useEffect(() => { + if (!isLoading && isAuthenticated && !requiresMFA && userInfo) { + const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase(); + + // Safety check - should not happen if onSubmit logic works correctly + if (role !== 'admin') { + navigate(`/${role}/login`, { replace: true }); + return; + } + + navigate('/admin/dashboard', { replace: true }); + } + }, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: yupResolver(loginSchema), + defaultValues: { + email: '', + password: '', + rememberMe: false, + }, + }); + + const onSubmit = async (data: LoginFormData) => { + try { + clearError(); + + // Validate antibot protection + const isValid = await validateAntibot(); + if (!isValid) { + return; + } + + // Verify reCAPTCHA if token is provided + if (recaptchaToken) { + try { + const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken); + if (verifyResponse.status === 'error' || !verifyResponse.data.verified) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } catch (error) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } + + // Call login API directly to check role BEFORE showing success toast + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const response = await authService.login({ + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'admin', // Admin login page only accepts admins + }); + + // Handle MFA requirement + if (response.requires_mfa) { + useAuthStore.setState({ + isLoading: false, + requiresMFA: true, + mfaUserId: response.user_id || null, + pendingCredentials: { + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'admin', + }, + error: null, + }); + setRecaptchaToken(null); + return; + } + + // Check if login was successful + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'Login failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-admin roles - show error and don't authenticate + if (role !== 'admin') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for administrators. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset loading state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + }); + + setRecaptchaToken(null); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is an admin + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for admins + toast.success('Login successful!'); + setRecaptchaToken(null); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Login failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + isAuthenticated: false, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + toast.error(errorMessage); + setRecaptchaToken(null); + } + } catch (error) { + if (import.meta.env.DEV) { + console.error('Login error:', error); + } + setRecaptchaToken(null); + } + }; + + const onSubmitMFA = async (data: MFATokenFormData) => { + try { + clearError(); + + // Get pending credentials from store + const state = useAuthStore.getState(); + if (!state.pendingCredentials) { + toast.error('No pending login credentials'); + return; + } + + // Call MFA verification API directly to check role before showing success + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const credentials = { + ...state.pendingCredentials, + mfaToken: data.mfaToken, + expectedRole: 'admin', // Admin login page only accepts admins + }; + + const response = await authService.login(credentials); + + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'MFA verification failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-admin roles - show error and don't authenticate + if (role !== 'admin') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for administrators. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset auth store state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is an admin + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for admins + toast.success('Login successful!'); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'MFA verification failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + requiresMFA: true, // Keep MFA state in case of error + }); + toast.error(errorMessage); + } + } catch (error) { + if (import.meta.env.DEV) { + console.error('MFA verification error:', error); + } + } + }; + + const handleBackToLogin = () => { + clearMFA(); + clearError(); + }; + + return ( +
+
+
+
+
+ {settings.company_logo_url ? ( + {settings.company_name + ) : ( +
+ +
+ )} +
+

Admin Login

+

Access your admin dashboard

+
+ +
+ {requiresMFA ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+
+ +
+ +
+ {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +
+ + + +
+ +
+
+ ) : ( +
+ + + {error && ( +
+ {error} +
+ )} + + {rateLimitInfo && !rateLimitInfo.allowed && ( +
+

Too many login attempts.

+

+ Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()} +

+
+ )} + +
+ +
+
+ +
+ +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + +
+ +
+ setRecaptchaToken(token)} + onError={(error) => { + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } + setRecaptchaToken(null); + }} + theme="light" + size="normal" + /> +
+ + + + )} + +
+ + ← Back to Home + +
+
+
+
+
+ ); +}; + +export default AdminLoginPage; + diff --git a/Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx b/Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx new file mode 100644 index 00000000..5605b5fa --- /dev/null +++ b/Frontend/src/features/auth/pages/HousekeepingLoginPage.tsx @@ -0,0 +1,571 @@ +import React, { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { X, Eye, EyeOff, LogIn, Loader2, Mail, Lock, Shield, ArrowLeft, Sparkles } from 'lucide-react'; +import { useNavigate, Link } from 'react-router-dom'; +import useAuthStore from '../../../store/useAuthStore'; +import { loginSchema, LoginFormData } from '../../../shared/utils/validationSchemas'; +import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext'; +import * as yup from 'yup'; +import { toast } from 'react-toastify'; +import Recaptcha from '../../../shared/components/Recaptcha'; +import { recaptchaService } from '../../../features/system/services/systemSettingsService'; +import { useAntibotForm } from '../hooks/useAntibotForm'; +import HoneypotField from '../../../shared/components/HoneypotField'; +import authService from '../services/authService'; + +const mfaTokenSchema = yup.object().shape({ + mfaToken: yup + .string() + .required('MFA token is required') + .min(6, 'MFA token must be 6 digits') + .max(8, 'MFA token must be 6-8 characters') + .matches(/^\d+$|^[A-Z0-9]{8}$/, 'Invalid token format'), +}); + +type MFATokenFormData = yup.InferType; + +const HousekeepingLoginPage: React.FC = () => { + const { isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); + const { settings } = useCompanySettings(); + const navigate = useNavigate(); + + const [showPassword, setShowPassword] = useState(false); + + const { + honeypotValue, + setHoneypotValue, + recaptchaToken, + setRecaptchaToken, + validate: validateAntibot, + rateLimitInfo, + } = useAntibotForm({ + formId: 'housekeeping-login', + minTimeOnPage: 3000, + minTimeToFill: 2000, + requireRecaptcha: false, + maxAttempts: 5, + onValidationError: (errors) => { + errors.forEach((err) => toast.error(err)); + }, + }); + + const { + register: registerMFA, + handleSubmit: handleSubmitMFA, + formState: { errors: mfaErrors }, + } = useForm({ + resolver: yupResolver(mfaTokenSchema), + defaultValues: { + mfaToken: '', + }, + }); + + useEffect(() => { + if (!isLoading && isAuthenticated && !requiresMFA && userInfo) { + const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase(); + + // Safety check - should not happen if onSubmit logic works correctly + if (role !== 'housekeeping') { + navigate(`/${role}/login`, { replace: true }); + return; + } + + navigate('/housekeeping/dashboard', { replace: true }); + } + }, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: yupResolver(loginSchema), + defaultValues: { + email: '', + password: '', + rememberMe: false, + }, + }); + + const onSubmit = async (data: LoginFormData) => { + try { + clearError(); + + // Validate antibot protection + const isValid = await validateAntibot(); + if (!isValid) { + return; + } + + // Verify reCAPTCHA if token is provided + if (recaptchaToken) { + try { + const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken); + if (verifyResponse.status === 'error' || !verifyResponse.data.verified) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } catch (error) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } + + // Call login API directly to check role BEFORE showing success toast + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const response = await authService.login({ + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'housekeeping', // Housekeeping login page only accepts housekeeping + }); + + // Handle MFA requirement + if (response.requires_mfa) { + useAuthStore.setState({ + isLoading: false, + requiresMFA: true, + mfaUserId: response.user_id || null, + pendingCredentials: { + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'housekeeping', + }, + error: null, + }); + setRecaptchaToken(null); + return; + } + + // Check if login was successful + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'Login failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-housekeeping roles - show error and don't authenticate + if (role !== 'housekeeping') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for housekeeping staff. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset loading state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + }); + + setRecaptchaToken(null); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is housekeeping + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for housekeeping + toast.success('Login successful!'); + setRecaptchaToken(null); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Login failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + isAuthenticated: false, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + toast.error(errorMessage); + setRecaptchaToken(null); + } + } catch (error) { + if (import.meta.env.DEV) { + console.error('Login error:', error); + } + setRecaptchaToken(null); + } + }; + + const onSubmitMFA = async (data: MFATokenFormData) => { + try { + clearError(); + + // Get pending credentials from store + const state = useAuthStore.getState(); + if (!state.pendingCredentials) { + toast.error('No pending login credentials'); + return; + } + + // Call MFA verification API directly to check role before showing success + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const credentials = { + ...state.pendingCredentials, + mfaToken: data.mfaToken, + expectedRole: 'housekeeping', // Housekeeping login page only accepts housekeeping + }; + + const response = await authService.login(credentials); + + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'MFA verification failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-housekeeping roles - show error and don't authenticate + if (role !== 'housekeeping') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for housekeeping staff. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset auth store state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is housekeeping + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for housekeeping + toast.success('Login successful!'); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'MFA verification failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + requiresMFA: true, // Keep MFA state in case of error + }); + toast.error(errorMessage); + } + } catch (error) { + if (import.meta.env.DEV) { + console.error('MFA verification error:', error); + } + } + }; + + const handleBackToLogin = () => { + clearMFA(); + clearError(); + }; + + return ( +
+
+
+
+
+ {settings.company_logo_url ? ( + {settings.company_name + ) : ( +
+ +
+ )} +
+

Housekeeping Login

+

Access your housekeeping dashboard

+
+ +
+ {requiresMFA ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+
+ +
+ +
+ {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +
+ + + +
+ +
+
+ ) : ( +
+ + + {error && ( +
+ {error} +
+ )} + + {rateLimitInfo && !rateLimitInfo.allowed && ( +
+

Too many login attempts.

+

+ Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()} +

+
+ )} + +
+ +
+
+ +
+ +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + +
+ +
+ setRecaptchaToken(token)} + onError={(error) => { + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } + setRecaptchaToken(null); + }} + theme="light" + size="normal" + /> +
+ + + + )} + +
+ + ← Back to Home + +
+
+
+
+
+ ); +}; + +export default HousekeepingLoginPage; + diff --git a/Frontend/src/features/auth/pages/StaffLoginPage.tsx b/Frontend/src/features/auth/pages/StaffLoginPage.tsx new file mode 100644 index 00000000..a2e9c8b6 --- /dev/null +++ b/Frontend/src/features/auth/pages/StaffLoginPage.tsx @@ -0,0 +1,576 @@ +import React, { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { X, Eye, EyeOff, LogIn, Loader2, Mail, Lock, Shield, ArrowLeft, User } from 'lucide-react'; +import { useNavigate, Link } from 'react-router-dom'; +import useAuthStore from '../../../store/useAuthStore'; +import { loginSchema, LoginFormData } from '../../../shared/utils/validationSchemas'; +import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext'; +import * as yup from 'yup'; +import { toast } from 'react-toastify'; +import Recaptcha from '../../../shared/components/Recaptcha'; +import { recaptchaService } from '../../../features/system/services/systemSettingsService'; +import { useAntibotForm } from '../hooks/useAntibotForm'; +import HoneypotField from '../../../shared/components/HoneypotField'; +import authService from '../services/authService'; + +const mfaTokenSchema = yup.object().shape({ + mfaToken: yup + .string() + .required('MFA token is required') + .min(6, 'MFA token must be 6 digits') + .max(8, 'MFA token must be 6-8 characters') + .matches(/^\d+$|^[A-Z0-9]{8}$/, 'Invalid token format'), +}); + +type MFATokenFormData = yup.InferType; + +const StaffLoginPage: React.FC = () => { + const { isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); + const { settings } = useCompanySettings(); + const navigate = useNavigate(); + + const [showPassword, setShowPassword] = useState(false); + + // Enhanced antibot protection + const { + honeypotValue, + setHoneypotValue, + recaptchaToken, + setRecaptchaToken, + validate: validateAntibot, + rateLimitInfo, + } = useAntibotForm({ + formId: 'staff-login', + minTimeOnPage: 3000, + minTimeToFill: 2000, + requireRecaptcha: false, + maxAttempts: 5, + onValidationError: (errors) => { + errors.forEach((err) => toast.error(err)); + }, + }); + + const { + register: registerMFA, + handleSubmit: handleSubmitMFA, + formState: { errors: mfaErrors }, + } = useForm({ + resolver: yupResolver(mfaTokenSchema), + defaultValues: { + mfaToken: '', + }, + }); + + // Redirect to staff dashboard on successful authentication + useEffect(() => { + if (!isLoading && isAuthenticated && !requiresMFA && userInfo) { + const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase(); + + // Safety check - should not happen if onSubmit logic works correctly + if (role !== 'staff') { + navigate(`/${role}/login`, { replace: true }); + return; + } + + navigate('/staff/dashboard', { replace: true }); + } + }, [isLoading, isAuthenticated, requiresMFA, userInfo, navigate]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: yupResolver(loginSchema), + defaultValues: { + email: '', + password: '', + rememberMe: false, + }, + }); + + const onSubmit = async (data: LoginFormData) => { + try { + clearError(); + + // Validate antibot protection + const isValid = await validateAntibot(); + if (!isValid) { + return; + } + + // Verify reCAPTCHA if token is provided + if (recaptchaToken) { + try { + const verifyResponse = await recaptchaService.verifyRecaptcha(recaptchaToken); + if (verifyResponse.status === 'error' || !verifyResponse.data.verified) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } catch (error) { + toast.error('reCAPTCHA verification failed. Please try again.'); + setRecaptchaToken(null); + return; + } + } + + // Call login API directly to check role BEFORE showing success toast + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const response = await authService.login({ + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'staff', // Staff login page only accepts staff + }); + + // Handle MFA requirement + if (response.requires_mfa) { + useAuthStore.setState({ + isLoading: false, + requiresMFA: true, + mfaUserId: response.user_id || null, + pendingCredentials: { + email: data.email, + password: data.password, + rememberMe: data.rememberMe, + expectedRole: 'staff', + }, + error: null, + }); + setRecaptchaToken(null); + return; + } + + // Check if login was successful + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'Login failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-staff roles - show error and don't authenticate + if (role !== 'staff') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for staff members. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset loading state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + }); + + setRecaptchaToken(null); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is staff + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for staff + toast.success('Login successful!'); + setRecaptchaToken(null); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Login failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + isAuthenticated: false, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + toast.error(errorMessage); + setRecaptchaToken(null); + } + } catch (error) { + if (import.meta.env.DEV) { + console.error('Login error:', error); + } + setRecaptchaToken(null); + } + }; + + const onSubmitMFA = async (data: MFATokenFormData) => { + try { + clearError(); + + // Get pending credentials from store + const state = useAuthStore.getState(); + if (!state.pendingCredentials) { + toast.error('No pending login credentials'); + return; + } + + // Call MFA verification API directly to check role before showing success + useAuthStore.setState({ isLoading: true, error: null }); + + try { + const credentials = { + ...state.pendingCredentials, + mfaToken: data.mfaToken, + expectedRole: 'staff', // Staff login page only accepts staff + }; + + const response = await authService.login(credentials); + + if (response.success || response.status === 'success') { + const user = response.data?.user ?? null; + + if (!user) { + throw new Error(response.message || 'MFA verification failed.'); + } + + // Check role BEFORE setting authenticated state or showing success toast + const role = user.role?.toLowerCase() || (user as any).role_name?.toLowerCase(); + + // Reject non-staff roles - show error and don't authenticate + if (role !== 'staff') { + // Call logout API to clear any server-side session + await authService.logout().catch(() => { + // Ignore logout errors + }); + + // Show error toast + toast.error(`This login is only for staff members. Please use the ${role} login page at /${role}/login to access your account.`, { + autoClose: 6000, + position: 'top-center', + toastId: 'role-error', + }); + + // Reset auth store state + useAuthStore.setState({ + isLoading: false, + error: 'Invalid role for this login page', + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Navigate to the appropriate login page + setTimeout(() => { + navigate(`/${role}/login`, { replace: true }); + }, 2000); + return; + } + + // Only proceed with authentication if user is staff + // Store minimal userInfo in localStorage + const minimalUserInfo = { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatar: user.avatar, + }; + localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo)); + + // Update auth store state + useAuthStore.setState({ + token: null, + userInfo: user, + isAuthenticated: true, + isLoading: false, + error: null, + requiresMFA: false, + mfaUserId: null, + pendingCredentials: null, + }); + + // Show success toast only for staff + toast.success('Login successful!'); + } + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'MFA verification failed. Please try again.'; + useAuthStore.setState({ + isLoading: false, + error: errorMessage, + requiresMFA: true, // Keep MFA state in case of error + }); + toast.error(errorMessage); + } + } catch (error) { + if (import.meta.env.DEV) { + console.error('MFA verification error:', error); + } + } + }; + + const handleBackToLogin = () => { + clearMFA(); + clearError(); + }; + + return ( +
+
+
+ {/* Header */} +
+
+ {settings.company_logo_url ? ( + {settings.company_name + ) : ( +
+ +
+ )} +
+

Staff Login

+

Access your staff dashboard

+
+ +
+ {requiresMFA ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+
+ +
+ +
+ {mfaErrors.mfaToken && ( +

{mfaErrors.mfaToken.message}

+ )} +
+ + + +
+ +
+
+ ) : ( +
+ + + {error && ( +
+ {error} +
+ )} + + {rateLimitInfo && !rateLimitInfo.allowed && ( +
+

Too many login attempts.

+

+ Please try again after {new Date(rateLimitInfo.resetTime).toLocaleTimeString()} +

+
+ )} + +
+ +
+
+ +
+ +
+ {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+
+ + +
+
+ +
+ setRecaptchaToken(token)} + onError={(error) => { + if (import.meta.env.DEV) { + console.error('reCAPTCHA error:', error); + } + setRecaptchaToken(null); + }} + theme="light" + size="normal" + /> +
+ + + + )} + +
+ + ← Back to Home + +
+
+
+
+
+ ); +}; + +export default StaffLoginPage; + diff --git a/Frontend/src/features/auth/pages/index.ts b/Frontend/src/features/auth/pages/index.ts new file mode 100644 index 00000000..b60dd0bf --- /dev/null +++ b/Frontend/src/features/auth/pages/index.ts @@ -0,0 +1,5 @@ +export { default as StaffLoginPage } from './StaffLoginPage'; +export { default as AdminLoginPage } from './AdminLoginPage'; +export { default as HousekeepingLoginPage } from './HousekeepingLoginPage'; +export { default as AccountantLoginPage } from './AccountantLoginPage'; + diff --git a/Frontend/src/features/auth/services/authService.ts b/Frontend/src/features/auth/services/authService.ts index fe3608fa..45b81cdc 100644 --- a/Frontend/src/features/auth/services/authService.ts +++ b/Frontend/src/features/auth/services/authService.ts @@ -5,6 +5,7 @@ export interface LoginCredentials { password: string; rememberMe?: boolean; mfaToken?: string; + expectedRole?: string; // Optional role validation for role-specific login endpoints } export interface RegisterData { @@ -80,7 +81,9 @@ const authService = { try { await apiClient.post('/api/auth/logout'); } catch (error) { - console.error('Logout error:', error); + if (import.meta.env.DEV) { + console.error('Logout error:', error); + } } }, diff --git a/Frontend/src/features/content/pages/AboutPage.tsx b/Frontend/src/features/content/pages/AboutPage.tsx index 978d8ff4..da7e14b0 100644 --- a/Frontend/src/features/content/pages/AboutPage.tsx +++ b/Frontend/src/features/content/pages/AboutPage.tsx @@ -18,10 +18,14 @@ import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer'; const AboutPage: React.FC = () => { const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); + const [apiError, setApiError] = useState(false); + const [loading, setLoading] = useState(true); useEffect(() => { const fetchPageContent = async () => { try { + setLoading(true); + setApiError(false); const response = await pageContentService.getAboutContent(); if (response.status === 'success' && response.data?.page_content) { setPageContent(response.data.page_content); @@ -39,10 +43,16 @@ const AboutPage: React.FC = () => { } metaDescription.setAttribute('content', response.data.page_content.meta_description); } + } else { + // No data received - don't set error, just leave pageContent as null + setPageContent(null); } } catch (err: any) { console.error('Error fetching page content:', err); - + setApiError(true); + setPageContent(null); + } finally { + setLoading(false); } }; @@ -50,9 +60,10 @@ const AboutPage: React.FC = () => { }, []); - const displayPhone = settings.company_phone || '+1 (234) 567-890'; - const displayEmail = settings.company_email || 'info@luxuryhotel.com'; - const displayAddress = settings.company_address || '123 Luxury Street\nCity, State 12345\nCountry'; + // Only use company settings from API, no hardcoded fallbacks + const displayPhone = settings.company_phone || null; + const displayEmail = settings.company_email || null; + const displayAddress = settings.company_address || null; const defaultValues = [ @@ -96,21 +107,23 @@ const AboutPage: React.FC = () => { } ]; - const values = pageContent?.values && pageContent.values.length > 0 + // Only use default values/features if pageContent was successfully loaded but is empty + // Don't use defaults if API failed + const values = pageContent && pageContent.values && pageContent.values.length > 0 ? pageContent.values.map((v: any) => ({ icon: v.icon || defaultValues.find(d => d.title === v.title)?.icon || 'Heart', title: v.title, description: v.description })) - : defaultValues; + : (pageContent && !apiError ? defaultValues : []); - const features = pageContent?.features && pageContent.features.length > 0 + const features = pageContent && pageContent.features && pageContent.features.length > 0 ? pageContent.features.map((f: any) => ({ icon: f.icon || defaultFeatures.find(d => d.title === f.title)?.icon || 'Star', title: f.title, description: f.description })) - : defaultFeatures; + : (pageContent && !apiError ? defaultFeatures : []); const team = pageContent?.team && typeof pageContent.team === 'string' @@ -130,6 +143,29 @@ const AboutPage: React.FC = () => { return IconComponent; }; + // Show error state if API failed + if (apiError) { + return ( +
+
+

Unable to Load Content

+

Please check your connection and try again later.

+
+
+ ); + } + + // Show loading state + if (loading) { + return ( +
+
+

Loading...

+
+
+ ); + } + return (
{} @@ -235,6 +271,7 @@ const AboutPage: React.FC = () => { {} + {values.length > 0 && (
@@ -280,8 +317,10 @@ const AboutPage: React.FC = () => {
+ )} {} + {features.length > 0 && (
@@ -327,8 +366,8 @@ const AboutPage: React.FC = () => {
+ )} - {} {(pageContent?.mission || pageContent?.vision) && (
@@ -570,7 +609,9 @@ const AboutPage: React.FC = () => { We'd love to hear from you. Contact us for reservations or inquiries.

+ {(displayAddress || displayPhone || displayEmail) && (
+ {displayAddress && (
@@ -588,6 +629,8 @@ const AboutPage: React.FC = () => { ))}

+ )} + {displayPhone && (
@@ -601,6 +644,8 @@ const AboutPage: React.FC = () => {

+ )} + {displayEmail && (
@@ -614,7 +659,9 @@ const AboutPage: React.FC = () => {

+ )}
+ )}
{ const [isLoadingNewest, setIsLoadingNewest] = useState(true); const [, setIsLoadingContent] = useState(true); const [error, setError] = useState(null); + const [apiError, setApiError] = useState(false); + const [apiErrorMessage, setApiErrorMessage] = useState(''); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [lightboxImages, setLightboxImages] = useState([]); + // Prevent body scroll when API error modal is shown + useEffect(() => { + if (apiError) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + return () => { + document.body.style.overflow = 'unset'; + }; + }, [apiError]); useEffect(() => { if (!lightboxOpen) return; @@ -86,6 +99,7 @@ const HomePage: React.FC = () => { const fetchPageContent = async () => { try { setIsLoadingContent(true); + setApiError(false); const response = await pageContentService.getHomeContent(); if (response.status === 'success' && response.data?.page_content) { const content = response.data.page_content; @@ -198,11 +212,17 @@ const HomePage: React.FC = () => { document.head.appendChild(metaDescription); } metaDescription.setAttribute('content', content.meta_description); + } else { + setPageContent(null); } + } else { + setPageContent(null); } } catch (err: any) { console.error('Error fetching page content:', err); - + setApiError(true); + setApiErrorMessage('Unable to connect to the server. Please check your internet connection and try again.'); + setPageContent(null); } finally { setIsLoadingContent(false); } @@ -225,11 +245,14 @@ const HomePage: React.FC = () => { response.status === 'success' ) { setBanners(response.data?.banners || []); + } else { + setBanners([]); } } catch (err: any) { console.error('Error fetching banners:', err); - - + setApiError(true); + setApiErrorMessage('Unable to connect to the server. Please check your internet connection and try again.'); + setBanners([]); } finally { setIsLoadingBanners(false); } @@ -261,7 +284,7 @@ const HomePage: React.FC = () => { setError(null); } } else { - + setFeaturedRooms([]); setError( response.message || 'Unable to load room list' @@ -269,7 +292,7 @@ const HomePage: React.FC = () => { } } catch (err: any) { console.error('Error fetching rooms:', err); - + setFeaturedRooms([]); if (err.response?.status === 429) { setError( @@ -307,10 +330,14 @@ const HomePage: React.FC = () => { response.status === 'success' ) { setNewestRooms(response.data?.rooms || []); + } else { + setNewestRooms([]); } } catch (err: any) { console.error('Error fetching newest rooms:', err); - + setApiError(true); + setApiErrorMessage('Unable to connect to the server. Please check your internet connection and try again.'); + setNewestRooms([]); } finally { setIsLoadingNewest(false); } @@ -321,6 +348,60 @@ const HomePage: React.FC = () => { return ( <> + {/* Persistent API Error Modal - Cannot be closed */} + {apiError && ( +
{ + // Prevent closing with Escape key + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + } + }} + onClick={(e) => { + // Prevent closing by clicking outside + e.stopPropagation(); + }} + > +
e.stopPropagation()} + > +
+
+ +
+

+ API Connection Error +

+

+ {apiErrorMessage || 'Unable to connect to the server. The API is currently unavailable.'} +

+
+

+ Please check: +

+
    +
  • Your internet connection
  • +
  • If the server is running
  • +
  • If the API endpoint is accessible
  • +
+
+
+ +
+
+
+
+ )} + {}
{ > {isLoadingBanners ? ( - ) : ( + ) : banners.length > 0 ? (
- )} + ) : null}
@@ -353,10 +434,10 @@ const HomePage: React.FC = () => {

- {pageContent?.hero_title || 'Featured & Newest Rooms'} + {pageContent?.hero_title || (apiError ? '' : 'Featured & Newest Rooms')}

- {pageContent?.hero_subtitle || pageContent?.description || 'Discover our most popular accommodations and latest additions'} + {pageContent?.hero_subtitle || pageContent?.description || (apiError ? '' : 'Discover our most popular accommodations and latest additions')}

{} @@ -439,7 +520,8 @@ const HomePage: React.FC = () => { (f: any) => f && (f.title || f.description) ) || []; - return (validFeatures.length > 0 || !pageContent) && ( + // Only show section if we have features from API, or if pageContent was loaded but is empty (not if API failed) + return validFeatures.length > 0 && (
{} @@ -489,72 +571,7 @@ const HomePage: React.FC = () => { )}
)) - ) : ( - <> -
-
- 🏨 -
-

- Easy Booking -

-

- Search and book rooms with just a few clicks -

-
- -
-
- 💰 -
-

- Best Prices -

-

- Best price guarantee in the market -

-
- -
-
- 🎧 -
-

- 24/7 Support -

-

- Support team always ready to serve -

-
- - )} + ) : null}
diff --git a/Frontend/src/features/rooms/components/BannerCarousel.tsx b/Frontend/src/features/rooms/components/BannerCarousel.tsx index 1caa15c2..ac6e1623 100644 --- a/Frontend/src/features/rooms/components/BannerCarousel.tsx +++ b/Frontend/src/features/rooms/components/BannerCarousel.tsx @@ -53,23 +53,12 @@ const BannerCarousel: React.FC = ({ }; - const defaultBanner: Banner = { - id: 0, - title: 'Welcome to Hotel Booking', - image_url: '/images/default-banner.jpg', - position: 'home', - display_order: 0, - is_active: true, - created_at: '', - updated_at: '', - description: undefined, - link_url: undefined, - }; + // Don't render if no banners - only show banners from API + if (banners.length === 0) { + return null; + } - const displayBanners = banners.length > 0 - ? banners - : [defaultBanner]; - const currentBanner = displayBanners[currentIndex]; + const currentBanner = banners[currentIndex]; return (
= ({ > {}
- {displayBanners.map((banner, index) => ( + {banners.map((banner, index) => (
= ({ src={banner.image_url} alt={banner.title} className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105" - onError={(e) => { - e.currentTarget.src = '/images/default-banner.jpg'; - }} /> ) : ( @@ -105,9 +91,6 @@ const BannerCarousel: React.FC = ({ src={banner.image_url} alt={banner.title} className="w-full h-full object-cover object-center transition-transform duration-1000 ease-out hover:scale-105" - onError={(e) => { - e.currentTarget.src = '/images/default-banner.jpg'; - }} /> )}
@@ -272,7 +255,7 @@ const BannerCarousel: React.FC = ({
{} - {displayBanners.length > 1 && ( + {banners.length > 1 && ( <> - )} - - {/* Sidebar */} -
-
- {/* Logo/Brand */} -
-
- - Housekeeping +
+ {/* Luxury Top Navigation Bar */} +
+
+
+ {/* Logo/Brand */} +
+
+
+
+ +
+
+
+

+ Enterprise Housekeeping +

+

Luxury Management System

+
-
- {/* Navigation */} - - - {/* User info and logout */} -
-
-

{userInfo?.name || userInfo?.email || 'User'}

-

{userInfo?.role || 'housekeeping'}

+ {/* User Menu */} +
+
+
+ +
+
+

{userInfo?.name || userInfo?.email || 'User'}

+

{userInfo?.role || 'housekeeping'}

+
+
+
-
-
- - {/* Overlay for mobile */} - {isMobile && sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} +
{/* Main content */} -
-
- -
-
+
+ +
); }; diff --git a/Frontend/src/pages/accountant/DashboardPage.tsx b/Frontend/src/pages/accountant/DashboardPage.tsx index 075b2428..b39b4be4 100644 --- a/Frontend/src/pages/accountant/DashboardPage.tsx +++ b/Frontend/src/pages/accountant/DashboardPage.tsx @@ -93,12 +93,29 @@ const AccountantDashboardPage: React.FC = () => { totalPayments: response.data.payments.length, pendingPayments: pendingPayments.length, })); + } else { + // Clear data if response is not successful + setRecentPayments([]); + setFinancialSummary(prev => ({ + ...prev, + totalRevenue: 0, + totalPayments: 0, + pendingPayments: 0, + })); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentPayments([]); + setFinancialSummary(prev => ({ + ...prev, + totalRevenue: 0, + totalPayments: 0, + pendingPayments: 0, + })); logger.error('Error fetching payments', err); } finally { setLoadingPayments(false); @@ -139,12 +156,29 @@ const AccountantDashboardPage: React.FC = () => { paidInvoices: paidInvoices.length, overdueInvoices: overdueInvoices.length, })); + } else { + // Clear data if response is not successful + setRecentInvoices([]); + setFinancialSummary(prev => ({ + ...prev, + totalInvoices: 0, + paidInvoices: 0, + overdueInvoices: 0, + })); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentInvoices([]); + setFinancialSummary(prev => ({ + ...prev, + totalInvoices: 0, + paidInvoices: 0, + overdueInvoices: 0, + })); logger.error('Error fetching invoices', err); } finally { setLoadingInvoices(false); diff --git a/Frontend/src/pages/admin/AuditLogsPage.tsx b/Frontend/src/pages/admin/AuditLogsPage.tsx index d3484da5..17848e8b 100644 --- a/Frontend/src/pages/admin/AuditLogsPage.tsx +++ b/Frontend/src/pages/admin/AuditLogsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { FileText, Search, @@ -75,6 +75,10 @@ const AuditLogsPage: React.FC = () => { if (error.name === 'AbortError') { return; } + // Clear data when API connection fails + setLogs([]); + setTotalPages(1); + setTotalItems(0); logger.error('Error fetching audit logs', error); toast.error(error.response?.data?.message || 'Unable to load audit logs'); } finally { diff --git a/Frontend/src/pages/admin/DashboardPage.tsx b/Frontend/src/pages/admin/DashboardPage.tsx index 0578631e..735757d1 100644 --- a/Frontend/src/pages/admin/DashboardPage.tsx +++ b/Frontend/src/pages/admin/DashboardPage.tsx @@ -89,12 +89,17 @@ const DashboardPage: React.FC = () => { const response = await paymentService.getPayments({ page: 1, limit: 5 }); if (response.success && response.data?.payments) { setRecentPayments(response.data.payments); + } else { + // Clear data if response is not successful + setRecentPayments([]); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentPayments([]); logger.error('Error fetching payments', err); } finally { setLoadingPayments(false); @@ -125,12 +130,17 @@ const DashboardPage: React.FC = () => { const response = await sessionService.getMySessions(); if (response.success && response.data?.sessions) { setSessions(response.data.sessions || []); + } else { + // Clear data if response is not successful + setSessions([]); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setSessions([]); logger.error('Error fetching sessions', err); } finally { setLoadingSessions(false); diff --git a/Frontend/src/pages/customer/DashboardPage.tsx b/Frontend/src/pages/customer/DashboardPage.tsx index b43c24a0..e9e5128e 100644 --- a/Frontend/src/pages/customer/DashboardPage.tsx +++ b/Frontend/src/pages/customer/DashboardPage.tsx @@ -65,9 +65,14 @@ const DashboardPage: React.FC = () => { const response = await paymentService.getPayments({ page: 1, limit: 5 }); if (response.success && response.data?.payments) { setRecentPayments(response.data.payments); + } else { + // Clear data if response is not successful + setRecentPayments([]); } } catch (err: any) { if (err.name !== 'AbortError') { + // Clear data when API connection fails + setRecentPayments([]); logger.error('Error fetching payments', err); } } finally { @@ -101,9 +106,14 @@ const DashboardPage: React.FC = () => { const response = await sessionService.getMySessions(); if (response.success && response.data?.sessions) { setSessions(response.data.sessions || []); + } else { + // Clear data if response is not successful + setSessions([]); } } catch (err: any) { if (err.name !== 'AbortError') { + // Clear data when API connection fails + setSessions([]); logger.error('Error fetching sessions', err); } } finally { diff --git a/Frontend/src/pages/customer/RoomListPage.tsx b/Frontend/src/pages/customer/RoomListPage.tsx index 101d3b80..d6004531 100644 --- a/Frontend/src/pages/customer/RoomListPage.tsx +++ b/Frontend/src/pages/customer/RoomListPage.tsx @@ -80,6 +80,14 @@ const RoomListPage: React.FC = () => { if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRooms([]); + setPagination({ + total: 0, + page: 1, + limit: 12, + totalPages: 0, + }); logger.error('Error fetching rooms', err); setError('Unable to load room list. Please try again.'); } finally { diff --git a/Frontend/src/pages/housekeeping/DashboardPage.tsx b/Frontend/src/pages/housekeeping/DashboardPage.tsx index f98d5a97..46781eb6 100644 --- a/Frontend/src/pages/housekeeping/DashboardPage.tsx +++ b/Frontend/src/pages/housekeeping/DashboardPage.tsx @@ -8,6 +8,12 @@ import { Calendar, MapPin, Play, + X, + TrendingUp, + Activity, + Award, + Eye, + Building2, ChevronDown, ChevronUp, } from 'lucide-react'; @@ -18,9 +24,310 @@ import advancedRoomService, { HousekeepingTask, ChecklistItem } from '../../feat import { logger } from '../../shared/utils/logger'; import useAuthStore from '../../store/useAuthStore'; +// Luxury Task Detail Modal Component +interface TaskModalProps { + task: HousekeepingTask | null; + isOpen: boolean; + onClose: () => void; + onUpdateChecklist: (task: HousekeepingTask, itemIndex: number, checked: boolean) => Promise; + onStartTask: (task: HousekeepingTask) => Promise; + onCompleteTask: (task: HousekeepingTask) => Promise; + isUpdating: boolean; +} + +const TaskDetailModal: React.FC = ({ + task, + isOpen, + onClose, + onUpdateChecklist, + onStartTask, + onCompleteTask, + isUpdating, +}) => { + if (!isOpen || !task) return null; + + const completedItems = task.checklist_items?.filter(item => item.completed).length || 0; + const totalItems = task.checklist_items?.length || 0; + const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; + const canStart = task.status === 'pending'; + const canComplete = task.status === 'in_progress' || task.status === 'pending'; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': + return 'bg-gradient-to-r from-green-500 to-emerald-600 text-white'; + case 'in_progress': + return 'bg-gradient-to-r from-blue-500 to-cyan-600 text-white'; + case 'pending': + return 'bg-gradient-to-r from-amber-500 to-orange-500 text-white'; + default: + return 'bg-gray-500 text-white'; + } + }; + + return ( +
+ {/* Enhanced Backdrop with luxury effect */} +
+ + {/* Luxury Modal with enhanced styling - Compact */} +
+ {/* Luxury Header with enhanced gradient and effects - Compact */} +
+ {/* Animated background pattern */} +
+
+
+ {/* Shimmer effect */} +
+
+ +
+
+
+
+
+ +
+
+
+

+ Room {task.room_number || task.room_id} +

+
+

{task.task_type}

+ + {task.status.replace('_', ' ').toUpperCase()} + +
+
+
+ +
+
+ + {/* Content with luxury scrollbar - Compact */} +
+ {/* Task Info with luxury cards - Compact */} +
+
+
+
+ +
+
+

Scheduled Time

+

{formatDate(task.scheduled_time)}

+
+
+ {task.completed_at && ( +
+
+
+ +
+
+

Completed At

+

{formatDate(task.completed_at)}

+
+
+ )} +
+ + {/* Notes with luxury styling - Compact */} + {task.notes && ( +
+
+
+

+
+ +
+ Special Notes +

+

{task.notes}

+
+
+ )} + + {/* Progress Bar with luxury styling - Compact */} + {task.checklist_items && task.checklist_items.length > 0 && ( +
+
+

+
+ +
+ Task Progress +

+ {progress}% +
+
+
+
+
+
+

+ {completedItems} of {totalItems} items completed +

+
+ )} + + {/* Checklist with luxury styling - Compact */} + {task.checklist_items && task.checklist_items.length > 0 && ( +
+

+
+ +
+ Checklist Items +

+
+ {task.checklist_items.map((item: ChecklistItem, index: number) => ( + + ))} +
+
+ )} + + {/* Duration with luxury styling - Compact */} + {task.actual_duration_minutes && ( +
+
+

+ Duration: {task.actual_duration_minutes} minutes +

+
+ )} +
+ + {/* Luxury Footer Actions - Sticky and Compact */} +
+
+ + {canStart && ( + + )} + {canComplete && ( + + )} +
+
+
+ ); +}; + +// Utility function to extract floor number from room number +const getFloorFromRoomNumber = (roomNumber: string | number | undefined): number => { + if (!roomNumber) return 0; + const roomStr = String(roomNumber); + // Extract first digit(s) as floor number (e.g., "101" -> 1, "201" -> 2, "1201" -> 12) + const match = roomStr.match(/^(\d+)/); + if (match) { + const floor = parseInt(match[1], 10); + // If room number is like "101", floor is 1; if "201", floor is 2 + // For numbers > 100, take first digit(s) as floor + return floor >= 100 ? Math.floor(floor / 100) : floor; + } + return 0; +}; + +// Get floor display name +const getFloorDisplayName = (floor: number): string => { + if (floor === 0) return 'Ground Floor'; + if (floor === 1) return '1st Floor'; + if (floor === 2) return '2nd Floor'; + if (floor === 3) return '3rd Floor'; + return `${floor}th Floor`; +}; + const HousekeepingDashboardPage: React.FC = () => { const { userInfo } = useAuthStore(); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [tasks, setTasks] = useState([]); const [stats, setStats] = useState({ pending: 0, @@ -28,21 +335,22 @@ const HousekeepingDashboardPage: React.FC = () => { completed: 0, total: 0, }); - const [expandedTasks, setExpandedTasks] = useState>(new Set()); + const [selectedTask, setSelectedTask] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); const [updatingTasks, setUpdatingTasks] = useState>(new Set()); + const [selectedFloor, setSelectedFloor] = useState(null); const tasksAbortRef = useRef(null); const fetchTasks = async () => { try { - // Cancel previous request if exists if (tasksAbortRef.current) { tasksAbortRef.current.abort(); } tasksAbortRef.current = new AbortController(); setLoading(true); + setError(null); - // Fetch today's tasks assigned to current user const today = new Date().toISOString().split('T')[0]; const response = await advancedRoomService.getHousekeepingTasks({ date: today, @@ -56,7 +364,6 @@ const HousekeepingDashboardPage: React.FC = () => { ); setTasks(userTasks); - // Calculate stats const pending = userTasks.filter((t: HousekeepingTask) => t.status === 'pending').length; const in_progress = userTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length; const completed = userTasks.filter((t: HousekeepingTask) => t.status === 'completed').length; @@ -67,13 +374,32 @@ const HousekeepingDashboardPage: React.FC = () => { completed, total: userTasks.length, }); + } else { + setTasks([]); + setStats({ + pending: 0, + in_progress: 0, + completed: 0, + total: 0, + }); } } catch (error: any) { if (error.name === 'AbortError') { return; } logger.error('Error fetching housekeeping tasks', error); - toast.error('Failed to load tasks'); + + setTasks([]); + setStats({ + pending: 0, + in_progress: 0, + completed: 0, + total: 0, + }); + + const errorMessage = error.response?.data?.detail || error.message || 'Failed to connect to server'; + setError(errorMessage); + toast.error('Failed to load tasks. Please check your connection.'); } finally { setLoading(false); } @@ -82,7 +408,6 @@ const HousekeepingDashboardPage: React.FC = () => { useEffect(() => { fetchTasks(); - // Auto-refresh every 30 seconds const interval = setInterval(() => { if (document.visibilityState === 'visible') { fetchTasks(); @@ -100,13 +425,13 @@ const HousekeepingDashboardPage: React.FC = () => { const getStatusColor = (status: string) => { switch (status) { case 'completed': - return 'bg-green-100 text-green-800 border-green-200'; + return 'from-green-500 to-emerald-600'; case 'in_progress': - return 'bg-blue-100 text-blue-800 border-blue-200'; + return 'from-blue-500 to-cyan-600'; case 'pending': - return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + return 'from-amber-500 to-orange-500'; default: - return 'bg-gray-100 text-gray-800 border-gray-200'; + return 'from-gray-500 to-gray-600'; } }; @@ -123,18 +448,70 @@ const HousekeepingDashboardPage: React.FC = () => { } }; - const toggleTaskExpansion = (taskId: number) => { - setExpandedTasks(prev => { - const newSet = new Set(prev); - if (newSet.has(taskId)) { - newSet.delete(taskId); - } else { - newSet.add(taskId); - } - return newSet; - }); + const openTaskModal = (task: HousekeepingTask) => { + setSelectedTask(task); + setIsModalOpen(true); }; + const closeTaskModal = () => { + setIsModalOpen(false); + setSelectedTask(null); + }; + + // Group tasks by floor + const tasksByFloor = React.useMemo(() => { + const grouped: { [floor: number]: HousekeepingTask[] } = {}; + tasks.forEach(task => { + const floor = getFloorFromRoomNumber(task.room_number); + if (!grouped[floor]) { + grouped[floor] = []; + } + grouped[floor].push(task); + }); + + // Sort floors and tasks within each floor + const sortedFloors = Object.keys(grouped) + .map(Number) + .sort((a, b) => a - b); + + const result: { floor: number; tasks: HousekeepingTask[] }[] = []; + sortedFloors.forEach(floor => { + // Sort tasks by room number within each floor + const sortedTasks = grouped[floor].sort((a, b) => { + const roomA = parseInt(String(a.room_number || a.room_id), 10) || 0; + const roomB = parseInt(String(b.room_number || b.room_id), 10) || 0; + return roomA - roomB; + }); + result.push({ floor, tasks: sortedTasks }); + }); + + return result; + }, [tasks]); + + // Auto-select first floor on initial load + useEffect(() => { + if (tasksByFloor.length > 0 && selectedFloor === null) { + setSelectedFloor(tasksByFloor[0].floor); + } + }, [tasksByFloor, selectedFloor]); + + // Get current floor tasks + const currentFloorTasks = React.useMemo(() => { + if (selectedFloor === null) return []; + const floorData = tasksByFloor.find(f => f.floor === selectedFloor); + return floorData?.tasks || []; + }, [tasksByFloor, selectedFloor]); + + // Get current floor stats + const currentFloorStats = React.useMemo(() => { + return { + pending: currentFloorTasks.filter(t => t.status === 'pending').length, + in_progress: currentFloorTasks.filter(t => t.status === 'in_progress').length, + completed: currentFloorTasks.filter(t => t.status === 'completed').length, + total: currentFloorTasks.length, + }; + }, [currentFloorTasks]); + const handleStartTask = async (task: HousekeepingTask) => { if (updatingTasks.has(task.id)) return; @@ -143,8 +520,11 @@ const HousekeepingDashboardPage: React.FC = () => { await advancedRoomService.updateHousekeepingTask(task.id, { status: 'in_progress', }); - toast.success('Task started'); + toast.success('Task started successfully!'); await fetchTasks(); + if (selectedTask?.id === task.id) { + setSelectedTask({ ...task, status: 'in_progress' }); + } } catch (error: any) { logger.error('Error starting task', error); toast.error(error.response?.data?.detail || 'Failed to start task'); @@ -174,7 +554,6 @@ const HousekeepingDashboardPage: React.FC = () => { checklist_items: updatedChecklist, }); - // Update local state immediately for better UX setTasks(prevTasks => prevTasks.map(t => t.id === task.id @@ -183,7 +562,10 @@ const HousekeepingDashboardPage: React.FC = () => { ) ); - // Recalculate stats + if (selectedTask?.id === task.id) { + setSelectedTask({ ...task, checklist_items: updatedChecklist }); + } + const updatedTask = { ...task, checklist_items: updatedChecklist }; const allTasks = tasks.map(t => t.id === task.id ? updatedTask : t); const pending = allTasks.filter((t: HousekeepingTask) => t.status === 'pending').length; @@ -206,7 +588,6 @@ const HousekeepingDashboardPage: React.FC = () => { const handleCompleteTask = async (task: HousekeepingTask) => { if (updatingTasks.has(task.id)) return; - // Check if all checklist items are completed const allCompleted = task.checklist_items?.every(item => item.completed) ?? true; if (!allCompleted && task.checklist_items && task.checklist_items.length > 0) { @@ -218,7 +599,6 @@ const HousekeepingDashboardPage: React.FC = () => { setUpdatingTasks(prev => new Set(prev).add(task.id)); try { - // Mark all checklist items as completed if not already const updatedChecklist = task.checklist_items?.map(item => ({ ...item, completed: true, @@ -228,8 +608,9 @@ const HousekeepingDashboardPage: React.FC = () => { status: 'completed', checklist_items: updatedChecklist, }); - toast.success('Task marked as completed!'); + toast.success('Task completed successfully! 🎉'); await fetchTasks(); + closeTaskModal(); } catch (error: any) { logger.error('Error completing task', error); toast.error(error.response?.data?.detail || 'Failed to complete task'); @@ -242,264 +623,360 @@ const HousekeepingDashboardPage: React.FC = () => { } }; - if (loading && tasks.length === 0) { + if (loading && tasks.length === 0 && !error) { return ; } return ( -
-
-
-

Housekeeping Dashboard

-

- Welcome back, {userInfo?.name || userInfo?.email || 'Housekeeping Staff'} -

+
+
+ {/* Header */} +
+
+

+ Enterprise Dashboard +

+

+ Welcome back, {userInfo?.name || userInfo?.email || 'Housekeeping Staff'} +

+
+
- -
- {/* Stats Cards */} -
-
-
-
-

Pending

-

{stats.pending}

+ {/* Error State */} + {error && ( +
+
+
+
+ +
+
+
+

Connection Error

+

{error}

+

+ Unable to load data from the server. Please check your internet connection and try again. +

+
-
-
+ )} -
-
-
-

In Progress

-

{stats.in_progress}

+ {/* Stats Cards */} + {!error && ( +
+
+
+
+
+
+ +
+ PENDING +
+

Pending Tasks

+

{stats.pending}

+
- -
-
-
-
-
-

Completed

-

{stats.completed}

+
+
+
+
+
+ +
+ IN PROGRESS +
+

In Progress

+

{stats.in_progress}

+
- -
-
-
-
-
-

Total Today

-

{stats.total}

+
+
+
+
+
+ +
+ COMPLETED +
+

Completed

+

{stats.completed}

+
+
+ +
+
+
+
+
+ +
+ TOTAL +
+

Total Today

+

{stats.total}

+
-
-
-
+ )} - {/* Tasks List */} -
-
-

Today's Tasks

-
+ {/* Tasks by Floor - Tab System */} + {!error && ( +
+
+

+ + Today's Tasks by Floor +

+ {tasks.length} task{tasks.length !== 1 ? 's' : ''} across {tasksByFloor.length} floor{tasksByFloor.length !== 1 ? 's' : ''} +
- {tasks.length === 0 ? ( -
- -

No tasks assigned

-

You don't have any housekeeping tasks assigned for today.

-
- ) : ( -
- {tasks.map((task) => { - const completedItems = task.checklist_items?.filter(item => item.completed).length || 0; - const totalItems = task.checklist_items?.length || 0; - const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; + {tasks.length === 0 ? ( +
+
+ +
+

No tasks assigned

+

You don't have any housekeeping tasks assigned for today.

+
+ ) : ( +
+ {/* Floor Tabs */} +
+
+ {tasksByFloor.map(({ floor, tasks: floorTasks }) => { + const isActive = selectedFloor === floor; + const floorStats = { + pending: floorTasks.filter(t => t.status === 'pending').length, + in_progress: floorTasks.filter(t => t.status === 'in_progress').length, + completed: floorTasks.filter(t => t.status === 'completed').length, + total: floorTasks.length, + }; - const isExpanded = expandedTasks.has(task.id); - const isUpdating = updatingTasks.has(task.id); - const canStart = task.status === 'pending'; - const canComplete = task.status === 'in_progress' || task.status === 'pending'; - - return ( -
-
-
-
-
-

- Room {task.room_number || task.room_id} -

- - {getStatusIcon(task.status)} - {task.status.replace('_', ' ')} - -
- -
-
- - {formatDate(task.scheduled_time)} -
-
- - {task.task_type} -
-
- - {task.checklist_items && task.checklist_items.length > 0 && ( -
-
- Progress - {progress}% -
-
-
-
-

- {completedItems} of {totalItems} items completed -

-
- )} -
- -
- {canStart && ( - - )} - {canComplete && ( - - )} + return ( -
-
+ ); + })}
+
- {/* Expanded Task Details */} - {isExpanded && ( -
-
- {task.notes && ( -
-

Notes

-

{task.notes}

+ {/* Current Floor Tasks */} + {selectedFloor !== null && ( +
+ {/* Floor Header with Stats */} +
+
+
+
+
- )} - - {task.checklist_items && task.checklist_items.length > 0 && (
-

Checklist

-
- {task.checklist_items.map((item: ChecklistItem, index: number) => ( - - ))} -
-
- )} - - {task.status === 'completed' && task.completed_at && ( -
-

- Completed: {formatDate(task.completed_at)} +

+ {getFloorDisplayName(selectedFloor)} +

+

+ {currentFloorStats.total} room{currentFloorStats.total !== 1 ? 's' : ''} assigned

- {task.actual_duration_minutes && ( -

- Duration: {task.actual_duration_minutes} minutes -

- )}
- )} +
+
+ {currentFloorStats.pending > 0 && ( +
+ + {currentFloorStats.pending} Pending +
+ )} + {currentFloorStats.in_progress > 0 && ( +
+ + {currentFloorStats.in_progress} In Progress +
+ )} + {currentFloorStats.completed > 0 && ( +
+ + {currentFloorStats.completed} Completed +
+ )} +
- )} -
- ); - })} + + {/* Tasks Grid */} + {currentFloorTasks.length === 0 ? ( +
+
+ +
+

No tasks on this floor

+

No housekeeping tasks assigned for {getFloorDisplayName(selectedFloor).toLowerCase()}.

+
+ ) : ( +
+ {currentFloorTasks.map((task) => { + const completedItems = task.checklist_items?.filter(item => item.completed).length || 0; + const totalItems = task.checklist_items?.length || 0; + const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; + const isUpdating = updatingTasks.has(task.id); + const canStart = task.status === 'pending'; + const canComplete = task.status === 'in_progress' || task.status === 'pending'; + + return ( +
openTaskModal(task)} + > +
+
+
+
+ +
+
+

+ Room {task.room_number || task.room_id} +

+

{task.task_type}

+
+
+
+ + {task.status.replace('_', ' ')} + +
+ +
+
+ + {formatDate(task.scheduled_time)} +
+
+ + {task.checklist_items && task.checklist_items.length > 0 && ( +
+
+ Progress + {progress}% +
+
+
+
+

+ {completedItems} of {totalItems} items completed +

+
+ )} + +
+ +
+ {canStart && ( + + )} + {canComplete && ( + + )} +
+
+
+ ); + })} +
+ )} +
+ )} +
+ )}
)}
+ + {/* Task Detail Modal */} +
); }; export default HousekeepingDashboardPage; - diff --git a/Frontend/src/pages/staff/ChatManagementPage.tsx b/Frontend/src/pages/staff/ChatManagementPage.tsx index 44b60e7b..e15ffe6c 100644 --- a/Frontend/src/pages/staff/ChatManagementPage.tsx +++ b/Frontend/src/pages/staff/ChatManagementPage.tsx @@ -218,8 +218,13 @@ const ChatManagementPage: React.FC = () => { const response = await chatService.listChats(); if (response.success) { setChats(response.data); + } else { + // Clear data if response is not successful + setChats([]); } } catch (error: any) { + // Clear data when API connection fails + setChats([]); toast.error(error.response?.data?.detail || 'Failed to load chats'); } finally { setLoading(false); @@ -232,8 +237,13 @@ const ChatManagementPage: React.FC = () => { const response = await chatService.getMessages(chatId); if (response.success) { setMessages(response.data); + } else { + // Clear data if response is not successful + setMessages([]); } } catch (error: any) { + // Clear data when API connection fails + setMessages([]); toast.error(error.response?.data?.detail || 'Failed to load messages'); } finally { setLoadingMessages(false); diff --git a/Frontend/src/pages/staff/DashboardPage.tsx b/Frontend/src/pages/staff/DashboardPage.tsx index ba544b45..cb7d2ad7 100644 --- a/Frontend/src/pages/staff/DashboardPage.tsx +++ b/Frontend/src/pages/staff/DashboardPage.tsx @@ -73,12 +73,17 @@ const StaffDashboardPage: React.FC = () => { const response = await paymentService.getPayments({ page: 1, limit: 5 }); if (response.success && response.data?.payments) { setRecentPayments(response.data.payments); + } else { + // Clear data if response is not successful + setRecentPayments([]); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentPayments([]); logger.error('Error fetching payments', err); } finally { setLoadingPayments(false); @@ -109,12 +114,17 @@ const StaffDashboardPage: React.FC = () => { const response = await bookingService.getAllBookings({ page: 1, limit: 5 }); if ((response.status === 'success' || response.success) && response.data?.bookings) { setRecentBookings(response.data.bookings); + } else { + // Clear data if response is not successful + setRecentBookings([]); } } catch (err: any) { // Handle AbortError silently if (err.name === 'AbortError') { return; } + // Clear data when API connection fails + setRecentBookings([]); logger.error('Error fetching bookings', err); } finally { setLoadingBookings(false); diff --git a/Frontend/src/shared/components/Footer.tsx b/Frontend/src/shared/components/Footer.tsx index b152bc92..f7d8a97f 100644 --- a/Frontend/src/shared/components/Footer.tsx +++ b/Frontend/src/shared/components/Footer.tsx @@ -34,16 +34,22 @@ const Footer: React.FC = () => { const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); const [enabledPages, setEnabledPages] = useState>(new Set()); + const [apiError, setApiError] = useState(false); useEffect(() => { const fetchPageContent = async () => { try { + setApiError(false); const response = await pageContentService.getFooterContent(); if (response.status === 'success' && response.data?.page_content) { setPageContent(response.data.page_content); + } else { + setPageContent(null); } } catch (err: any) { console.error('Error fetching footer content:', err); + setApiError(true); + setPageContent(null); } }; @@ -82,9 +88,10 @@ const Footer: React.FC = () => { }, []); + // Only use company settings from API, no hardcoded fallbacks const displayPhone = settings.company_phone || null; const displayEmail = settings.company_email || null; - const displayAddress = settings.company_address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam'; + const displayAddress = settings.company_address || null; const phoneNumber = displayPhone ? displayPhone.replace(/\s+/g, '').replace(/[()]/g, '') : ''; const phoneHref = displayPhone ? 'tel:' + phoneNumber : ''; @@ -134,13 +141,15 @@ const Footer: React.FC = () => { { label: 'Contact Us', url: '/contact' } ]; + // Only use default links if pageContent was successfully loaded but is empty + // Don't use defaults if API failed const quickLinks = pageContent?.footer_links?.quick_links && pageContent.footer_links.quick_links.length > 0 ? pageContent.footer_links.quick_links - : defaultQuickLinks; + : (pageContent && !apiError ? defaultQuickLinks : []); const allSupportLinks = pageContent?.footer_links?.support_links && pageContent.footer_links.support_links.length > 0 ? pageContent.footer_links.support_links - : defaultSupportLinks; + : (pageContent && !apiError ? defaultSupportLinks : []); // Filter support links to only show enabled policy pages const supportLinks = allSupportLinks.filter((link) => { @@ -284,6 +293,7 @@ const Footer: React.FC = () => {
{/* Quick Links */} + {quickLinks.length > 0 && (

Quick Links @@ -303,8 +313,10 @@ const Footer: React.FC = () => { ))}

+ )} {/* Guest Services */} + {supportLinks.length > 0 && (

Guest Services @@ -324,6 +336,7 @@ const Footer: React.FC = () => { ))}

+ )} {/* Contact Information */}
@@ -331,7 +344,9 @@ const Footer: React.FC = () => { Contact + {(displayAddress || displayPhone || displayEmail) && (
    + {displayAddress && (
  • @@ -340,15 +355,16 @@ const Footer: React.FC = () => {
    - {(displayAddress + {displayAddress .split('\n').map((line, i) => ( {line} {i < displayAddress.split('\n').length - 1 &&
    }
    - )))} + ))}
  • + )} {displayPhone && (
  • @@ -376,6 +392,7 @@ const Footer: React.FC = () => {
  • )}
+ )} {/* Rating Section */}
diff --git a/Frontend/src/styles/index.css b/Frontend/src/styles/index.css index 42b7cabe..d2255550 100644 --- a/Frontend/src/styles/index.css +++ b/Frontend/src/styles/index.css @@ -587,3 +587,23 @@ img[loading="lazy"]:not([src]) { .prose.prose-invert a { color: #d4af37 !important; } + +/* Custom luxury scrollbar for modals */ +.custom-scrollbar::-webkit-scrollbar { + width: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: #f3f4f6; + border-radius: 10px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #d4af37 0%, #c9a227 100%); + border-radius: 10px; + border: 2px solid #f3f4f6; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, #f5d76e 0%, #d4af37 100%); +}