Add google authenticate
This commit is contained in:
parent
01369a743d
commit
b70411e1f1
13
backend/.env
13
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
|
||||
BIN
backend/__pycache__/auth_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/auth_utils.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/email_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/email_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/grocery_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/grocery_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/notification_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/notification_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/oauth_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/oauth_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/user_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/user_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
106
backend/email_utils.py
Normal file
106
backend/email_utils.py
Normal file
@ -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"""
|
||||
<html dir="rtl">
|
||||
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
|
||||
<h2>שינוי סיסמה</h2>
|
||||
<p>קוד האימות שלך הוא:</p>
|
||||
<h1 style="color: #22c55e; font-size: 32px; letter-spacing: 5px;">{code}</h1>
|
||||
<p>הקוד תקף ל-<strong>10 דקות</strong>.</p>
|
||||
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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
|
||||
184
backend/main.py
184
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)
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True)
|
||||
20
backend/oauth_utils.py
Normal file
20
backend/oauth_utils.py
Normal file
@ -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'
|
||||
}
|
||||
)
|
||||
@ -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
|
||||
|
||||
41
backend/reset_admin_password.py
Normal file
41
backend/reset_admin_password.py
Normal file
@ -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()
|
||||
@ -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()
|
||||
|
||||
178
demo-recipes.sql
Normal file
178
demo-recipes.sql
Normal file
@ -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
|
||||
);
|
||||
@ -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() {
|
||||
</div>
|
||||
</header>
|
||||
) : (
|
||||
<TopBar onAddClick={() => setDrawerOpen(true)} user={user} onLogout={handleLogout} onShowToast={addToast} />
|
||||
<TopBar
|
||||
onAddClick={() => 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 && (
|
||||
<ChangePassword
|
||||
token={getToken()}
|
||||
onClose={() => setChangePasswordModal(false)}
|
||||
onSuccess={() => {
|
||||
addToast("הסיסמה שונתה בהצלחה", "success");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
176
frontend/src/components/ChangePassword.jsx
Normal file
176
frontend/src/components/ChangePassword.jsx
Normal file
@ -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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>שינוי סיסמה</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<p style={{ marginBottom: "1rem", color: "var(--text-muted)" }}>
|
||||
קוד אימות יישלח לכתובת המייל שלך. הקוד תקף ל-10 דקות.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary full"
|
||||
onClick={handleRequestCode}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "שולח..." : "שלח קוד אימות"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{codeSent && (
|
||||
<div style={{
|
||||
padding: "0.75rem",
|
||||
background: "rgba(34, 197, 94, 0.1)",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
color: "var(--accent)"
|
||||
}}>
|
||||
✓ קוד אימות נשלח לכתובת המייל שלך
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>קוד אימות (6 ספרות)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => 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" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>סיסמה נוכחית</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>סיסמה חדשה</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>אימות סיסמה חדשה</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
ביטול
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "משנה..." : "שמור סיסמה"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
@ -61,6 +78,49 @@ function Login({ onSuccess, onSwitchToRegister }) {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{
|
||||
margin: "1rem 0",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
position: "relative"
|
||||
}}>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderTop: "1px solid var(--border-subtle)",
|
||||
zIndex: 0
|
||||
}}></div>
|
||||
<span style={{
|
||||
background: "var(--card)",
|
||||
padding: "0 1rem",
|
||||
position: "relative",
|
||||
zIndex: 1
|
||||
}}>או</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
className="btn ghost full-width"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
border: "1px solid var(--border-subtle)"
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18">
|
||||
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/>
|
||||
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/>
|
||||
<path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707 0-.593.102-1.17.282-1.709V4.958H.957C.347 6.173 0 7.548 0 9c0 1.452.348 2.827.957 4.042l3.007-2.335z"/>
|
||||
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
|
||||
</svg>
|
||||
המשך עם Google
|
||||
</button>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
עדיין אין לך חשבון?{" "}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
function TopBar({ onAddClick, user, onLogout, onShowToast }) {
|
||||
function TopBar({ onAddClick, user, onLogout, onChangePassword, onShowToast }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
@ -20,6 +20,11 @@ function TopBar({ onAddClick, user, onLogout, onShowToast }) {
|
||||
+ מתכון חדש
|
||||
</button>
|
||||
)}
|
||||
{onChangePassword && (
|
||||
<button className="btn ghost" onClick={onChangePassword}>
|
||||
🔒 שינוי סיסמה
|
||||
</button>
|
||||
)}
|
||||
{onLogout && (
|
||||
<button className="btn ghost" onClick={onLogout}>
|
||||
יציאה
|
||||
|
||||
@ -5,4 +5,8 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
assetsInclude: ['**/*.svg'],
|
||||
server: {
|
||||
port: 5174,
|
||||
// port: 5173, // Default port - uncomment to switch back
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user