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 0000000..681b898 Binary files /dev/null and b/backend/__pycache__/auth_utils.cpython-312.pyc differ diff --git a/backend/__pycache__/db_utils.cpython-312.pyc b/backend/__pycache__/db_utils.cpython-312.pyc index e7a1dfa..49e25cf 100644 Binary files a/backend/__pycache__/db_utils.cpython-312.pyc and b/backend/__pycache__/db_utils.cpython-312.pyc differ diff --git a/backend/__pycache__/email_utils.cpython-312.pyc b/backend/__pycache__/email_utils.cpython-312.pyc new file mode 100644 index 0000000..519a5fc Binary files /dev/null and b/backend/__pycache__/email_utils.cpython-312.pyc differ 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 0000000..6a65603 Binary files /dev/null and b/backend/__pycache__/grocery_db_utils.cpython-312.pyc differ diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc index f3ea2d5..ac62b4a 100644 Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index 1aac91c..fe77bd0 100644 Binary files a/backend/__pycache__/main.cpython-313.pyc and b/backend/__pycache__/main.cpython-313.pyc differ 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 0000000..2722871 Binary files /dev/null and b/backend/__pycache__/notification_db_utils.cpython-312.pyc differ diff --git a/backend/__pycache__/oauth_utils.cpython-312.pyc b/backend/__pycache__/oauth_utils.cpython-312.pyc new file mode 100644 index 0000000..6ba3367 Binary files /dev/null and b/backend/__pycache__/oauth_utils.cpython-312.pyc differ 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 0000000..2b2f41f Binary files /dev/null and b/backend/__pycache__/user_db_utils.cpython-312.pyc differ diff --git a/backend/email_utils.py b/backend/email_utils.py new file mode 100644 index 0000000..e721713 --- /dev/null +++ b/backend/email_utils.py @@ -0,0 +1,106 @@ +import os +import random +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime, timedelta +from dotenv import load_dotenv + +load_dotenv() + +# In-memory storage for verification codes (in production, use Redis or database) +verification_codes = {} + +def generate_verification_code(): + """Generate a 6-digit verification code""" + return str(random.randint(100000, 999999)) + +async def send_verification_email(email: str, code: str, purpose: str = "password_change"): + """Send verification code via email""" + smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com") + smtp_port = int(os.getenv("SMTP_PORT", "587")) + smtp_user = os.getenv("SMTP_USER") + smtp_password = os.getenv("SMTP_PASSWORD") + smtp_from = os.getenv("SMTP_FROM", smtp_user) + + if not smtp_user or not smtp_password: + raise Exception("SMTP credentials not configured") + + # Create message + message = MIMEMultipart("alternative") + message["Subject"] = "קוד אימות - מתכונים שלי" + message["From"] = smtp_from + message["To"] = email + + # Email content + if purpose == "password_change": + text = f""" + שלום, + + קוד האימות שלך לשינוי סיסמה הוא: {code} + + הקוד תקף ל-10 דקות. + + אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו. + + בברכה, + צוות מתכונים שלי + """ + + html = f""" + + +

שינוי סיסמה

+

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

+

{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 99fee42..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,9 +200,17 @@ 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", + "http://localhost:5174", "http://localhost:3000", "https://my-recipes.dvirlabs.com", "http://my-recipes.dvirlabs.com", @@ -192,9 +220,8 @@ app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_methods=["*"], allow_headers=["*"], - max_age=0, # Disable CORS preflight caching ) @@ -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]) @@ -551,12 +731,6 @@ def delete_grocery_list_endpoint(list_id: int, current_user: dict = Depends(get_ return -@app.options("/grocery-lists/{list_id}/pin") -async def options_pin_grocery_list(list_id: int): - """Handle CORS preflight for pin endpoint""" - return Response(status_code=200) - - @app.patch("/grocery-lists/{list_id}/pin", response_model=GroceryList) def toggle_pin_grocery_list_endpoint(list_id: int, current_user: dict = Depends(get_current_user)): """Toggle pin status for a grocery list (owner only)""" @@ -748,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.css b/frontend/src/App.css index 6040486..9bea9c0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -124,6 +124,18 @@ body { } } +.content-wrapper { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.4rem; +} + +@media (min-width: 960px) { + .content-wrapper { + display: contents; + } +} + .sidebar, .content { display: flex; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5d890ee..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 */} @@ -373,45 +381,27 @@ function App() { )} -
- -
+
+
+ {error &&
{error}
} -
- {error &&
{error}
} - - {/* Random Recipe Suggester - Top Left */} -
-

חיפוש מתכון רנדומלי

-
-
- - -
+ {/* Random Recipe Suggester - Top Left */} +
+

חיפוש מתכון רנדומלי

+
+
+ + +
@@ -452,6 +442,26 @@ function App() { currentUser={user} />
+ +
+ +
+
)} @@ -491,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/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx index 7ab893f..493f164 100644 --- a/frontend/src/components/NotificationBell.jsx +++ b/frontend/src/components/NotificationBell.jsx @@ -221,13 +221,15 @@ function NotificationBell({ onShowToast }) { width: 420px; max-height: 550px; background: var(--panel-bg); + backdrop-filter: blur(10px); border: 1px solid var(--border-color); border-radius: 16px; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); z-index: 1000; display: flex; flex-direction: column; overflow: hidden; + opacity: 0.98; } .notification-header { diff --git a/frontend/src/components/PinnedGroceryLists.jsx b/frontend/src/components/PinnedGroceryLists.jsx index 9ca5c29..c6abf14 100644 --- a/frontend/src/components/PinnedGroceryLists.jsx +++ b/frontend/src/components/PinnedGroceryLists.jsx @@ -55,7 +55,7 @@ function PinnedGroceryLists({ onShowToast }) {

{list.name}

    {list.items.length === 0 ? ( -
  • רשימה ריקה
  • +
  • הרשימה ריקה
  • ) : ( list.items.map((item, index) => { const isChecked = item.startsWith("✓ "); 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 && (