From 1d33e52100405c88f2022af063c86662cdf38fae Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Mon, 8 Dec 2025 07:04:50 +0200 Subject: [PATCH] Manage users --- .../__pycache__/auth_utils.cpython-313.pyc | Bin 0 -> 3971 bytes backend/__pycache__/db_utils.cpython-313.pyc | Bin 8219 -> 8321 bytes backend/__pycache__/main.cpython-313.pyc | Bin 6868 -> 12929 bytes .../__pycache__/user_db_utils.cpython-313.pyc | Bin 0 -> 3938 bytes backend/auth_utils.py | 80 +++++++++ backend/db_utils.py | 13 +- backend/main.py | 166 +++++++++++++++++- backend/requirements.txt | 5 + backend/schema.sql | 16 +- backend/user_db_utils.py | 83 +++++++++ frontend/src/App.css | 88 +++++++++- frontend/src/App.jsx | 142 +++++++++++++-- frontend/src/api.js | 23 ++- frontend/src/authApi.js | 60 +++++++ frontend/src/components/Login.jsx | 77 ++++++++ frontend/src/components/RecipeDetails.jsx | 29 ++- frontend/src/components/RecipeFormDrawer.jsx | 34 ++-- frontend/src/components/Register.jsx | 118 +++++++++++++ frontend/src/components/TopBar.jsx | 15 +- 19 files changed, 890 insertions(+), 59 deletions(-) create mode 100644 backend/__pycache__/auth_utils.cpython-313.pyc create mode 100644 backend/__pycache__/user_db_utils.cpython-313.pyc create mode 100644 backend/auth_utils.py create mode 100644 backend/user_db_utils.py create mode 100644 frontend/src/authApi.js create mode 100644 frontend/src/components/Login.jsx create mode 100644 frontend/src/components/Register.jsx diff --git a/backend/__pycache__/auth_utils.cpython-313.pyc b/backend/__pycache__/auth_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e56bedd0d16f573ce500d3589a65b1817c13631 GIT binary patch literal 3971 zcma)9T}&I<6~5yc|6?1l&2JVahHTX>?TwMoPf!a)Pt8~apVs600U;mx-*6t ztyH&9+4LdVT}AODZTEpneMq00hm~6OsgJh2Qs^vgTD7XQ>I;Z$RpqJYUV99o>2|L) z_wSs0ukShEJ>T)Z$Kyg!etYAB{KSpW-^jo$wpwRD${_R@2}oe3P!wSlWiZ2&v1Q5< zWid

=YNZVr$ffZFI~{*|8mTtW%Dt6FZ|W?25Xvo62lc4N(vFM7`Ks)AeCr!WZMs zqYv5;66}Iw$WoobO@b3Q3oh(WctU8XriKH8I}sEb62U=E@N}SM4z~zinDNos)awbc z8KM%Morz$qVuM{?*Qdj*VIP;Wl9JX8&&15kt;oHUM70f@ri*$`gEcN9xGITQ!p1Su zAI<4YDwa1yvh5g#gA`pBGn(NNB4e@0OmZr6XQMZ-=5U`TrLd&;tw{O4)RL$yN`11@ zm&Iy2mm-S|>x3{cbV2^%NrVhXR@Ah06{igwtjS>|RkIYi9^&9*;oJW?RF6>{#STkh zjue40tt(grmO~0NSMxMNF|xyva^Q2ta!Aue>LKkAy4`G|Mq%S@%y~#y>mHomi^AN- zu?bOI;zi!P7QdikUem#H%3}X3YffM2yEy&z1S}3)4V$E-)U;&S=2JMI)eZZiq-bJB zH*8C=OLpC`r6r;e=CBV|7+!PN#JHrb=xR2xDC?4#Sk3ogDJ5qmEio^qRsatP5uBXN>2gNv&*lve`4vg?O#Bw? z-vpm_6{-T-wV{@-C&^Eeo3oW*-&Sf1mx2R@=^b~|!;uFg6?bQ0e8=N|c=y5G;z-4F zws375Bv(GZQf#fbkC(XP`>+eSQbv@O`XPpooGGR26REl@LAwrzdocLUaInOgZ-@gaLK(&<7Hh7xLg{;#nzu;B9dcZlmeEsz^K_eoan;bUx4Lz#X_5e)Tl= zLmg&u02&;G%CM)Fq|{1w9lYF4CV5h|6G=nO7}VQ!|Kejr=3Aq`p>8PL3$q>YY41W+ zbx-f};OTPk^ww}Cc>d|w(^x5Zu`s>u4?XvX%l`1z$%=oVaHHn1Vt>UwT;hgt7ulVI zs3IC3>0VaGl9r@VD&T@suz(a1ZubAoZLW(meXv4dZ}xoG z6?1la2vl{Q)rxU1uI64*f_TtlJ=Uxk?;LOE=ntllC?Z-LK) zP6$429;yQROJm@P^AqQ1?!x4s?SY-9_9xw+c2}B0n|I4i10UP|UZ42<&X>cn@(H2b zB;2=cbDrm1pv(mxNyQHyCBNiO>@)|Toc{E5@y@SYU&!U=iNe&jyQRdnR3T}lXTL@d zEraMdB8y$Ve#Yhp%D^CT1# zicL|%<-rXm<8Cz0&d&A`KCa1uK&ng)%8mc^1 zgU1L+`r*@VK?RYBb68!bZ{wdwznFaXUb*@Defzer<-YgdJ8u6zvVw-UbEmEAdD~#Q zZE)K^yd6Bb)m91&?AbY=YZq}&*DecMuQc*Qf5P{mU%Wl)g!>*c%xl;nIHuV6J#;8n zKzp=6sz7vTiPu8_geyyotUpo?sGcMPDX`+3!cB}O!>=zT$hc&Ym^or}TH>skwET~= zI8!cI`z;VuY#SG@OFEy*VGM4kgu*Xib@c#A{3()a0PE;dN(b7(Dv2{;gVl2LcoZ2ZpfDP1^Zk(b=YYxbn8Zs=4m2#PX4v2|lCpLv z;1E_f9Hu$|RL3;CG`M+H&CpB+G7veXQ>LLvteT5l{+EdQwn|Tq%d7CWA|s9984wW* zYX5`^?#wVR(5XM6&KIcbE97~B2FhsQ1&VxyoRGMB8z0_zaOcrSr4v&X&y9j>yTM;@ zZg+)CsPnJx;7>0X9J@9f6Dp4FB4{?pUXo_F*~(npgehpA_0Z;by)VgVuhGf07Sp>3 znx_|^O_tAJ`-b#)+gyyR$nGL&Ho2Fi+3P@8nMvjkZI#j39!x};QfP!IKxcOD8`{}B rhT=?uDJ9aC8EFsMnZVY?(%`M~_kOTT2Bl?O($>q%_x?pj)ENH(0FZfT literal 0 HcmV?d00001 diff --git a/backend/__pycache__/db_utils.cpython-313.pyc b/backend/__pycache__/db_utils.cpython-313.pyc index dfc8c39f38d5a9184e11d6a89b8f12899571ae05..752c85150931c83187075d20915e310712df9e32 100644 GIT binary patch delta 446 zcmbR3(CEnfnU|M~0SIJc%rfgY@;Y!bo}FySCp@`d+In&VAIszgoEn@u3Z=!VMe&&_ zn@@1Mu`r(9%+I%t5h1$xC_gJ>$z)w_iOu&!iWw&hiivY`nKFV*WneJjoE$86nuXgh z(Ri|wcmYD!bFN^4X5atpT`ob&7%lAQrL0D|M&P1KbdL7Q+)fxDtKWH#; z^7eCga`*7w;1_zp!PC#x$<@pAT^}gd8~gi%9!MH2^s87IXi<-Viz>q$XFeAZ=EGW& zK=u(4E*B;CBXaB@wyKbeIdhR71Hl~_gh&7Y delta 349 zcmZp4obAB-nU|M~0SIg#7-haP6An`*kRApGg~{usPoScCn_Xm=GO~eOU*tLYy_^(d#AYseSw>|^M$rW-3yN1bFQ{0k c_kjV(-4L=vc}MXMjg4_%K->=&lf4wo0b2lNR{#J2 diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index 225a93004bbcb3523e956e01bd25ad37944d410c..735674ec1e70e51309235287e0a3e0cb34bc4970 100644 GIT binary patch literal 12929 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 I9Xn$F7mbT4kN^Mx delta 3122 zcmZuyT})iZ6`r~G?%n@o*@gY%Z&?=1;=&pVve!SjIHq=%05%g+8rDkJ>>8~L%WCc- z*HtT)m)L$t?Q|3gKQ-!8tV)&B2fEcK!1%>D!- zUunKMbIvz2cfOf9vzh&D%(drq+6a7wKaJ%sdf#>hl%FVX_xEiib*?AP+#Dv1Fm1|` zv~sJoO;fg{o!g~NrySe?aXRJXPN?-MSF(vWCEeVeZ05~L5BDU!+#4nV5+TeSBFvJq zkEu8xA-qL;t>Cq#S{mL~>HVP{+>QfopL9FH>pI}|r%W(#6LYiXgu(-H!aSEL52gZh zW7SCxV#=BdGB0b1Q(0OQrZ55Kak1It?ee9Rl+Ofo=1(Xr5F+&fcsp=TA*aWe^06Qb zB~;dyY8_Qrdx%`u8oW;6okd=62k>x|&Qutf{!xu}9b$F^a}t@|9W2bct4tU#a){Ma z|F?~v+IW=Flo{4bS(NoAlu?!SrPNeZ{`C>C!qVyoOR0LXJ*u-9j(eRp{iTQeuMx*P)wNHXh>W!Pubk)Kxh%aF{kw&*%Uvso_hTyo=q3CJ6-4r0n9uw z@iuIUL!I43?IaiJOJW%Lm{}J&lNkCGt;u=!w0(bpBb0czk<7XOq4g0PFU|zF)lWUL4!^+N`!`K5$in z#N?UH{h)!Wt<)|r8(#3RHe*T05@=Rulx$UL2+Vj>h051WV5Un{qujMX~j)p-?lq_AeR^yyo zcM&cC7#aYmU6*^k02`65%RLLh9I_q&l*pI%mb>G3#-G^RO6Q*jL}&2s#zEnMv-4+w z80GlOhmJ8SBaiWEFy0}HwL=Q>k^0Kv7SltrBVT^-dVugK`!b1XGkglX{1t?aX+XF* zE^Od>!NygV&5*mZ#camFJ#DP6>a4GhL1+Vt5e9$@?dsgR`NS0~UHF^b^H*2fd+PS+ zrN^$z+m_2uHJi*zKW>WGvO%1u^O?0(@q><6<0H^r?pE2dn*Pxn#oTIEJ_k#U1pomb z0V%+5#aA5zkjPw}A6x2)?1SCTHEOam2Xl&R;YA1R{vZxo&tLAtTg|7$|H5-4a_u(m zKxX4Yl}$#G=L+%|lr1^KSifpyi`C~z7o~1rNM6@hv6FnWa6|YRgmLk(yXRaK%(An( zyt-0Xz;_FZ@iOdNiR^obqxaqKKd~nu{k6X^{rO&${{9mdKac!k`Ya^B=erT&y-1($ z+DPvdeZFVb{4V1dOmS}_Hfhm5(F2oC?UP6Z@R6r$(yu=n4o-U9pW19-e;UvM|KKqJ zioXmd2K)H}{pA9+iGZF&X+`c`U>K!wb4>8M1J>EHBkTe}|W zCI2=(UQs+iWEBF{d%%YZ`6r~mGJyQhW@y_Hd8n2`+jO*|Xw>fAVtWK?WR6Ks#U=Rg zky9}mdD`UPQt$kUIz;>t)^ I>jC}#53hk>p8x;= diff --git a/backend/__pycache__/user_db_utils.cpython-313.pyc b/backend/__pycache__/user_db_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6126eeeca5d51376a447906e9cbc616a9c115e6 GIT binary patch literal 3938 zcmeHK%}*Og6rc63?e)h320}|dHza9eAXG^pX{ta)u#=DvlX&f>5mv2by}&lcMzaeE zC~9+rgo>zYRc=L9FZmC8&3}N(p=gshZlO}uQ{S8Q+NMfPBGFq$+BY+A-kbMk z=Jy+qHzg@ZpgfG9F;W3Se!-4geC5XGI5fT_3Q@SLggP}xIp}+?dam%4mx;pfB~(y^ zj92l(=gW9|eTsiC`BDiApdNfL@68f)iXc{2K;ML^-=5sjK;z zp;<#S)GSi1oD8clXCA)IHV!izt&Al-m1d>UK}H3)lXTbQDxmIwbt#^{vgau5f>U<% z;uXR5>VrJ6?C1k~sQ2=whbsj*H#Ig^ByJ9@cbK>bW6^umO7@Ix~vro1zod@ zV!`&uE@Ur{C@DLTFKTLjvS?aNcVi@(vW33h)2CQBF?c<02dSjWK0fi6`x_ ztDG@)YKLID8-t2+Ya|)7o7|yk)im!GX)fxu1r|Y=E>g=D-Gpp`Ig2%R;CA*- z>1t+b?nF7C%$Ta((F?f@Muo*>XDlOc_Ds*&5ttqo)eWNiAp|1DR6#r-zlK_V2<`hm zwC}mpxP1HJ?Z<(e{_06L`P;Y4bLIhgWs_ZYinc8uNfwquNLbN^uQS~{-C z$B&uavh!^pPsUT}WMVjRNj7rbEC2$eGPB0=&1F?<+pJNM0oe9AsZ+#(?bq+=+Ki>! z;<#>Ulf{A#O>Z1q1oE}7R2pt9Fh0ViUv>Ey)V$azDx(~-g zP8L~QH|+vV1g$;=fyCLr9y<6Swh?St?po~n_M?TawP5dqp^~S;yJtfTEr%9EC66FA zm4c+9ZDndL+`T3F1Hn=gX=+)%zj*)ACkw)c7)HA#>3L(zeH&PL-8m|p5$0R* zHwKSh9-FHGlpcnZ=2J@iT6`$=V(4xoMYmicx6_(A2^bD;%DHd zM<4`4Ha&{1V<_H6f!i1r927@U(TRdV7Y`kw>j1YE?|n=?J2uB^A>M7>v3#pe$`qNyIvN!n+&*T2G5eGXFD#mzXmQI z8`)*xMrwh}ZfXa$H+KoOY)S{R^aLybAj$63x{zc~5f_q9QYXRp41BMI$LxmGyl{E9 z@JQc*__m+rW#nF8qyGaM8*0%w9CM)femyAGyD^R2cHo_Y^P_cv*HC*G8adhv9v5E~ zb=Nnf)`cs(in?fswqf$EAnZo>??I1#&!Vd7T>to1w|UcY9v|%frbrt4CJI~#ZAXER zZ0F(^&rcO|GkJZ0o&_Dg3e1}zN<7DL&q&}IY5b9N{^1ijU#YE`+p}_{M4(#LSoPhJ bE$o##B~YE-B2blN5GzVed+1NpvylG)B0J|k literal 0 HcmV?d00001 diff --git a/backend/auth_utils.py b/backend/auth_utils.py new file mode 100644 index 0000000..0168ac9 --- /dev/null +++ b/backend/auth_utils.py @@ -0,0 +1,80 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +import bcrypt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import os + +# Secret key for JWT (use environment variable in production) +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days + +security = HTTPBearer() + + +def hash_password(password: str) -> str: + """Hash a password for storing.""" + # Bcrypt has a 72 byte limit, truncate if necessary + password_bytes = password.encode('utf-8')[:72] + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password_bytes, salt) + return hashed.decode('utf-8') + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a stored password against one provided by user""" + # Bcrypt has a 72 byte limit, truncate if necessary + password_bytes = plain_password.encode('utf-8')[:72] + hashed_bytes = hashed_password.encode('utf-8') + return bcrypt.checkpw(password_bytes, hashed_bytes) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Create JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> dict: + """Decode and verify JWT token""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict: + """Get current user from JWT token (for protected routes)""" + token = credentials.credentials + payload = decode_token(token) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + ) + return {"user_id": int(user_id), "username": payload.get("username")} + + +# Optional dependency - returns None if no token provided +def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[dict]: + """Get current user if authenticated, otherwise None""" + if not credentials: + return None + try: + return get_current_user(credentials) + except HTTPException: + return None diff --git a/backend/db_utils.py b/backend/db_utils.py index c5ba8ba..a8f6141 100644 --- a/backend/db_utils.py +++ b/backend/db_utils.py @@ -55,7 +55,7 @@ def list_recipes_db() -> List[Dict[str, Any]]: cur.execute( """ SELECT id, name, meal_type, time_minutes, - tags, ingredients, steps, image, made_by + tags, ingredients, steps, image, made_by, user_id FROM recipes ORDER BY id """ @@ -85,7 +85,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di image = %s, made_by = %s WHERE id = %s - RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by + RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id """, ( recipe_data["name"], @@ -133,9 +133,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]: with conn.cursor() as cur: cur.execute( """ - INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by + INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id """, ( recipe_data["name"], @@ -146,6 +146,7 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]: json.dumps(recipe_data.get("steps", [])), recipe_data.get("image"), recipe_data.get("made_by"), + recipe_data.get("user_id"), ), ) row = cur.fetchone() @@ -163,7 +164,7 @@ def get_recipes_by_filters_db( try: query = """ SELECT id, name, meal_type, time_minutes, - tags, ingredients, steps, image, made_by + tags, ingredients, steps, image, made_by, user_id FROM recipes WHERE 1=1 """ diff --git a/backend/main.py b/backend/main.py index 14e3183..e6a755e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,10 @@ import random from typing import List, Optional +from datetime import timedelta -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, Depends from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr import os import uvicorn @@ -14,6 +15,21 @@ from db_utils import ( get_recipes_by_filters_db, update_recipe_db, delete_recipe_db, + get_conn, +) + +from auth_utils import ( + hash_password, + verify_password, + create_access_token, + get_current_user, + ACCESS_TOKEN_EXPIRE_MINUTES, +) + +from user_db_utils import ( + create_user, + get_user_by_username, + get_user_by_email, ) @@ -34,12 +50,35 @@ class RecipeCreate(RecipeBase): class Recipe(RecipeBase): id: int + user_id: Optional[int] = None # Recipe owner ID class RecipeUpdate(RecipeBase): pass +# User models +class UserRegister(BaseModel): + username: str + email: EmailStr + password: str + + +class UserLogin(BaseModel): + username: str + password: str + + +class Token(BaseModel): + access_token: str + token_type: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + app = FastAPI( title="Random Recipes API", @@ -77,6 +116,7 @@ def list_recipes(): ingredients=r["ingredients"] or [], steps=r["steps"] or [], image=r.get("image"), + user_id=r.get("user_id"), ) for r in rows ] @@ -84,9 +124,10 @@ def list_recipes(): @app.post("/recipes", response_model=Recipe, status_code=201) -def create_recipe(recipe_in: RecipeCreate): +def create_recipe(recipe_in: RecipeCreate, current_user: dict = Depends(get_current_user)): data = recipe_in.dict() data["meal_type"] = data["meal_type"].lower() + data["user_id"] = current_user["user_id"] row = create_recipe_db(data) @@ -100,10 +141,24 @@ def create_recipe(recipe_in: RecipeCreate): ingredients=row["ingredients"] or [], steps=row["steps"] or [], image=row.get("image"), + user_id=row.get("user_id"), ) @app.put("/recipes/{recipe_id}", response_model=Recipe) -def update_recipe(recipe_id: int, recipe_in: RecipeUpdate): +def update_recipe(recipe_id: int, recipe_in: RecipeUpdate, current_user: dict = Depends(get_current_user)): + # Check ownership BEFORE updating + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute("SELECT user_id FROM recipes WHERE id = %s", (recipe_id,)) + recipe = cur.fetchone() + if not recipe: + raise HTTPException(status_code=404, detail="המתכון לא נמצא") + if recipe["user_id"] != current_user["user_id"]: + raise HTTPException(status_code=403, detail="אין לך הרשאה לערוך מתכון זה") + finally: + conn.close() + data = recipe_in.dict() data["meal_type"] = data["meal_type"].lower() @@ -121,11 +176,25 @@ def update_recipe(recipe_id: int, recipe_in: RecipeUpdate): ingredients=row["ingredients"] or [], steps=row["steps"] or [], image=row.get("image"), + user_id=row.get("user_id"), ) @app.delete("/recipes/{recipe_id}", status_code=204) -def delete_recipe(recipe_id: int): +def delete_recipe(recipe_id: int, current_user: dict = Depends(get_current_user)): + # Get recipe first to check ownership + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute("SELECT user_id FROM recipes WHERE id = %s", (recipe_id,)) + recipe = cur.fetchone() + if not recipe: + raise HTTPException(status_code=404, detail="המתכון לא נמצא") + if recipe["user_id"] != current_user["user_id"]: + raise HTTPException(status_code=403, detail="אין לך הרשאה למחוק מתכון זה") + finally: + conn.close() + deleted = delete_recipe_db(recipe_id) if not deleted: raise HTTPException(status_code=404, detail="המתכון לא נמצא") @@ -154,6 +223,7 @@ def random_recipe( ingredients=r["ingredients"] or [], steps=r["steps"] or [], image=r.get("image"), + user_id=r.get("user_id"), ) for r in rows ] @@ -177,6 +247,88 @@ def random_recipe( return random.choice(recipes) +# Authentication endpoints +@app.post("/auth/register", response_model=UserResponse, status_code=201) +def register(user: UserRegister): + """Register a new user""" + print(f"[REGISTER] Starting registration for username: {user.username}") + + # Check if username already exists + print(f"[REGISTER] Checking if username exists...") + existing_user = get_user_by_username(user.username) + if existing_user: + print(f"[REGISTER] Username already exists") + raise HTTPException( + status_code=400, + detail="שם המשתמש כבר קיים במערכת" + ) + + # Check if email already exists + print(f"[REGISTER] Checking if email exists...") + existing_email = get_user_by_email(user.email) + if existing_email: + print(f"[REGISTER] Email already exists") + raise HTTPException( + status_code=400, + detail="האימייל כבר רשום במערכת" + ) + + # Hash password and create user + print(f"[REGISTER] Hashing password...") + password_hash = hash_password(user.password) + print(f"[REGISTER] Creating user in database...") + new_user = create_user(user.username, user.email, password_hash) + print(f"[REGISTER] User created successfully: {new_user['id']}") + + return UserResponse( + id=new_user["id"], + username=new_user["username"], + email=new_user["email"] + ) + + +@app.post("/auth/login", response_model=Token) +def login(user: UserLogin): + """Login user and return JWT token""" + # Get user from database + db_user = get_user_by_username(user.username) + if not db_user: + raise HTTPException( + status_code=401, + detail="שם משתמש או סיסמה שגויים" + ) + + # Verify password + if not verify_password(user.password, db_user["password_hash"]): + raise HTTPException( + status_code=401, + detail="שם משתמש או סיסמה שגויים" + ) + + # Create access token + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": str(db_user["id"]), "username": db_user["username"]}, + expires_delta=access_token_expires + ) + + return Token(access_token=access_token, token_type="bearer") + + +@app.get("/auth/me", response_model=UserResponse) +def get_me(current_user: dict = Depends(get_current_user)): + """Get current logged-in user info""" + from user_db_utils import get_user_by_id + user = get_user_by_id(current_user["user_id"]) + if not user: + raise HTTPException(status_code=404, detail="משתמש לא נמצא") + + return UserResponse( + id=user["id"], + username=user["username"], + email=user["email"] + ) + + 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=8000, reload=True) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 8933c6e..6d77712 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,8 @@ pydantic==2.7.4 python-dotenv==1.0.1 psycopg2-binary==2.9.9 + +# Authentication +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 diff --git a/backend/schema.sql b/backend/schema.sql index aa7547c..e644fd6 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -1,3 +1,15 @@ +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + -- Create recipes table CREATE TABLE IF NOT EXISTS recipes ( id SERIAL PRIMARY KEY, @@ -8,7 +20,9 @@ CREATE TABLE IF NOT EXISTS recipes ( tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"] ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"] steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...] - image TEXT -- Base64-encoded image or image URL + image TEXT, -- Base64-encoded image or image URL + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Optional: index for filters diff --git a/backend/user_db_utils.py b/backend/user_db_utils.py new file mode 100644 index 0000000..fd37d36 --- /dev/null +++ b/backend/user_db_utils.py @@ -0,0 +1,83 @@ +import os +import psycopg2 +from psycopg2.extras import RealDictCursor + + +def get_db_connection(): + """Get database connection""" + return psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "recipes_db"), + user=os.getenv("DB_USER", "recipes_user"), + password=os.getenv("DB_PASSWORD", "recipes_password"), + ) + + +def create_user(username: str, email: str, password_hash: str): + """Create a new user""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + INSERT INTO users (username, email, password_hash) + VALUES (%s, %s, %s) + RETURNING id, username, email, created_at + """, + (username, email, password_hash) + ) + user = cur.fetchone() + conn.commit() + return dict(user) + finally: + cur.close() + conn.close() + + +def get_user_by_username(username: str): + """Get user by username""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT id, username, email, password_hash, created_at FROM users WHERE username = %s", + (username,) + ) + user = cur.fetchone() + return dict(user) if user else None + finally: + cur.close() + conn.close() + + +def get_user_by_email(email: str): + """Get user by email""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT id, username, email, password_hash, created_at FROM users WHERE email = %s", + (email,) + ) + user = cur.fetchone() + return dict(user) if user else None + finally: + cur.close() + conn.close() + + +def get_user_by_id(user_id: int): + """Get user by ID""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT id, username, email, created_at FROM users WHERE id = %s", + (user_id,) + ) + user = cur.fetchone() + return dict(user) if user else None + finally: + cur.close() + conn.close() diff --git a/frontend/src/App.css b/frontend/src/App.css index 9776687..4780b9d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -32,11 +32,21 @@ body { justify-content: center; align-items: flex-start; } + +.user-greeting-header { + text-align: center; + padding: 0.5rem 1rem; + font-size: 1.3rem; + font-weight: 600; + color: var(--text-main); +} + .app-root { min-height: 100vh; max-width: 1200px; margin: 0 auto; padding: 1.5rem; + padding-top: 4.5rem; /* Add space for fixed theme toggle */ direction: rtl; } @@ -74,6 +84,15 @@ body { color: var(--text-muted); } +.user-greeting { + font-size: 1.1rem; + font-weight: 600; + color: var(--accent); + padding: 0.5rem 1rem; + background: rgba(79, 70, 229, 0.1); + border-radius: 8px; +} + /* Layout */ .layout { @@ -640,7 +659,7 @@ select { .toast-container { position: fixed; - bottom: 1.5rem; + bottom: 5rem; right: 1.5rem; z-index: 60; display: flex; @@ -1264,4 +1283,69 @@ html { [data-theme="light"] .recipe-list-image { background: rgba(229, 231, 235, 0.5); -} \ No newline at end of file +} + +/* Auth Pages */ +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: var(--bg); +} + +.auth-card { + width: 100%; + max-width: 420px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); +} + +.auth-title { + font-size: 2rem; + font-weight: 800; + margin-bottom: 0.5rem; + text-align: center; + color: var(--text-main); +} + +.auth-subtitle { + text-align: center; + color: var(--text-muted); + margin-bottom: 2rem; + font-size: 0.95rem; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-footer { + margin-top: 1.5rem; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; +} + +.link-btn { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + text-decoration: underline; + font-weight: 600; +} + +.link-btn:hover { + color: var(--accent-hover); +} + +.full-width { + width: 100%; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b1c403c..5948a98 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,9 +8,17 @@ import RecipeFormDrawer from "./components/RecipeFormDrawer"; import Modal from "./components/Modal"; import ToastContainer from "./components/ToastContainer"; import ThemeToggle from "./components/ThemeToggle"; +import Login from "./components/Login"; +import Register from "./components/Register"; import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api"; +import { getToken, removeToken, getMe } from "./authApi"; function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + const [authView, setAuthView] = useState("login"); // "login" or "register" + const [loadingAuth, setLoadingAuth] = useState(true); + const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); @@ -42,6 +50,27 @@ function App() { } }); + // Check authentication on mount + useEffect(() => { + const checkAuth = async () => { + const token = getToken(); + if (token) { + try { + const userData = await getMe(token); + setUser(userData); + setIsAuthenticated(true); + } catch (err) { + // Token invalid or expired + removeToken(); + setIsAuthenticated(false); + } + } + setLoadingAuth(false); + }; + checkAuth(); + }, []); + + // Load recipes for everyone (readonly for non-authenticated) useEffect(() => { loadRecipes(); }, []); @@ -134,7 +163,8 @@ function App() { const handleCreateRecipe = async (payload) => { try { - const created = await createRecipe(payload); + const token = getToken(); + const created = await createRecipe(payload, token); setDrawerOpen(false); setEditingRecipe(null); await loadRecipes(); @@ -153,7 +183,8 @@ function App() { const handleUpdateRecipe = async (payload) => { try { - await updateRecipe(editingRecipe.id, payload); + const token = getToken(); + await updateRecipe(editingRecipe.id, payload, token); setDrawerOpen(false); setEditingRecipe(null); await loadRecipes(); @@ -177,7 +208,8 @@ function App() { setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" }); try { - await deleteRecipe(recipeId); + const token = getToken(); + await deleteRecipe(recipeId, token); await loadRecipes(); setSelectedRecipe(null); addToast("המתכון נמחק בהצלחה!", "success"); @@ -208,10 +240,89 @@ function App() { } }; + const handleLoginSuccess = async () => { + const token = getToken(); + const userData = await getMe(token); + setUser(userData); + setIsAuthenticated(true); + await loadRecipes(); + }; + + const handleLogout = () => { + removeToken(); + setUser(null); + setIsAuthenticated(false); + setRecipes([]); + setSelectedRecipe(null); + }; + + // Show loading state while checking auth + if (loadingAuth) { + return ( +

+
+ טוען... +
+
+ ); + } + + // Show main app (readonly if not authenticated) return (
setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} /> - setDrawerOpen(true)} /> + + {/* User greeting above TopBar */} + {isAuthenticated && user && ( +
+ שלום, {user.username} 👋 +
+ )} + + {/* Show login/register option in TopBar if not authenticated */} + {!isAuthenticated ? ( +
+
+ 🍽 +
+
מה לבשל היום?
+
מנהל המתכונים האישי שלך
+
+
+
+ + +
+
+ ) : ( + setDrawerOpen(true)} user={user} onLogout={handleLogout} /> + )} + + {/* Show auth modal if needed */} + {!isAuthenticated && authView !== null && ( +
setAuthView(null)}> +
e.stopPropagation()}> + {authView === "login" ? ( + setAuthView("register")} + /> + ) : ( + { + addToast("נרשמת בהצלחה! כעת התחבר", "success"); + setAuthView("login"); + }} + onSwitchToLogin={() => setAuthView("login")} + /> + )} +
+
+ )}
@@ -289,19 +400,24 @@ function App() { recipe={selectedRecipe} onEditClick={handleEditRecipe} onShowDeleteModal={handleShowDeleteModal} + isAuthenticated={isAuthenticated} + currentUser={user} />
- { - setDrawerOpen(false); - setEditingRecipe(null); - }} - onSubmit={handleFormSubmit} - editingRecipe={editingRecipe} - /> + {isAuthenticated && ( + { + setDrawerOpen(false); + setEditingRecipe(null); + }} + onSubmit={handleFormSubmit} + editingRecipe={editingRecipe} + currentUser={user} + /> + )} { + if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) { + return window.__ENV__.API_BASE; + } + return "/api"; +}; + +const API_BASE = getApiBase(); + +export async function register(username, email, password) { + const res = await fetch(`${API_BASE}/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, email, password }), + }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Failed to register"); + } + return res.json(); +} + +export async function login(username, password) { + const res = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Failed to login"); + } + return res.json(); +} + +export async function getMe(token) { + const res = await fetch(`${API_BASE}/auth/me`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) { + throw new Error("Failed to get user info"); + } + return res.json(); +} + +// Auth helpers +export function saveToken(token) { + localStorage.setItem("auth_token", token); +} + +export function getToken() { + return localStorage.getItem("auth_token"); +} + +export function removeToken() { + localStorage.removeItem("auth_token"); +} diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx new file mode 100644 index 0000000..ec3618b --- /dev/null +++ b/frontend/src/components/Login.jsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { login, saveToken } from "../authApi"; + +function Login({ onSuccess, onSwitchToRegister }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const data = await login(username, password); + saveToken(data.access_token); + onSuccess(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

התחברות

+

ברוכים השבים למתכונים שלכם

+ +
+ {error &&
{error}
} + +
+ + setUsername(e.target.value)} + required + placeholder="הזן שם משתמש" + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="הזן סיסמה" + autoComplete="current-password" + /> +
+ + +
+ +
+

+ עדיין אין לך חשבון?{" "} + +

+
+
+
+ ); +} + +export default Login; diff --git a/frontend/src/components/RecipeDetails.jsx b/frontend/src/components/RecipeDetails.jsx index 5b1c987..4df5323 100644 --- a/frontend/src/components/RecipeDetails.jsx +++ b/frontend/src/components/RecipeDetails.jsx @@ -1,6 +1,6 @@ import placeholderImage from "../assets/placeholder.svg"; -function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) { +function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser }) { if (!recipe) { return (
@@ -13,6 +13,15 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal } onShowDeleteModal(recipe.id, recipe.name); }; + // Debug ownership check + console.log('Recipe ownership check:', { + recipeUserId: recipe.user_id, + recipeUserIdType: typeof recipe.user_id, + currentUserId: currentUser?.id, + currentUserIdType: typeof currentUser?.id, + isEqual: recipe.user_id === currentUser?.id + }); + return (
{/* Recipe Image */} @@ -66,14 +75,16 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal } )} -
- - -
+ {isAuthenticated && currentUser && Number(recipe.user_id) === Number(currentUser.id) && ( +
+ + +
+ )}
); } diff --git a/frontend/src/components/RecipeFormDrawer.jsx b/frontend/src/components/RecipeFormDrawer.jsx index 341fa49..860a5d7 100644 --- a/frontend/src/components/RecipeFormDrawer.jsx +++ b/frontend/src/components/RecipeFormDrawer.jsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; -function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { +function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, currentUser = null }) { const [name, setName] = useState(""); const [mealType, setMealType] = useState("lunch"); const [timeMinutes, setTimeMinutes] = useState(15); @@ -10,6 +10,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { const [ingredients, setIngredients] = useState([""]); const [steps, setSteps] = useState([""]); + + const lastIngredientRef = useRef(null); + const lastStepRef = useRef(null); const isEditMode = !!editingRecipe; @@ -20,7 +23,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { setMealType(editingRecipe.meal_type || "lunch"); setTimeMinutes(editingRecipe.time_minutes || 15); setMadeBy(editingRecipe.made_by || ""); - setTags((editingRecipe.tags || []).join(", ")); + setTags((editingRecipe.tags || []).join(" ")); setImage(editingRecipe.image || ""); setIngredients(editingRecipe.ingredients || [""]); setSteps(editingRecipe.steps || [""]); @@ -28,19 +31,23 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { setName(""); setMealType("lunch"); setTimeMinutes(15); - setMadeBy(""); + setMadeBy(currentUser?.username || ""); setTags(""); setImage(""); setIngredients([""]); setSteps([""]); } } - }, [open, editingRecipe, isEditMode]); + }, [open, editingRecipe, isEditMode, currentUser]); if (!open) return null; const handleAddIngredient = () => { setIngredients((prev) => [...prev, ""]); + setTimeout(() => { + lastIngredientRef.current?.focus(); + lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 0); }; const handleChangeIngredient = (idx, value) => { @@ -53,6 +60,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { const handleAddStep = () => { setSteps((prev) => [...prev, ""]); + setTimeout(() => { + lastStepRef.current?.focus(); + lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 0); }; const handleChangeStep = (idx, value) => { @@ -84,7 +95,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean); const cleanSteps = steps.map((s) => s.trim()).filter(Boolean); const tagsArr = tags - .split(",") + .split(" ") .map((t) => t.trim()) .filter(Boolean); @@ -95,12 +106,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { tags: tagsArr, ingredients: cleanIngredients, steps: cleanSteps, + made_by: madeBy.trim() || currentUser?.username || "", }; - if (madeBy.trim()) { - payload.made_by = madeBy.trim(); - } - if (image) { payload.image = image; } @@ -191,11 +199,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
- + setTags(e.target.value)} - placeholder="מהיר, טבעוני, משפחתי..." + placeholder="מהיר טבעוני משפחתי..." />
@@ -205,6 +213,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { {ingredients.map((val, idx) => (
handleChangeIngredient(idx, e.target.value)} placeholder="למשל: 2 ביצים" @@ -234,6 +243,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { {steps.map((val, idx) => (
handleChangeStep(idx, e.target.value)} placeholder="למשל: לחמם את התנור ל־180 מעלות" diff --git a/frontend/src/components/Register.jsx b/frontend/src/components/Register.jsx new file mode 100644 index 0000000..01d197f --- /dev/null +++ b/frontend/src/components/Register.jsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import { register } from "../authApi"; + +function Register({ onSuccess, onSwitchToLogin }) { + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + + // Validation + if (password !== confirmPassword) { + setError("הסיסמאות אינן תואמות"); + return; + } + + if (password.length < 6) { + setError("הסיסמה חייבת להכיל לפחות 6 תווים"); + return; + } + + setLoading(true); + + try { + await register(username, email, password); + // After successful registration, switch to login + onSwitchToLogin(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

הרשמה

+

צור חשבון חדש והתחל לנהל את המתכונים שלך

+ +
+ {error &&
{error}
} + +
+ + setUsername(e.target.value)} + required + placeholder="בחר שם משתמש" + autoComplete="username" + minLength={3} + /> +
+ +
+ + setEmail(e.target.value)} + required + placeholder="your@email.com" + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="בחר סיסמה חזקה" + autoComplete="new-password" + minLength={6} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + placeholder="הזן סיסמה שוב" + autoComplete="new-password" + minLength={6} + /> +
+ + +
+ +
+

+ כבר יש לך חשבון?{" "} + +

+
+
+
+ ); +} + +export default Register; diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index a9ac13e..37f1d06 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -1,4 +1,4 @@ -function TopBar({ onAddClick }) { +function TopBar({ onAddClick, user, onLogout }) { return (
@@ -12,9 +12,16 @@ function TopBar({ onAddClick }) {
- + {user && ( + + )} + {onLogout && ( + + )}
);