From b70411e1f1abb44f5a2ba5891d8476e20c6cb755 Mon Sep 17 00:00:00 2001 From: dvirlabs <114520947+dvirlabs@users.noreply.github.com> Date: Sun, 14 Dec 2025 06:04:25 +0200 Subject: [PATCH] Add google authenticate --- backend/.env | 13 ++ .../__pycache__/auth_utils.cpython-312.pyc | Bin 0 -> 4241 bytes backend/__pycache__/db_utils.cpython-312.pyc | Bin 4946 -> 8502 bytes .../__pycache__/email_utils.cpython-312.pyc | Bin 0 -> 4101 bytes .../grocery_db_utils.cpython-312.pyc | Bin 0 -> 12347 bytes backend/__pycache__/main.cpython-312.pyc | Bin 4694 -> 34838 bytes .../notification_db_utils.cpython-312.pyc | Bin 0 -> 4063 bytes .../__pycache__/oauth_utils.cpython-312.pyc | Bin 0 -> 781 bytes .../__pycache__/user_db_utils.cpython-312.pyc | Bin 0 -> 5098 bytes backend/email_utils.py | 106 ++++++++++ backend/main.py | 184 +++++++++++++++++- backend/oauth_utils.py | 20 ++ backend/requirements.txt | 8 + backend/reset_admin_password.py | 41 ++++ backend/user_db_utils.py | 2 +- demo-recipes.sql | 178 +++++++++++++++++ frontend/src/App.jsx | 20 +- frontend/src/authApi.js | 34 ++++ frontend/src/components/ChangePassword.jsx | 176 +++++++++++++++++ frontend/src/components/Login.jsx | 62 +++++- frontend/src/components/TopBar.jsx | 7 +- frontend/vite.config.js | 4 + 22 files changed, 849 insertions(+), 6 deletions(-) create mode 100644 backend/__pycache__/auth_utils.cpython-312.pyc create mode 100644 backend/__pycache__/email_utils.cpython-312.pyc create mode 100644 backend/__pycache__/grocery_db_utils.cpython-312.pyc create mode 100644 backend/__pycache__/notification_db_utils.cpython-312.pyc create mode 100644 backend/__pycache__/oauth_utils.cpython-312.pyc create mode 100644 backend/__pycache__/user_db_utils.cpython-312.pyc create mode 100644 backend/email_utils.py create mode 100644 backend/oauth_utils.py create mode 100644 backend/reset_admin_password.py create mode 100644 demo-recipes.sql create mode 100644 frontend/src/components/ChangePassword.jsx diff --git a/backend/.env b/backend/.env index 9bcf84f..de1b663 100644 --- a/backend/.env +++ b/backend/.env @@ -4,3 +4,16 @@ DB_USER=recipes_user DB_NAME=recipes_db DB_HOST=localhost DB_PORT=5432 + +# Email Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=dvirlabs@gmail.com +SMTP_PASSWORD=agaanrhbbazbdytv +SMTP_FROM=dvirlabs@gmail.com + +# Google OAuth +GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 +GOOGLE_REDIRECT_URI=http://localhost:8001/auth/google/callback +FRONTEND_URL=http://localhost:5174 \ No newline at end of file diff --git a/backend/__pycache__/auth_utils.cpython-312.pyc b/backend/__pycache__/auth_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..681b8983bab875f545e1ed368341d12fe0f8aa48 GIT binary patch literal 4241 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 delta 1999 zcmZ`(TWl0n7(O$zGqac3OZUF*woFS~*$1~nNAR@1*q(XKIp+$qKQeQh_Pom?)p4FC;9(GW=!0;@TaOBVo~ii5 z&Zlw&MLf^0vRoC`XH2c=u9)PC6txa}R-H4!==5;aQ@3&`(&MeXic|HCu!`%LW!SH{ zkD=iJL^zdKeX20RMxY{nf#Cs0P^ns<0p>d1h(+-NC#3jPi`wC%%M?GDVbl7*O$8+Y z3nG(DEJ$>AfLvp@O-loPulM!$?H^3OF|=o@Ydo*%qgd4rkM(qPOlT_Z8Bb~2kvvv* zj^)#-vG*b0v$bo><_@f;Gvlh3RNgBKSk)(RE?tqUg7o`v@GgH1Fpu;~6cBgotGa^S zIjvhBWpJx5Jhqz@W{q|lDeM{#JZ_B%o@H%w@Oa(&XqVII3;TYgo2^1)SXi?m?u3xv zG-1G^~DD%cHBmWC#BdM2w5 zVw}fcp=F4n2Loxc#J}ndf=ydedlO&=-4tD9O6Y9hZ1#IrWc4q(Ypz&pmh3@;Wo$Le z9On=W#PI_5le@ylJLu}NbzGZF=f_7km&J5Gms8U^79k#E%ABI*G}_uS4w%eQRZmh5 zj)L1vz7?Ao|Ap(Kmtm*ACx2VRN#UY9jzMXvyI_-ZL{`uueWu za#qbAC>}U|&_Fg@!$P_wxo3A5cN;8UE6a!o=$6Y;;sACzX_*O0P~e)J2GX3A6t!EHT8;h1Nhj!;T2#=U^*u zRp5{O6`ZTKP3^bK@T}WKKBP`+Ju*2L>K4u1)IIV^=pgwg^epKLzeOybm)-kfHd9hu z2UYL}lJ#s->cNaza5IfGP}m4i=5YRqMqAvhVk+iIV>P9#tD3?5;4a8-hgZ7}FoRaC zNb*-i3p^p3hwO^vrwylAQK(0FE3(mLAwUKxsalhVUt2Y{u%{l8RI!-uCZtt3I zF`7dF4~zB>zI_97R!xm1^~rHn-Yz$3Qz0`* z=OvlVvr*JB(ncCw< zNN994sP^#S0J&JZ;qB)kGE7t706d{~h!4TCUxb5(lq^c`{mPM(hFj$hj zvwMqs7h+#_oa(q*-Fi)GyY29qg!rNqC(qR#7pu!`Uc+(nTb;~A$e(r3ObalCYVO0z zonc-Q|G7BKJA>17jOln+`ic&b*%oCklhZLhGWJoR_mw%^53Ie}yfQJS?!XC%(bm_T zfCk4f%nf9_fgFFJwyP-Eb_2O@qL!Pe?v8*M>6p_Hki$=UPlx21@hTY8s7W@m!*)8X|v)fMP79;J;2x3MfS&}z) vN0~s$9a`Xu9~m6jw_L%KS}|+zU_(K{UT7?6CliIE#rPs0SrH*@{xAOm|BloV diff --git a/backend/__pycache__/email_utils.cpython-312.pyc b/backend/__pycache__/email_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..519a5fc80eac8458656f01d1f94516638bde90a3 GIT binary patch literal 4101 zcmcH+U2hx5agRJ6k6$81)3WTg^1(_IF|i~|t}IuQ)x?qHDupb=lGB0^BAj?j>Z~J= z*gHz1O1W}uBsOZmGAu`QV;4#SG`5@q{t0==LxH}?rU43P8+Fh;G;d5~poO10d*o4+ zEYwe3;C5zbW@l$-W_RcQ?(?}3w0&*8@`Mkef6{@~oMmL=b3m4ngd|2mkrHGgj5)SN zYyfSFJ;6p;hVtx+Bf&*D6K54?!WnTU_y})~9f~XAj<^|QL)d+y?xXlnTG8c{Nc6DH;3ZMVx}3m33)_SeBiZiRO5=OwIU^`+pL^{DPGSN!2%>PLS(4+jE=*%0$KbM_f7q8Ai#Dk87bo8B8atz5qK!$@GEv5c zlemw_(|D{8Yg4+K8Uy`U97|-H369Dstc^{G(J7pi#&ANEmGQJLD_Tn`W9*BU?HaGP zW}I3>{IF6Dd|Ewp3+Ovf&6Vk^=PsYi#a3c@e{ zM=P!Z5gxt^@NG1KdwzqMElxdKb{X(1t=g6IcHLnyHbb%FEW5;R55PS8ia8D|RP&Bk zNOBcP?iG^Gti!Tn9&lpIa+*iCP8X7ROVfvp?y~kKxh(2DFS)b4mOXwu8J+5D!N+=Rp{O3uV?kjT!P7e>w?WY_eO5l=6>awV4LQ>V?d?sUZ zJPlOCBPu#3NjSIDI30#96uvCnC|nl`i-ntoKLffdGz);N7Cwc+3J`A#g}a4Yg`0+R zBt3B!M|Fc8AYh+uR5ko5QPXBrB8^8U#bg|($3df8C^vK7jl%7N6-ouMR$Z!E*A)Mg z0BhZaWmjPNn?m6;KtBU}mW09*P!_v{^t0+UHZ?9;I#&Vt0@O6O9TE!HP3me(MXrLt z%CNOtg+-xo4Ho$lR$VPEPL~FBk#~z8#}FV4K&{Y zM=ou8pzs%%WvbuF#}(t!KZgsrGU}Ps6N(_o@ETHbPGB>IL)FW$01gOxJx+P zo|^3z6s$w)Hft#{DktMzLI)5J^rl7d6qwVtO);4Baz^yPyQC)Ly;Ygf(^{sr>|h+R zjGKTQ$6#GsYF5xxMV5p{+<`msM7NL-Nn8d8wjToCp_e?EvQ$|6J2KJPe)Q0m{k8qV z+{zYz3N!)#KM6{Y=eEuEw9-7I8urzCk>X8N5c+-pHwwHDMC%crc|OE zd>V4h9P`$lV7N3L3Yc+S(N2b4hE3HBE{Qyby5B}wWExhhLot(8pCG_RGCBNWAyHFCa|W7 zaWI}90U}U!4q9!sl|Z{jpA+h*N+YzIF!oSWww4>_MNWOqsAXZO!duDY z18oa~-+Dqfho02zS*!nLzNTx9?|RN5|4(y6`Iq=@+U7+2Bh33xBD#eI&@FjI7+Q_Lvy zw;1rw0MFy2yyp$bw|j9k*O~V=F7!X~h5$d3_a0d2UuQiZpFDqZvEvcDw;BiIJiD*9 zUT$6I{p+>+)_rxfuWeWl);;$*J>0*M)4}~nyB8Yu+=qR9Uy%96&GZG>Z#*2}hLeVI zT$ZBMB^J#UnkpL~Kv!Me?o?7)CHrOxCNW>x?v<J$}!6FSH`-|nkpm&gA2!7cf| z?NP(w2fo7#1J44X^jCtGt{;#lN0A99H`KdnGCSR@P7|NJ2pxi7o1%BAR4oJ{wgx{wRSw@g! z=0ArZYXAv-)CBm)Fl!yt=U5sb)Q6@wN5Qqn=zs=+(8yN%+Tb+mS!t`gJ!XJ1rGJUz zFy(jfY41a~fSx&P9@RBHa5k;`L*M!KuQ(t0nid8i-TA=B+4I>8=W<+b_CbBqLub&s(p%Hdx~2AftsG8 zhHsJQq7QalSHFgWKe9Ha^#{I(;qL4zA^`W_HNn@78+266G91ILj1&=o`}PeAih&># T$bmF~6_5sSr|&rhrd9t1#ovA& literal 0 HcmV?d00001 diff --git a/backend/__pycache__/grocery_db_utils.cpython-312.pyc b/backend/__pycache__/grocery_db_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a656035a86706ea5b01b95e0a80c585fbb771be GIT binary patch literal 12347 zcmeHNU2xl0b_NNM1ph=y)Q@E=4kAafY0-)md$W#XdzTbxM~+M-BDF~rc{vmTQFJI$ z3xJWuQk`rk(~&lAEpN89X4;)`J$;bJJ5!~7(Y{%3ADrn6RcW>gHl0nUec3lA(!GHKq(R#&P2eIZn<{i;&EsY$CsURgdYpzl>SJQu%673P&c>QK zJ8R*LoV^PgW@(P%?3`)Ji2j?RS?kB9aR+MyIT~a%Ww@MT?H^O)PSyeCvJ6=zV`iNo z)5IReQkA_`%hPNV^y$LVG}k;uu`a0J!XCjn&CpW|*IYHuR;bge8AtP!nRP=wx6)3n zvA02a8{5sc!x!kN^M+=&6IyQ9v|K%6GkXYH>X?{sRMi&_zcVr#kebz^*_a^AWq8(Ok|^8*HJjmK zp49GA%wJcG2uS9UeSCF$SFr6YPgc zJ~c6t<^p{3LvA9#2_J}=*@;9_nfas8i$rJU(i|U6 zvJ#b!&2W+l-eCsbpXbCZpN^xou3l&j80_VI^+_!^5TaAf z%bV2EK$|W#x15&iK${KA!^AN(u7)!EwJ}HBB;O;@cqcPFTWM@J5IP|RR=K+luyJVK% zg-25_S-6{AJS%b%J;jOf=}ek~^32RkQj{nb;j<~8$_Sjt%)bEQJXl_WKZYt&NQWd6 z+L9cqs^ND=D^~tC{PRfAEe|cK72;okl2-T$pTG@iUH6v#=tqGaThr~nrM|!Y;bPyG z?d(T`MWf5qzSG?PQ~OHmqG^Y=-?lH=i$==XT(l8Q9n0d%Six~@&uOvRip|8KBX{$6 z@}K_kqGN}4Ajw|GTWh5eX=PnaBtRSCZ_1?6Mq@*51VjiB zx2d=>LBx&MLGuy`-P`{qRkriH64IVQ9sGTbe^UdBD~* zI(ZgZwpc2qY86&T0WW)&nVe@bJR?lUc#dV}lHxR|E}AUREY#}3XU@V_fRY4mB0jo;Xz$j4=fmac@Hd247~hFcv3(Y7C3(j74*Z+W8%-C=aM)2wecvVr=2B_LWG_@}$ zR_0bMYn=thYkOTzK-O-erR}ccj^ooNz~J7KzjF1h#D7LDhyTL*FRs37Wc{yiEJU;S zchvMg=Z;jz~AHc+p4%rxj_XFJ$ zs8YFp462MU3l9HOGOlVb##Sadh1Haq1`vtG@IQ)B^4uHzT8YlFN-ho@<^6<9ng*CB2zg~@pbzD&7s@;Fp zcb?cd-{bElb>Sci>M#=C1AV+pl&KKaS$o!S5W+wc{LFd7k9+c_ny5%VR9s)cL31sM zd?~;L31i)MN{(mUc8r>yOhOOfbi=T7KU_8t^Ts;wR0=}tj)f?Xg*CI5TH$Rul>IAU z34cy5SYW(0kUnq8Yi&uxkad9uThjuwTyd%M3*)81*0|N;JgtrsYz+z|4*12Cpt?b@ zzyDV+*&1uEeGJE<;F&ARhph}YDWRMdyTr()&DpT)ja{_dQH>*Ykr^Bt9Sw#e(a6xX zU^wEt_ExTI?5%(=0*;$P@L>0d2?rxg&gf-wSCQ8Yew_+O2MC`%whFIJGRJ0Tfn36# znnc~mB-7ZDlYz+#lY-I7W92b0^`MMn=vMaTEb`j{+cO*EW5D{Dev}28N=7a8*_FV7 zVU+M$me+^RgCO@l`~)|sW+T`-46xPOwrJUDaW9&`vbzfQBRdZF?a)$a<@IgH@f}C& z?Q2WdR$kn8JpYxwxnS>BOD}9YPN=25+YaX6T^&2_&f=l!n7P?xHQgYV_NF`d0(sd>tEff-dyPv<*Kd`)ImiMJF;)^O) zuEHLrGvYMIYdE9{m;qY}kKv&n(W^2OCzR;btI$$90>WEjW%SPOe*7=cC4(cO%fmxJ zS~cvz!{`PZ7-1AkiEN{~*1*OF-yIwt3j_lt)~Gbxmr~ngxqiMaJ{r0qs+Dxom9Ava#XMAqA~a z=T1blYXeT@4f#r$-mfJH36s za(T-HySLinV1oTreS-bCpCmS{t-edt#t|cWzGQ;?W3tEJOY(jgC)!eg9bTPHLc77( zCb9K&zm83+srwB$c9zT&z?YCeC(BP@v9yN`_GQ}v4@{cvO_PRuFEKl%?M-wzp$03X z+ETEP`||I~_NEDMZyvCn>#$Z2FRXen?sof!z&0AK?z}EaM3cWBo zZmS;BVAjvwyDi9L&HOF2GQ0d?2YNhr55Nam-JAruPYrM zx;7Mnwse_K4ZX5fn(+eV!>}5)ejpy9teKV0phLhP5pRPD{5w$bbNC5=3AaZ2hz=I0 zJ72OJ*xa(*zHC|k(c;9)+bh|X#J#syF08f}952`K3J<;uHsE&>8}3#=O>OiV;rTIb zLU(76|K&Q);y3@xiix(pDjdf~W*IiUFhzSn_i&!fQ!Is!PC9^!234(fBFU6D0imLI zY8#Esyq`+v%|N2Mpi_JSJqzC!U8N1X1G%feSC`kE8;E2Qi4@1cAp}McV`5fN++8iF zsvI?9GU?R3M}~(h8bm0aVc3_Q!M?EWz(Gi|ma69@o!-`>Vs!#2^ek1zCR=+x2{IZ- zn>yY|*RVbgm3DZdaUnaM@${tqnvxo%~@)B(pdVpC!Q~7lo(L4S8rhG6wu1xJfScOld!NQ{-cUe9D4B z<=v>96hs~$JHv+~@WvlMhzh95OeV!6u8>C@0*_)e?ABphf+vDU=N9nMO+0H)=?gO% zHk;xu;d9CeI)tCXy+|4ihF=rb-w@s{!uxB&^>4(_Z(Q0uKe~(ZzJBE>h`EadB&*OGBslCTviJ*WNt z`<8h#9FTI7ws|D(yYDXl-TwRE|NrlQ@BCYr%g*5$*?Ay1HNtU!K_BYFV({@`y@TU! za3Ux2A4Lx>L>{D!d6Z{+FQ7!txJzlr&UkU4DeTf$bqHEi?S!gjws?C?9nPQR1I zn?kOz+wW$6bEqI(=r3e`OQ{^D?nzl4SHp;CV-E4R#FhPO3T9U!~iCY@} zpdI!=!}obP{xUBf3LFv-2cEqwh;6T%G-&4m?GIEKnYY*;IGic}Ay^%O3xL?7VeUZBJf!a@~&UU_G}&4;|OnP2@!$@N9UWxOGx3;j&RY6)*;R zs2{~H_%1NtY4{9sD1Bcxi)VD@eQAZBeKOFg?^*F|p#8EzJombw^tM%0tTHhNusUQ1? zAoT@~D}5m9paoZ`TOe@q5#xFpvr3)?S9GxY0W04OeLSqIffLFM4eH>%0C@RkXyB1E zG*h>J9jp*w{kb~FGjH*QFWAnI4%Pr*<)driK-XjXY#?w-Y3HC0-XP$q6EssbP8#ynH z46gv^^%ddBwD4DTFkS?VeE9uqE5gXs<*Pb4*8(qzlP|32IH?@9FYCXrLn6g1PKm!R zeofqS>y7UVauCmr@FTqMH$I`yE}tPSoD4>yY3u2sXmD_#H{|2fwrDUM5CfrTug{#e z9O;cj51l!db{*~RKGX41Uw|b{o1PpFNF!-WdtfLqAV$*Gvw_Ia;6Nmhwww)oc{mV> z0+PG!^x1Q#f}$7-T}*HRsbgJFb$6We8Pg60 z3QJo;xyjdqs?&R*HylV8=|cn5KR$a}E&Yl#*oT4Wp{_}nX({!=5jk8RMDT-Rx>)HF zeN?(w>6NTNPvj~FuqP|5WOyJ?XgSqEpRR``ilm*9K(Ewym9;C9E{_ggxf05T*fSKQ zj#ryDFc=M94)*mDV(OZv500cO!@bfAJ^HYo-Utw)S4`KksNPU0D>|aZs}0p>_6g~# zD}jN4M3}-xT&V!CLJXwKBS7}-a5f-_0E*_z;ZV?qvIGB3gT2Git9{D!WG_X?;vP)n z^9C2-{JhBd4Nq}k1qG4chfwA-FrNuwM)HY*-yE<6j6@(2W}>hwoZlL-X<=q1%&rTw zC}9p=m{o!2)WNe+*oXj3v!7)Xj^=1uUL(FVllG89y(qPkvzHvI{Y~xxT<-IO zZLJrd!eokE6kiNVp^K*n0_{@p#lXe(K;(t!;Lyb@!DyiOVtAxUAuSi5@9o267B6D1 z4KxppNG$}CoHqPLo`v(PT*~fx?O3Y7^IBKRQ<@Y?Q>*GHIwzlBOS)TPQRCpsourj1j3l0s#wx(;8vr`x9XF9>x~yINEh zjwnJB_rQth432=18`4gBeA*Z(?7C@`_7jK$4N70$$GDIDZwhI zpvbI(jR>IU!9h5gg7wHL8vLe!SwlJ*ISqab!mJ8%3JjSHnA>K#vS~w51hz0z2Rh^# zyoM>)BL*Wwq23XO7Cu{UI}W1;(jjtAk;8_b+8`YyC#RJsD2$x5_=~K8gAubjUhB*m zruCcRLRri@Tl|h|LD(dB0%gnUgfGzhPqDS7OFD|Yxpg^1VdN0vNXN)wqf9|r<(Lsb z&x5}QCx2%LX#UHTfT+WYlmOJhs+-M>5-=*l=0Dg4h@&dI5wMHafKde9faq<{2rw#d z>UV$+IMPm`S-iRUTi(TQ;q^Uoy^A*%-@(5b@8UNX--74u#qW6+XBMXy zXBWSYpm*fYZzAZ;K7FOBirew`fRN(^$75*6xJy+WH;&>S)x4MZh`^)zBKIQqoBR!) z=iaChz`y;T@fs%nsG+B+3rmV67y-Q;4ucdJnzy9+z6kZF*DKL<|J=5Zma~_JqywY7 z^#jEiuyo+x)fm5P(M*4!mwEgMc&~Ttbk_M%;5z9LoZtM$1 zr1MlwUE9+YE46X7@e6fDfB@1uGr=vHxx%9BBU2-9ynNSF6JO;|dM?DB7w#5#(CYao z7d*Qb3U;xWVA9hcclM_W3MXZv>T~8&vV#CAQ?df`JxvbvoAeAhg>b+YV%|i;f#}si zQKEX}5*4Cr5;>Rf7x@w#5IA?yYo}6<;@3{gKJS`|A)cGd|uLK9AbI2n-1*hw#Y2{vOqg3tW5K2o|Rc8>M2(5@})7?d{ zpOO<&n!jN+P2G&6cV_YTcE;v2-lt0nQblqq_X35H6UAS|1qYRr8HB>(*H6o}qxk%_ z3n6BOPRvmLTprM*=K&SUuZGE#pK>tDFIxRJs9gmy!`~@%U6$UYlc<0^EZy*R$;ip@ zl2)%HX=@M+v%*i`Bg!?EriT2GKw`(&e+{;?+UK%6RoM`KV8GQD~IH$RW-tQVIu+VGMeP*Ip1R<^| z4IMfhkx7!)hiR)UECj_PX&Z=i(jzDy3mmsjam$@dyLZRBJYreC2?a$@+4&`PNiGKPwd0$JoM zkyq!kYOKf6q)?sG2_Z>glS0X8BFZ$4e1){AXBH4DB>!-(MT{PW*-$Yhf+D9WL&!)> z;W^Uk==wTcKr9nfLy*{^dkIKKBx>y=Cnf6+)DoI|WB zQU2yUhnW1$VGYZXgcv?CyktJ66Jb7i6*Q}>APZyIIbwr`qfplatVbBir7cX|NNO^s z!4i9}1*5Y13LPY`kxZhag_3ns+NLDzjppgG9Q4Z9W4}(2$oU@rBD7|x2;?e!?PRL5 zCMnd&$Rl$U4q6)Bq=G2$yxt{aB4y9tmpc)&LSN?LEi!#MVuJ3~PE6Z5roq$k2wMBQ zeCEeYryS(Cj><^n`~m(V<#5!dvPEBQ-|NTaR#D3Qt@=V3qH#=_R)G@f^=C4M5G7-L zk`6;mK3m$^rPD>q?I$G!)Lf(lvxK1lhEfZXuM`MDb10H>74izxLrSkB<-?Re2OZu* zBALA8LyQ@UCFh^xFVYN0?Jb5FjJz;kHlO4oc_2eI2*9Z4Z0~?L81~AFn1~m;{bAzo z@b*IJx(PunM)KJV6L;rGsQng1Gm_h8Ng$)R??V11>5M)%dl$crj~Vj3y9jx18eeZC z^IP7f-;R9^!kH-=jD`YfhZu{y zsgJQ=O~HYtAqjfHK2iWTf#q!4zK!CfNU+e2E;Xd>a;6?+BGuzdJ)~JQjh0@GMu%EA zZ)Sq*)xk)#b;s6S+dt16+>$5t_AOhsjMfp52%u#2g3U~S*!z5>xo>(!n0-I<&C#LCf=dO-*SH6A=h)p z@WwTP8#h4NVmODE^Y|O*uW>$CmyWX_+9>TJXE!-}$Y~{q6hTazENdN!3%Kd{Yi9Hr zr6K}%0#4e9eG+Ij1ZnVEB+W}{i!#m;8bz<7zDYaT8l*%?bBOvFf05sVGr@iAs7g9& zCfb*3IZxRyRuvSOmufhdd%2pk6+r(mT{3koRpgo8Ia8S`D~~y522zz(u>*<94XKjS z8yzzpF<-J|jgsQ)b+PAD6_v53M8*0KE1KsHr#!{eoiiIg^fb&G;J?v0(-^b7*_@TN z&imWL?+nKq_ay6De^KtV*_Vu{#6;&3M|JUX&fxWtoyM#~vHM~B9 z^d4SmM+9p9;fTDuVw2n16C6l8Rgs4Hkt_va0kSbf_cKHv;~u3!8#jy@WW?^LJUNd# zH7E_So*MESWL7_;MjSJ!h&67ET2wG&Mp2$5MrD#1l}Th284EmS?6;|DGjGwX!b1M+ zNn#YUCkd3YS(7L~yv(G?AFtIN+QxkK$unu71jY1VlY%MRnO1SK9bE0B%vSKSlqUr?pWXc+5_aw@i@LBoF z>5uJIv6iIWJJI&BqbTKYPe!Nqq+Es5Wm6X*>`uRs@HBuujnyVf*JIJVVVSYSY{|j~ zCCP`b+SuV#NqNkbC|Ubq$)>pmaAMQzra~XOy_h=gqU%?tu1sG^x@)sCSFc;F-k7M~ zIMaBBXBJGWXw$)JkZW*&=)i|dYqNQ3ci|C-T#w?;!d9$+d?UKLq z_KaI26c(|7(rK%wMJ&wuF3SGS3(u2*mp4AMn&%&>Vm1G*5_NSw$F1hJauNO-*46VI zkAH9c`fC*JO)88hpblM9J^;=+0r*9f0lAzLNF%X5hguHZ<0`kZwZzA{b zR?0mM_Lb%PhL^FkHxcp{!6A0`?L~y@p#SFLTQWKPR@)K$*bd&50qM3J9~1zGO=dwhXs2-IJwx~ooMflKh+cOy}ZzVMaeL@P&O1V9Qw3? zN?G!7HrI9gl>H6o7oqJ$&G*cTN&Ci$wmS~@U3c}IG2U`!!Tn_1`sDwA0{>|vYZL3g zztWoa8_v&}-tLXUjw0@xhgNl%j6c|8h5t`YrVh8|Pwf`+7kN5X8$bDX7$G0e5IKth zB2J2E{DW^`j0mZ`uug* z0mGx$fvC0$jBzUcdffUo@-S6v(Wu&E%G0Qty-WH743qSa$@wSb+$M+dQ;Zf#{}kVk zp+(<{&Py-?=(mx}IbOO>Jk)sh3>0i(%tv}GqFuFmkmh{Kud_c|*;G`|G zrI*N<^DJ7CWf&*@7>Rcht@xMl{9U6JAG>Q~BT2V!qVtZ!lT8Iug8kD~kDi8ychfXH zyoshE{6E}eI=sX3!>tzb@AkCWjiUt`k0vq2$uO+|SRrm=`M{vi$jm|U0lNUE4q2}= zdD_pype$LN75HtK3(PQO%3ICN%|6WW8vx0cm$a3%7-Un$q|Yq=Pm}P!nZy5qC($^! zam;H-`GQW$7j#lS?8(@C#CELmmIW9=5n%H3h8ey9dj2dv1KP5j?`#1kKfH{TpFch$ z(BzNLBd{n^z6t!TLHe~Z1Ny++XShn54lMS>?LCT)Mff*L9-She;G}YiUpKt59U2A; zO0h^s$4u1!*R6d2f3|E%`xKR)-mg2$}SN|qhVEPn`9Uh z&>Q z;a^v?B9Z9G2^U;=3)#Jk|`>0Vw&B+$M+cJ z_1Pt2<1w}!J8r<(WtnWuBE==PI;{LCLd^GD}l543A$*PDB8JTddcPm0&XJ)M&lI3UTe zoT;k+)FhOH_seuZQmpOu(-7Sfbc_~7w(^UYCEjvKiN{= zVJ!TS#R31H8clFV-J8iu7ldCib`zKu3L~UFG;tXdwjQpY2@}CU^@Bj?KJRkBrRiaK zoqAT(hiVL2ei-T$C%ZAJBBSCJH3K$4Cd_4z2(UCo<$ zt0_s7mQ|)kaB|ADtx%>$)N$U_Av)>&?KsZm8XzX3d|7=fYjUTg^dc6_eVEhINQ;A>|A%p*-PGY(OxME+6m#GCLiqZvH9b@ zJSefGWK!Dd9rfulv|SDKy+9d)m$f9`z)Q#*0e3appi4?ym;}Mw8=?aOBTBMQrZF3a zX;V$MS0uH9X7l+j){r^x;`>-p*qSnfNKz2ItE?)cYjp+a>%y#Jbrq@8rR$(W967ZZ z-bfXAOIy&GZKP50eZhJw+f4Zq(WIMvxrfE7^Tfg(5WR6xKu5|5(-4O!^Oq8UxBlUW3EL zIg>r^+Zb^zv||yWXZ0HQ+F&Scdf2?;kE^9 zi@Geg>6T?D%eQSoXt`Uub~gI{=zF8d(rpu6cPynW-{uADRyE(jhw|-N5cd95*m zWxjiH$C1R2BgvZ1xKMe=Qp0lZT(Is|b3a__2H5c5EvcVf^M2ENP05li6Q}M9wzzZq zg0SOmK~>7?yzZKE(FR@w`&s(S$rndb zM*hgi8|sd+T3>a?ShRi}{FO%c`2Y$OV#Vf$0GqZ_3b% zo3J>Cw7`f0bj|ZfN0WF6oIj2l(#grR`FYsN1SITph7c=-;XnXk1cZ?i>V1iL0+~e6 zFiF+q(6TBWBP#4KeJE=(I;&$XzrFvR{d4Q)Hzzh6UU0Oj(=Iz*P2)n-(S_<` zaiQXF@!GrCKP#Utd1qC!blv}#U1FX%NlbfDEHwYpg5zLZI4JLe zS!K)*2eQy!T^-DJ_(3mEXw2MC{Jkm2Q`n?btl}{o?#;jYn7_p^_Er`;eA&t)@r6}ot zCx_u^X0v2CLL zj-vp`MC$sk1^4N=^)#cY1=GS*YpSSpx<64=539E6T~pyyaruqiGrMCalf}N2r|ic1 znf0-~Nzb}eX=SWAQR+kT8x=DZu?@3n(aqK*Dle$!@_C<6gGYjR23ciNF z2SjP*gF1hEXs2q6K+iNZQ;~Q~qsu&JY@7AIMMbpgwEeI-Z1`} z$%Gv@(J8uuIJdNmd&9b#%TVAooK!n5=vDx`=*UpwF+rU=Bs}OvC($-8d_}kh8yQ*W z&5j$nM_bR~NTgS9eeR_m1es;1x-R0CSzhAR)vosTF;gyv!P0+2*JhAb`XBUpn;gbR z6{vZ7-kC39ajl6)v$Gs;(5$qZklUm)>+p*izDdAPsCw6SB_+{30^)gvbkZQHF) zI@V3J6TemyJ2_XGsM$W>y&$wMnT@6^{9SkBtT?ypz3_Z@eDBGm`_!WKRNQ)M$;!FP z;^k-Kj&pJ0+>(v6b@89wb(Gxac$SGK=gx}@m%bG5eLi`qFMd&sw*(g4m*dvUf3;+! zH2*8oin_kqdf3GOQSqT_?gxeZp-SNgMFROt1qA<~k|%$)?Xbc4gEf_h_8Wh&-vs{; z4MxO{mdcY;9&BxHk{J}6tyx=MG%FO7JhZ7AQIM zvp_1n&VpqTt@_ELo2*6aj(M-lugIu&Vuy43*yNRtEvp|TccZ_I@5lryMr|#;yu^Vtx<3$S2Dwk zY%>h$K7n6D;0cUIoxi>KCJK0)EGx8PTfJQ}ac0E)txN=&4ueu7!z?l2Ga$&{1s#U% zu&*eW5^qIO(u?F&kaLY3CSntxk+T~Ljs0La9E?g25SO+}gP{-|>1Q;XZHv<%qTswPZ zacyg2ZELb(Z(Jy)e9XEdWp`iSIJNO>O%rWP1_um1td8rBDM!lrM9Nh=>zq5E@a~E` zcY|ApEvJ?2F%bJ3t|dEXb6&Si+1_v{(lcx{oTc&dC*q8c|I6h9&RKe&GnfKAC4iLu zhQi|Q^eQMC#FA9;=KtGOSoZh-B?s7GEzt_%+1E)m#!=gQ6k05Oj4*Wr;lx{UfXpY%-p zo=O5?A5Hn$)32ys)o*&|6BqP*vEbB`T$3_8LoH10@E-UNfTQh z7|@6_obS_6AJSaUT<1&q39Iph`0$?NkGLhNWQEX=N*4D{oV;69MkpGRoD zd$skDk^krXAzt_+6MQOPpbV+noGd<__-8h%Uk6hlk1Duk1j{#-rvFp;9S4iW~rLi!EN#7c$zBM`~N84(mooUH2W|b`%$8GSriC-AE4~*xx%@nnb z7}cZfyfPMuVuiQ7{7nB!e;%t;G5QaZ(=Q5T@+Ps_hiE*9eASL?WI%+;?k zM{F>dBWkvf<8GZ@reMuTD&s{_jjho!Eg5?VyI2P8SJACxp2)084B2hw6E18R; zSnp*C2bE;vn2{8vkC8{(0VmC+?N@M-&wzZth~si^I2e+zj~T6z_gHXkkQaxDUc`w; z(fj;}3QSXne9j+a+}9@{XWS=VI7gtBKZiGyN*NQCPg^4WjFS8XIsXYx+6jZzuI`Sm zcF^gQ(z^%>HUnhZdJ>mu&=%IH>Vjb@6zRPjka?#Xq>%FI z9{)E*K)1k2v?t8?C|{|(LYNqww2ke1ut8+ahpqQeuk*t*2M$R4jJVg%mYuZJd zIZTm8`mPYUb2z zW3puZq%BoYaeaJhJofbL*<```Ng-8Qd*jl~rP+P+o@8muq&-zucOyI#o;@|cEm^j6 z(($u`5(W6v3!Z1<&S!|F+kjnziY=3_pE)W%DqB6x`XSXNI*Uj;X@(q(mQR`KWlc#X{XR`CIXYK4u3!W{h+KsWExo75%#jAHtTT_MQ zv8^-CY0I*SD_%GI(oZ~F9@sfob*i>;v37f+cKiI{WbLlW(|2opbDNVjt&^udE^kOx z;(jY6*?7md{hij^C3k!+?`*ui=1!yUo$k4pQkAt(#8tRayoW2Vi$!M!COgpruWw=N z$%Q(oA8PKFZ@61o6MN>}n)%&{qJ62VW^kMp_DScmldGt@F)}kUTN~ecG+BOZ(s{R_ zoYd2$b+adD4}gWJx$TbEcg9POB1@rc3bHi9b46|Z=gYWzn%GbpN0}$y*}d@8v+?u( zo-5296mloX5#jVdV9Yzmy7zcqiukxK1{`H0)~6bimmQDx^uMG{@zs`#NAszX}KR%S--)#IOIbO zPa8vUsqUDB^1Sn~<7-N}?2f{SREHV&8cn??k&jQ<*b=K-lg_;p9Um1y4P`KG zpETaBshc&h<&aAr#VVe@V0cYpb zvr@$`)DQGKZl6}Gq2E(I2%Wd*aR}kB>H9YJxfj(oNji8UB1{L zGukGJlqi40(g%xb!tt zGCvmm8e+1r2$N2k*k~O+%Epv7rE||pWmH)hNoFmt8+&$xK~@T8f2&KuFo})5Wwe@2 zbZ=w`r$KOWX4ZVqDBg#O`GEzCX2ASDvqffVa{b29A~Z5gt3(H5GDl@d*K<>jgC73Q zS5xoHtXT$Sj8Z*HklB%g@BvY%17&3kp-eW7U{|JQAx9(9yhq4!3E3EhDA|C0$bBE! z65Ed_i%(3PyyGZm1b&I*cC;CmIj*4H0Kcu>@NxZ`*^Bd=5{*wJ>km$xoUTa-wRiJ` zr5xpPp$L*npbl47YN=RAsIe-SdjW4Sg^hWayMLX>jyGf7DGk z;6SH*st1JT*|NW-Z4%KjzpN5+kEWp=@&koMZVOC0anGb^ua)#T*vE)XRtk2MO+!hO zd}MCS1ajmUEtT)U@%FBOj6ZKgHNFrs%2@U)lFR2NXpzf8OuCZb;WJ$M@%%Rz-&V1b zF{_LcW-!>S!{7!gF_Y*ZK5?dkA@o}GibDQW>m1x^$!>xa=}cB+I23*8Y(Y72U)|?=IMn6_rQ#zxq+STeHfk|_(neCjr zc-xm~YEQa4CXU>#u8qAo8%oq|Pgb`~9GTn)BAYKP7+^> zv(Wkx6GuhJ$4$r)tIsyt(9Ujx&OuY}dUk`5cW@vy;>!?FX+4emZL~;UEdMEd@`aJ? zD#N#+$No0kqlCep?!rUuC{I3=>;gL06GXP>z8XGQeq^Yok4pSggytA|)F=ma@*@*T zp#Pqb$&Z{sChOyo=_8mn=}_s*3vMZHm4HgK>yqvbs=bH~XK=a} zLpLZ3TK^R_&q1o(1y0@`HX;XmayhbJa*58nKx@>87UBvKI$Qejsd4=H6gI3)`4{!9 zJbL!P0IN6s>3TsqO|G7&#)?7?EM=H>g{YG~%INi$~Ajb319&Y?pr z1np(#%c1=f#w|JcP4&s9Efo&yPd_P5En``g(3{MO<( zhG|zbZ!z9=j1gg*y6ebfh3?G#j?9Y_a2#33!b6MBn~U#wnPiuCsY?T!H=qto5%+bf z>YL=eMGjkN-XI@o=w!|3tK|C)I5(}b;bTrBCO39dA}uAZ1g)n4wuX`NQJN$tMb1yi zSwjv>?js*<%E_XnRj$HT%w1Vbpan1Oz*zt&gqf~Twt_avM4$xYs{J$ojLz8YTY2_o zt7T%Lb^(*;G!lm&MC1M&k+`$)d@)ulXLTH0a1X_;Lm+%mosBN8JCs;=DCs#oapI1n z?5Eb&J7vvtql=rtxgATE9fw-aQKoQcirE`DH*4{9OTtp2818&%*{-BejBNngRTsN9 z=a_F#tlx9Hb#ZT3VsBT{d3vG)nQM21_C?nc>?a#zN{M>eF2%BDvXMGbdxcS<03B2l(J>G3TW*~qHd zrdl=EJ(`?HvKWIx-~&?Eh4KWho&l8vhRj;L59m(B$vdga|?nA9Wa z+(#3+ITHD0(>mD4={VK5n7)84=jf2-Z+%gcAhlp9VJ25kQ=QQT+H%s(d{YjWn>MpK z8jZnpwnmvJu7aw%u4Ocj(&cTc^XDyfY561YRl+ZbQFH{sofi!EcViPE*u zfycg*sBBJo%43$9W;OV3S!Jwo){&^%lq_rdMUl;Bzi(7;1p4eg2QL#Bf57cMbU+yO zJ!bA~ep%jvfr5&NdKNpRX)L~hn4GEff0rGC#zNDDJgkMV>KPNdd~*~R5rzj}3@|Jv zD=B$jOX04+z{1^v20j|6WrHIe;v$WghQY%Xlu-@B!mihSWs75^qW})G1&}SDw-6v@ zWU?GAU6t>5aWW{1AKc`gAQZg_D1WVWr+>lyLfrbo$L@yNU2~mDcZ;4ISJoCyE^}>W zbYrvp>pRVo1I3T}xcp zn|25p*^a`KjcY2IBPA%vs!_R?0K5UrpeYY~z-r3Z5*U?h35?nIs=*XNkW+$4)-fUp zsZ6=b&xix(=FvS$gJc?sjb8Gw(`z~Hkr8V+ItUA~A^P>F%&87p6;3P-*d)fnF>Xj> zbg=WW4w3BEQl!9-r7GGV6DyS+)W{LEO66Xck7Q*~;4rdw6ADmaKmjM`s*DwVjaKzS z>@)s?9Sw4GuA*fZXDdz>&~c+tvU%T-C~Ztt(s80n95KSNqnV~(*i1x5)Wbv|Bk;0n zOgHQhusNmYl_Ez*SJ84p8V&F80G)#LX2qu+a%c~w$Qos`)nTLjU3juaS=%_#=6&{P z!}KU;wB^euGNYb*i=KRGj3SZiWPIhmJQb^;?^NV>;6+Owys(XKP#4dvRx%{Wzt6?S zJ%>Ghn@0V0I4})h@%eb@Od_MSfildHbC7ZgQk#Y=uB?irP3$uVYw!zH_{kIm7x^#Wz?Tz3N zSg&$ChGd-4>Mnge{VEtXK9yv+ePtuk+%!l>)iKJldaHZ*_r(Z$Whm`x}e{c{Vi2wKT0o4RXQm!QHqIGWurr;uZc`h^5R^a~C& zsN1|aquPu$TO7h2_B@#gf(LwNMJf-$9r6dbiWc8|uPG~|aq~mB) z7IvumS3R%{YMe%LGEvl_Mn$Y(u4X=%@Eu$b4zW&XiaVO)LbJRRh2KP@-yG}d8O23F z!GYG^p`lUBmS*-ZcnBCqH^$P9#%W<_P>RwfLujy9^o>gY7WGS;aUC^yD2WhxU%_Py{!G73ABEuqVlDrrMya7~CJvUuMDLmv9 zlT$)YDLG|u(sr_YQchTtdPKGXClO4dlLPX}Nv3D0p--lJpd;G&Jx1mC5w(KYcD9#N z)sxde&T4YjkVCAV#6%Rf)Dt~Qmu8Kw28gLJ7^NoyBGF`FSdwi^EGBg~O4%6ok z=@U09GN$Rr6mpQ9I5~8roE#$EcHeC-HcS+NvCU-hq&%=7t4bA@O*b-U$-$DNGhs7kFnzWB zmGa4|+4i}z_fAffC;6@S3?9Dtfsr$8TO!}m21-=%O2y=c*{5$8PgErN{dW#_#*aRm zJb3;dSInDZ7nkUD8>U2fQO*NP2KeNh03Wy2&6^4Kt~+O*i9dTac_w&|Ye2?xOZ1vU ze!Tt_^5aGMcP|;>Tf!26qN`r1iaQ(TjT2Q#zU9uY1Mw$LCwHB>$9Z@M0l;fsxKA(2 zxr6HL(3M;@x06cV_EFvHRBgkZb(?TzB31G{I(NPKo@FE7kSZvr-|1R1!v7I&7l=%S zmrU?O=jW-4t%*h6@g<7Z+^3{e!1g5rMLx%>hF*!=tLFB7aE`j=*pgunZ=T+FpTmpN z9_Lxe(o1UM$)-6IrEFd@RKN;<+IF9#H>KSy2QBT)42e7(cf9N3>vt!;d+q^W%`tQm zUX%mF1K-jbWq9KD<|M!QXO-S~{nljVwtL2MzMF^Ka-Vr9!rk7Nt6|AVo{x~3g;3_zO9l#CdX}|?4eF{SU%O;5@<;hu%Y96UqpTryauCo~ z#;v%`o8;@43`cmrho9YbpJN^Z*`Ne2J#Em^R?e-NdpWUTFOAv0JDax0TiTPGI__Z_ z7R`3wrx)eizhr<9EVqoAGzNP=XrrDzn$?^Fz9i*Yjf>Lx64n^JDf=;u3%qjIv8Jq^ zKS3oPytDg>_`x&D-A~@bB&~?`;m!!YVjg{t7v)3;7~loluQpE2dC!{ZMoSIv!A;gn zM!Y|&+^}TA8|YMAexG7gI23q*;4lPVJKshKzWYx5iTKHj$@WY4I2Z4jTfIcD+h{so zlylFL0lpt|vG|$?E1x z=K2%9Jyes{pSe8K+h@1Nw_RFrJ$FZ&8YVX?Wx)mdc(Y33O~Cdp8Q{|q55mT=#D;wY zZ2!+HR>cDGt!EZ0p45RV0;r%fyEvz!^b>F9&Z%pP z)RE@Ro8No$X7>HtpKK|A?zes)k4F%U@a0iA9lvX36jQl7Jh)!evwG3U8W<5ow1Qa- zXTy>Y6(YrGHY#~sh-G6S*9-BYm9>hAY@(RVCNWabB}9x(L`)jpQH6bkvP}{V0~(=C z&(M@aqkzT^pjT`K(GZCfYd5i@Gz>J;=A~nG{vf4ck|a%w3P}-r5sSIoyc4QxU<7AI z`8SW*11(gcb{oLEt@{>9dK2w5Ol8xcc~WQ&wNRU6cH8!!NZM)Yx!fcp^z4QX+DVRne;6T_ z28*$!DeeQgBco@})HU%qS z<^e3!z`_SGU0^GQWAe4cvCcsAbBkUOb(a?zB`#g|y&&ZIwCv405&oydOlJzznMe<2 z3(^ea9rOe!`eY{0&nIURmcRvRg6U21tz;|zWpXg`N3D%@NEt7uPG6j5H0RTKM`bMn zY!xIe$Pq!LVyED`_enc2p2(3+RME`3MmK+He5UQ%isGn&{vyr0Wf~|hnRh~m zO$!rDIOy;ty#f=Y3#MlP`5~&9(alTsYkK+DkKW-Y`qTW|Fn`rw!f}3YF~hr$h55w+ z3!mXv2gasINSJ;VdkO~6uVSJf+8YP=yXKC#2gY6H&2fHyu;rETJnMr!O9+w_M7WSi zmw)PQ;|nM`3KK0teiHxdVCST8We_VsqAf7#A&-p#B&N3pWCQ&x+WsKgu`yR|MXA=0 zTcQcQ+Kl2>&E_YEdii&Tjut*nnBhoO1^R)-Xn{uDydQ)LrCXG-C}1)aknE>{2WigF1u84u^2B%d^z<+ppm<&uMI%IP z0%R9pD&7W9jyU|%$Z`JNvHnCYVaD_-GDEsdPrmwPdZHincZ*b3Xui?P zL5xrwrgU@%Po5cpw4FRN7XM7z^4n*6twX6fbhaCZL_karzuCO1FUbu*f3=xcn@#@K z*#Z8Gi?+6^Ek*c4*j%`3OTm%xp+0e=93)I4yV3pr`a1SSl8eFrG1YovUnKjb zf88Hr{&@U_b;z+(U?I>6^>c2)39}e%rKd&LVs4q86>`({yqn+is9*0{x;(>j%cN8c z^!$yIo2PJA2KSsWAZu+@a9JvTd{y=S+~PHXQuJwV@2bH{6J^4P|6Xfn|)1ur|z$mMfVqJ!Zd*x*K<(AWtWNSW5RjN;KjAhq$#9k zd4=SbeK*g-{10pCb=h`g!pm65pMh-QqPqrau3QeR93czEXTP;TU4+PpP9$CgVH0W3 zGJ?o{ExaikITXyz8d2%T1YjN z4Ye9USo;e~-AAd4ma5nz_m0orOU+eU+beb}fB2d+VPX4;ij-4T1-NP-QbKQJUd!Ab zdHa=L+Z&k&_^U#oZ(rL(un5g*2~_*|hp!J!4&lB^BE7BNS*xnR|J`cc^6nI?A>gYz iO0{qIZ+ky;?l<-C832jnr>lw(zKnVGjXq8MI{yp4Eil;t diff --git a/backend/__pycache__/notification_db_utils.cpython-312.pyc b/backend/__pycache__/notification_db_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..272287141bf541083f26f8982a53f2fb86811bfa GIT binary patch literal 4063 zcmds4O>7%Q6rTO_uGevXnx8hIwA=rfx}ky-Rj3qVm*yYr1Vm(HZ9J27TgMKwo0eEE zN~H)|1*z$Y9D2x+sFxmls<2a!8Db?<*-U2Cmgh}E zlLod$YhjMq@+>h;eTLX7C28Fv7+fjQ+aogy+ydM?XF0ZC3tIPj+`JaS%q2be<;V%f*K0s&h%3PUsKby47pX66(?W1#TaQ(ebgWa8v8{5*F52!or%Du&}n9uuyrLPx^)@qH(SJj2YH;?_GZ^IzAB_ z9T@#kGjP~h1mwHU%wf$ildu(tNjW>hN!=>H&E^eE8CWRGFZHp8WzUwqQMS4}YF(x* zbG70rqZ?&;84dX;!;T-n)K!?LARI%|@UTx&*K zWN5bqn8g-o_Lj+(<$dZGANOBMOu+Xy6Zob<(}}SRiBRJvNkoWw!^+MjW(E;gQ3z>*PE;9&93u%9=Ds6 zX0?bEzgj$m8k%=tjmQqnXIQqA*-UyN<&ly(5KE3D1B3g?0H5dU%7B15kP41Uc@guu zAVN$yu;BHCS`v{L^Jto@5C@^II7oTn0CC{!`)AN?UtWr!&o9F2 zF_5t<1~PWVK*o+3K%CzZ2N|o5I0PQbk38|@(6{f1ATMzgYRH}VkXV|YPVb>Tll78J zd|hT-JUSfhAOBCtdnGnDQvJ^ITRy!SjYUi9puG=D;<$2cTHk1-qzLf)mA>J4G}oz( z#Ujy|_Q7Wr8lgp^@%~pJJcas-JknDD1)`16cx;ESLR;9QPoJ9u{)H5m>ql7JA=b1q zuk^({p$oRicy0$PDQMC*2rwB5J@RX`oek+=1hR;>Jj;gs2u&@@7DX|rEJn8* zkkVREf*UR8HkEUCr7h9F5$xU+yBDKdE~!_fRe2@%i+HjC9PYi>yL=g_{}Oe#+Bz0T z3QetlA-@#3D-@LWUzMYq%F)8V;`1@}Iq*496kxJq2Wsft5RcHqY`;yF+hiralG{wY z9d7>soth>7KyCx`_AT+#d+u9aav z@lm92JX#gz0-a$c9tE1YwGWU!9-D}k$GP+{u)_?85?+bkr3#y2d|h&C*)FVUGkjnN z(d#1BSl=n0Wh9&Ikqvc0+BpmRs46K*-CYqq4S|Djn`6+_!s*`9(z$wWLpfP0E0TY| zT2cr!IfzM>&w20ywk!uAPoYy2AY7iSEB*{X`v9a$;JE|fae#osD(apN9eBLu!lRDj z?t*2mob=ZNOC>;RiD>yX2dILtCSn1UtXHiw~k6?C-R zRsR7r{?FRc5g}>f9^5PbO7-MoV{dUw%8pp5R{bDIy0H{a zCg21~RCRWMvAK4f?8Mci12H9Y!Bw*^_a#kYWr;xZdsErvEJQwKvmKtaD1(Zn%-D}W z6#_;fmL4Ulcnt*v9Hg2QzG2&7MZ>3dzyZgSc?J`Y8upo~PQN4_4G(M^ZO62h#z zaD?1~k17=J^BbM*WiJ^W>(k@As!cz@s4uuwW^9TxYkfsL^JuJpFlFO2s~ z(+8U$=zDrP^W?1bv_En8=)rO2W99VTv$Ki${>+>2`LWaS$Ag?PX%9vb9{Y{**x+|` Ft3Tag+-m>; literal 0 HcmV?d00001 diff --git a/backend/__pycache__/user_db_utils.cpython-312.pyc b/backend/__pycache__/user_db_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b2f41f200252f9ca0fafb6e49705b91edeb0dc0 GIT binary patch literal 5098 zcmeHL&2JM&6rcUDz4m&OI3z&$XcrJFCME&`t!RNN!~xQRgX{n$vg&Sj*Ra9%MzfoM zqos{h4vC;5m0Ce>2=xGhKY(j{s?bB?px2NJAP^KqJ+b8DZd=EwDvygkzA# zT}C6k7Snh=paq}?p@yIeP{U9odNALjiI)Q-A!3tfwdiGjL?E`HOQ46f7Ci!bE9fHe z0lf|MXqkz(|3yMc#TmCHtt*A3q1ycu*fOzN3z0TtCmhtJHipD&gRGV@*3%lT9q{zA zprDU!vx)Xhw%;}+Y(rxpG#udxDU}7yEaCy1{KW<{% z6}*64o;nLYJ7L@^tLFGwSiTmm%bJ!OJgeJ!Y0~A4l6?m)k+<`Bu>alcNm$&Uk)&ebs)w>&vbi`)=kJVmnAz zY*nqJun_BQnD;D5yS%Xj^V|0?#11?XgVA6G1p`664pwmm5?Dzo>TW=4tByV8L&QXhSLGje6W?16|rCc2l3@fK-MWdvKU{0^pi8A^GQSR0(E`^-X7@^V= z#pb<^dyEJ1OF|eq0n9pq662stVMiGAuT^uA~yOx%^PQs4T-B-oQM|{%;}{sZxGss56szgJcG87xSHBLZ;lJ-B6kK|VB*|V ztRL%&t;>pB(l5y5&EA1rrqUdi%PXpF;<7^0Rz&ud!4!~dSRPCb4^baW-sXWKAyZH! zDM4ghS;zmzA>F#jI>r0E|xE2~jTLw;&=)B)pAiaU!~jNPvj*RnqJr9X&))1`vD@22`Qn zK|YaWr@7HGZ3**nFL8%zl|eM)KseC3=pi3TX?9WSocZukfCHopKBWJ$ann_ME_rq8 zradoozbMX^2wZOM>f4LH+q?PDn%6K#D-83PALjHcRK@u8tHC&ppbBujn;?*{Cj6!V zrguCKz|}px4Zi!Jz9f2w0KM%qM^_5Hcn^f&hG=Mp_ButcFm_w~*d0t(fqUuI0N2o# zqxKzm^SCK$Tl{I)&@h4P;LAhy7(ljT=7SZ5>}#a1FjCk0ky^Wv%Jef!FBD2M%=#~| zjoW_ntAgKWIWFl{xhXE!`f=%>2)_sDJVOvV3F5`B1dCB~QR + +

שינוי סיסמה

+

קוד האימות שלך הוא:

+

{code}

+

הקוד תקף ל-10 דקות.

+
+

+ אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו. +

+ + + """ + + part1 = MIMEText(text, "plain") + part2 = MIMEText(html, "html") + message.attach(part1) + message.attach(part2) + + # Send email + await aiosmtplib.send( + message, + hostname=smtp_host, + port=smtp_port, + username=smtp_user, + password=smtp_password, + start_tls=True, + ) + +def store_verification_code(user_id: int, code: str): + """Store verification code with expiry""" + expiry = datetime.now() + timedelta(minutes=10) + verification_codes[user_id] = { + "code": code, + "expiry": expiry + } + +def verify_code(user_id: int, code: str) -> bool: + """Verify if code is correct and not expired""" + if user_id not in verification_codes: + return False + + stored = verification_codes[user_id] + + # Check if expired + if datetime.now() > stored["expiry"]: + del verification_codes[user_id] + return False + + # Check if code matches + if stored["code"] != code: + return False + + # Code is valid, remove it + del verification_codes[user_id] + return True diff --git a/backend/main.py b/backend/main.py index db939f9..1c6b64d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,8 +2,9 @@ import random from typing import List, Optional from datetime import timedelta -from fastapi import FastAPI, HTTPException, Query, Depends, Response +from fastapi import FastAPI, HTTPException, Query, Depends, Response, Request from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware from pydantic import BaseModel, EmailStr, field_validator import os @@ -53,6 +54,15 @@ from notification_db_utils import ( delete_notification, ) +from email_utils import ( + generate_verification_code, + send_verification_email, + store_verification_code, + verify_code, +) + +from oauth_utils import oauth + class RecipeBase(BaseModel): name: str @@ -118,6 +128,16 @@ class UserResponse(BaseModel): is_admin: bool = False +class RequestPasswordChangeCode(BaseModel): + pass # No fields needed, uses current user from token + + +class ChangePasswordRequest(BaseModel): + verification_code: str + current_password: str + new_password: str + + # Grocery List models class GroceryListCreate(BaseModel): name: str @@ -180,6 +200,13 @@ app = FastAPI( description="API פשוט לבחירת מתכונים לצהריים / ערב / כל ארוחה 😋", ) +# Add session middleware for OAuth (must be before other middleware) +app.add_middleware( + SessionMiddleware, + secret_key=os.getenv("SECRET_KEY", "your-secret-key-change-in-production-min-32-chars"), + max_age=3600, # 1 hour +) + # Allow CORS from frontend domains allowed_origins = [ "http://localhost:5173", @@ -458,6 +485,159 @@ def get_me(current_user: dict = Depends(get_current_user)): ) +@app.post("/auth/request-password-change-code") +async def request_password_change_code( + current_user: dict = Depends(get_current_user) +): + """Send verification code to user's email for password change""" + from user_db_utils import get_user_by_id + + # Get user from database + user = get_user_by_id(current_user["user_id"]) + if not user: + raise HTTPException(status_code=404, detail="משתמש לא נמצא") + + # Generate verification code + code = generate_verification_code() + + # Store code + store_verification_code(current_user["user_id"], code) + + # Send email + try: + await send_verification_email(user["email"], code, "password_change") + except Exception as e: + raise HTTPException(status_code=500, detail=f"שגיאה בשליחת אימייל: {str(e)}") + + return {"message": "קוד אימות נשלח לכתובת המייל שלך"} + + +@app.post("/auth/change-password") +def change_password( + request: ChangePasswordRequest, + current_user: dict = Depends(get_current_user) +): + """Change user password after verifying code and current password""" + from user_db_utils import get_user_by_id + + # Get user from database + user = get_user_by_id(current_user["user_id"]) + if not user: + raise HTTPException(status_code=404, detail="משתמש לא נמצא") + + # Verify code + if not verify_code(current_user["user_id"], request.verification_code): + raise HTTPException(status_code=401, detail="קוד אימות שגוי או פג תוקף") + + # Verify current password + if not verify_password(request.current_password, user["password_hash"]): + raise HTTPException(status_code=401, detail="סיסמה נוכחית שגויה") + + # Hash new password + new_password_hash = hash_password(request.new_password) + + # Update password in database + conn = get_conn() + cur = conn.cursor() + try: + cur.execute( + "UPDATE users SET password_hash = %s WHERE id = %s", + (new_password_hash, current_user["user_id"]) + ) + conn.commit() + except Exception as e: + conn.rollback() + raise HTTPException(status_code=500, detail=f"שגיאה בעדכון סיסמה: {str(e)}") + finally: + cur.close() + conn.close() + + return {"message": "הסיסמה עודכנה בהצלחה"} + + +# ============= Google OAuth Endpoints ============= + +@app.get("/auth/google/login") +async def google_login(request: Request): + """Redirect to Google OAuth login""" + redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:5174/auth/google/callback") + return await oauth.google.authorize_redirect(request, redirect_uri) + + +@app.get("/auth/google/callback") +async def google_callback(request: Request): + """Handle Google OAuth callback""" + try: + # Get token from Google + token = await oauth.google.authorize_access_token(request) + + # Get user info from Google + user_info = token.get('userinfo') + if not user_info: + raise HTTPException(status_code=400, detail="Failed to get user info from Google") + + email = user_info.get('email') + google_id = user_info.get('sub') + name = user_info.get('name', '') + given_name = user_info.get('given_name', '') + family_name = user_info.get('family_name', '') + + if not email: + raise HTTPException(status_code=400, detail="Email not provided by Google") + + # Check if user exists + existing_user = get_user_by_email(email) + + if existing_user: + # User exists, log them in + user_id = existing_user["id"] + username = existing_user["username"] + else: + # Create new user + # Generate username from email or name + username = email.split('@')[0] + + # Check if username exists, add number if needed + base_username = username + counter = 1 + while get_user_by_username(username): + username = f"{base_username}{counter}" + counter += 1 + + # Create user with random password (they'll use Google login) + import secrets + random_password = secrets.token_urlsafe(32) + password_hash = hash_password(random_password) + + new_user = create_user( + username=username, + email=email, + password_hash=password_hash, + first_name=given_name if given_name else None, + last_name=family_name if family_name else None, + display_name=name if name else username, + is_admin=False + ) + user_id = new_user["id"] + + # Create JWT token + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": str(user_id), "username": username}, + expires_delta=access_token_expires + ) + + # Redirect to frontend with token + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5174") + return Response( + status_code=302, + headers={"Location": f"{frontend_url}?token={access_token}"} + ) + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Google authentication failed: {str(e)}") + + # ============= Grocery Lists Endpoints ============= @app.get("/grocery-lists", response_model=List[GroceryList]) @@ -742,4 +922,4 @@ def delete_notification_endpoint( if __name__ == "__main__": - uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file + uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True) \ No newline at end of file diff --git a/backend/oauth_utils.py b/backend/oauth_utils.py new file mode 100644 index 0000000..78547fc --- /dev/null +++ b/backend/oauth_utils.py @@ -0,0 +1,20 @@ +import os +from authlib.integrations.starlette_client import OAuth +from starlette.config import Config + +# Load config +config = Config('.env') + +# Initialize OAuth +oauth = OAuth(config) + +# Register Google OAuth +oauth.register( + name='google', + client_id=os.getenv('GOOGLE_CLIENT_ID'), + client_secret=os.getenv('GOOGLE_CLIENT_SECRET'), + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': 'openid email profile' + } +) diff --git a/backend/requirements.txt b/backend/requirements.txt index 955dfbe..c86cbd8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,11 @@ python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.9 bcrypt==4.1.2 + +# Email +aiosmtplib==3.0.2 + +# OAuth +authlib==1.3.0 +httpx==0.27.0 +itsdangerous==2.1.2 diff --git a/backend/reset_admin_password.py b/backend/reset_admin_password.py new file mode 100644 index 0000000..0b9aba2 --- /dev/null +++ b/backend/reset_admin_password.py @@ -0,0 +1,41 @@ +import psycopg2 +import bcrypt +import os +from dotenv import load_dotenv + +load_dotenv() + +# New password for admin +new_password = "admin123" # Change this to whatever you want + +# Hash the password +salt = bcrypt.gensalt() +password_hash = bcrypt.hashpw(new_password.encode('utf-8'), salt).decode('utf-8') + +# Update in database +conn = psycopg2.connect(os.getenv('DATABASE_URL')) +cur = conn.cursor() + +# Update admin password +cur.execute( + "UPDATE users SET password_hash = %s WHERE username = %s", + (password_hash, 'admin') +) +conn.commit() + +# Verify +cur.execute("SELECT username, email, is_admin FROM users WHERE username = 'admin'") +user = cur.fetchone() +if user: + print(f"✓ Admin password updated successfully!") + print(f" Username: {user[0]}") + print(f" Email: {user[1]}") + print(f" Is Admin: {user[2]}") + print(f"\nYou can now login with:") + print(f" Username: admin") + print(f" Password: {new_password}") +else: + print("✗ Admin user not found!") + +cur.close() +conn.close() diff --git a/backend/user_db_utils.py b/backend/user_db_utils.py index 9944e84..fd4af0c 100644 --- a/backend/user_db_utils.py +++ b/backend/user_db_utils.py @@ -76,7 +76,7 @@ def get_user_by_id(user_id: int): cur = conn.cursor(cursor_factory=RealDictCursor) try: cur.execute( - "SELECT id, username, email, first_name, last_name, display_name, is_admin, created_at FROM users WHERE id = %s", + "SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, created_at FROM users WHERE id = %s", (user_id,) ) user = cur.fetchone() diff --git a/demo-recipes.sql b/demo-recipes.sql new file mode 100644 index 0000000..3559a84 --- /dev/null +++ b/demo-recipes.sql @@ -0,0 +1,178 @@ +-- Demo recipes for user dvir (id=3) + +-- Recipe 1: שקשוקה +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) +VALUES ( + 'שקשוקה', + 'breakfast', + 25, + '["מהיר", "בריא", "צמחוני"]'::jsonb, + '["4 ביצים", "4 עגבניות", "1 בצל", "2 שיני שום", "פלפל חריף", "כמון", "מלח", "שמן זית"]'::jsonb, + '[ + "לחתוך את הבצל והשום דק", + "לחמם שמן בסיר ולטגן את הבצל עד שקוף", + "להוסיף שום ופלפל חריף ולטגן דקה", + "לקצוץ עגבניות ולהוסיף לסיר", + "לתבל בכמון ומלח, לבשל 10 דקות", + "לפתוח גומות ברוטב ולשבור ביצה בכל גומה", + "לכסות ולבשל עד שהביצים מתקשות" + ]'::jsonb, + 'https://images.unsplash.com/photo-1525351484163-7529414344d8?w=500', + 'דביר', + 3 +); + +-- Recipe 2: פסטה ברוטב עגבניות +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) +VALUES ( + 'פסטה ברוטב עגבניות', + 'lunch', + 20, + '["מהיר", "צמחוני", "ילדים אוהבים"]'::jsonb, + '["500 גרם פסטה", "רסק עגבניות", "בזיליקום טרי", "3 שיני שום", "שמן זית", "מלח", "פלפל"]'::jsonb, + '[ + "להרתיח מים מלוחים ולבשל את הפסטה לפי ההוראות", + "בינתיים, לחמם שמן בסיר", + "לטגן שום כתוש דקה", + "להוסיף רסק עגבניות ולתבל", + "לבשל על אש בינונית 10 דקות", + "להוסיף בזיליקום קרוע", + "לערבב את הפסטה המסוננת עם הרוטב" + ]'::jsonb, + 'https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=500', + 'דביר', + 3 +); + +-- Recipe 3: סלט ישראלי +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) +VALUES ( + 'סלט ישראלי', + 'snack', + 10, + '["מהיר", "בריא", "טבעוני", "צמחוני"]'::jsonb, + '["4 עגבניות", "2 מלפפונים", "1 בצל", "פטרוזיליה", "לימון", "שמן זית", "מלח"]'::jsonb, + '[ + "לחתוך עגבניות ומלפפונים לקוביות קטנות", + "לקצוץ בצל דק", + "לקצוץ פטרוזיליה", + "לערבב הכל בקערה", + "להוסיף מיץ לימון ושמן זית", + "לתבל במלח ולערבב היטב" + ]'::jsonb, + 'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=500', + 'דביר', + 3 +); + +-- Recipe 4: חביתה עם ירקות +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) +VALUES ( + 'חביתה עם ירקות', + 'breakfast', + 15, + '["מהיר", "בריא", "חלבוני", "צמחוני"]'::jsonb, + '["3 ביצים", "1 בצל", "1 פלפל", "עגבניה", "גבינה צהובה", "מלח", "פלפל שחור", "שמן"]'::jsonb, + '[ + "לקצוץ את הירקות לקוביות קטנות", + "לטגן את הירקות בשמן עד שמתרככים", + "להקציף את הביצים במזלג", + "לשפוך את הביצים על הירקות", + "לפזר גבינה קצוצה", + "לבשל עד שהתחתית מוזהבת", + "להפוך או לקפל לחצי ולהגיש" + ]'::jsonb, + 'https://images.unsplash.com/photo-1525351159099-122d10e7960e?w=500', + 'דביר', + 3 +); + +-- Recipe 5: עוף בתנור עם תפוחי אדמה +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) +VALUES ( + 'עוף בתנור עם תפוחי אדמה', + 'dinner', + 60, + '["משפחתי", "חגיגי"]'::jsonb, + '["1 עוף שלם", "1 ק״ג תפוחי אדמה", "פפריקה", "כורכום", "שום", "מלח", "פלפל", "שמן זית", "לימון"]'::jsonb, + '[ + "לחמם תנור ל-200 מעלות", + "לחתוך תפוחי אדמה לרבעים", + "לשפשף את העוף בתבלינים, שמן ומיץ לימון", + "לסדר תפוחי אדמה בתבנית", + "להניח את העוף על התפוחי אדמה", + "לאפות כשעה עד שהעוף מוזהב", + "להוציא, לחתוך ולהגיש עם הירקות" + ]'::jsonb, + 'https://images.unsplash.com/photo-1598103442097-8b74394b95c6?w=500', + 'דביר', + 3 +); + +-- Recipe 6: סנדוויץ טונה +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) +VALUES ( + 'סנדוויץ טונה', + 'lunch', + 5, + '["מהיר", "קר", "חלבוני"]'::jsonb, + '["קופסת טונה", "2 פרוסות לחם", "מיונז", "חסה", "עגבניה", "מלפפון חמוץ", "מלח", "פלפל"]'::jsonb, + '[ + "לסנן את הטונה", + "לערבב את הטונה עם מיונז", + "לתבל במלח ופלפל", + "למרוח על פרוסת לחם", + "להוסיף חסה, עגבניה ומלפפון", + "לכסות בפרוסת לחם שנייה" + ]'::jsonb, + 'https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=500', + 'דביר', + 3 +); + +-- Recipe 7: בראוניז שוקולד +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) +VALUES ( + 'בראוניז שוקולד', + 'snack', + 35, + '["קינוח", "שוקולד", "אפייה"]'::jsonb, + '["200 גרם שוקולד מריר", "150 גרם חמאה", "3 ביצים", "כוס סוכר", "חצי כוס קמח", "אבקת קקאו", "וניל"]'::jsonb, + '[ + "לחמם תנור ל-180 מעלות", + "להמיס שוקולד וחמאה במיקרוגל", + "להקציף ביצים וסוכר", + "להוסיף את תערובת השוקולד", + "להוסיף קמח וקקאו ולערבב", + "לשפוך לתבנית משומנת", + "לאפות 25 דקות", + "להוציא ולהניח להתקרר לפני חיתוך" + ]'::jsonb, + 'https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=500', + 'דביר', + 3 +); + +-- Recipe 8: מרק עדשים +INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) +VALUES ( + 'מרק עדשים', + 'dinner', + 40, + '["בריא", "צמחוני", "טבעוני", "חם"]'::jsonb, + '["2 כוסות עדשים כתומות", "בצל", "גזר", "3 שיני שום", "כמון", "כורכום", "מלח", "לימון"]'::jsonb, + '[ + "לשטוף את העדשים", + "לקצוץ בצל, גזר ושום", + "לטגן את הבצל עד שקוף", + "להוסיף שום ותבלינים", + "להוסיף גזר ועדשים", + "להוסיף 6 כוסות מים", + "לבשל 30 דקות עד שהעדשים רכים", + "לטחון חלק מהמרק לקבלת מרקם עבה", + "להוסיף מיץ לימון לפני הגשה" + ]'::jsonb, + 'https://images.unsplash.com/photo-1547592166-23ac45744acd?w=500', + 'דביר', + 3 +); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cb69b6a..405eb50 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import ToastContainer from "./components/ToastContainer"; import ThemeToggle from "./components/ThemeToggle"; import Login from "./components/Login"; import Register from "./components/Register"; +import ChangePassword from "./components/ChangePassword"; import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api"; import { getToken, removeToken, getMe } from "./authApi"; @@ -51,6 +52,7 @@ function App() { const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" }); const [logoutModal, setLogoutModal] = useState(false); + const [changePasswordModal, setChangePasswordModal] = useState(false); const [toasts, setToasts] = useState([]); const [theme, setTheme] = useState(() => { try { @@ -324,7 +326,13 @@ function App() { ) : ( - setDrawerOpen(true)} user={user} onLogout={handleLogout} onShowToast={addToast} /> + setDrawerOpen(true)} + user={user} + onLogout={handleLogout} + onChangePassword={() => setChangePasswordModal(true)} + onShowToast={addToast} + /> )} {/* Show auth modal if needed */} @@ -493,6 +501,16 @@ function App() { onCancel={() => setLogoutModal(false)} /> + {changePasswordModal && ( + setChangePasswordModal(false)} + onSuccess={() => { + addToast("הסיסמה שונתה בהצלחה", "success"); + }} + /> + )} + ); diff --git a/frontend/src/authApi.js b/frontend/src/authApi.js index e6ae1dc..c947259 100644 --- a/frontend/src/authApi.js +++ b/frontend/src/authApi.js @@ -53,6 +53,40 @@ export async function getMe(token) { return res.json(); } +export async function requestPasswordChangeCode(token) { + const res = await fetch(`${API_BASE}/auth/request-password-change-code`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Failed to send verification code"); + } + return res.json(); +} + +export async function changePassword(verificationCode, currentPassword, newPassword, token) { + const res = await fetch(`${API_BASE}/auth/change-password`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + verification_code: verificationCode, + current_password: currentPassword, + new_password: newPassword, + }), + }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Failed to change password"); + } + return res.json(); +} + // Auth helpers export function saveToken(token) { localStorage.setItem("auth_token", token); diff --git a/frontend/src/components/ChangePassword.jsx b/frontend/src/components/ChangePassword.jsx new file mode 100644 index 0000000..70a9c85 --- /dev/null +++ b/frontend/src/components/ChangePassword.jsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { changePassword, requestPasswordChangeCode } from "../authApi"; + +export default function ChangePassword({ token, onClose, onSuccess }) { + const [step, setStep] = useState(1); // 1: request code, 2: enter code & passwords + const [verificationCode, setVerificationCode] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [codeSent, setCodeSent] = useState(false); + + const handleRequestCode = async () => { + setError(""); + setLoading(true); + + try { + await requestPasswordChangeCode(token); + setCodeSent(true); + setStep(2); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + + // Validation + if (!verificationCode || !currentPassword || !newPassword || !confirmPassword) { + setError("נא למלא את כל השדות"); + return; + } + + if (verificationCode.length !== 6) { + setError("קוד האימות חייב להכיל 6 ספרות"); + return; + } + + if (newPassword !== confirmPassword) { + setError("הסיסמאות החדשות אינן תואמות"); + return; + } + + if (newPassword.length < 6) { + setError("הסיסמה חייבת להכיל לפחות 6 תווים"); + return; + } + + setLoading(true); + + try { + await changePassword(verificationCode, currentPassword, newPassword, token); + onSuccess?.(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

שינוי סיסמה

+ +
+ +
+ {error &&
{error}
} + + {step === 1 && ( +
+

+ קוד אימות יישלח לכתובת המייל שלך. הקוד תקף ל-10 דקות. +

+ +
+ )} + + {step === 2 && ( +
+ {codeSent && ( +
+ ✓ קוד אימות נשלח לכתובת המייל שלך +
+ )} + +
+ + setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + disabled={loading} + autoFocus + placeholder="123456" + maxLength={6} + style={{ fontSize: "1.2rem", letterSpacing: "0.3rem", textAlign: "center" }} + /> +
+ +
+ + setCurrentPassword(e.target.value)} + disabled={loading} + /> +
+ +
+ + setNewPassword(e.target.value)} + disabled={loading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={loading} + /> +
+ +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx index ec3618b..1882b24 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { login, saveToken } from "../authApi"; function Login({ onSuccess, onSwitchToRegister }) { @@ -7,6 +7,18 @@ function Login({ onSuccess, onSwitchToRegister }) { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + // Check for token in URL (from Google OAuth redirect) + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('token'); + if (token) { + saveToken(token); + // Clean URL + window.history.replaceState({}, document.title, window.location.pathname); + onSuccess(); + } + }, [onSuccess]); + const handleSubmit = async (e) => { e.preventDefault(); setError(""); @@ -23,6 +35,11 @@ function Login({ onSuccess, onSwitchToRegister }) { } }; + const handleGoogleLogin = () => { + const apiBase = window.__ENV__?.API_BASE || "http://localhost:8001"; + window.location.href = `${apiBase}/auth/google/login`; + }; + return (
@@ -61,6 +78,49 @@ function Login({ onSuccess, onSwitchToRegister }) { +
+
+ או +
+ + +

עדיין אין לך חשבון?{" "} diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index 65ee762..9b65525 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -1,6 +1,6 @@ import NotificationBell from "./NotificationBell"; -function TopBar({ onAddClick, user, onLogout, onShowToast }) { +function TopBar({ onAddClick, user, onLogout, onChangePassword, onShowToast }) { return (

@@ -20,6 +20,11 @@ function TopBar({ onAddClick, user, onLogout, onShowToast }) { + מתכון חדש )} + {onChangePassword && ( + + )} {onLogout && (