commit
ba7d0c9121
13
backend/.env
13
backend/.env
@ -4,3 +4,16 @@ DB_USER=recipes_user
|
|||||||
DB_NAME=recipes_db
|
DB_NAME=recipes_db
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
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.
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
|
||||||
194
backend/main.py
194
backend/main.py
@ -2,8 +2,9 @@ import random
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import timedelta
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from pydantic import BaseModel, EmailStr, field_validator
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@ -53,6 +54,15 @@ from notification_db_utils import (
|
|||||||
delete_notification,
|
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):
|
class RecipeBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -118,6 +128,16 @@ class UserResponse(BaseModel):
|
|||||||
is_admin: bool = False
|
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
|
# Grocery List models
|
||||||
class GroceryListCreate(BaseModel):
|
class GroceryListCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -180,9 +200,17 @@ app = FastAPI(
|
|||||||
description="API פשוט לבחירת מתכונים לצהריים / ערב / כל ארוחה 😋",
|
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
|
# Allow CORS from frontend domains
|
||||||
allowed_origins = [
|
allowed_origins = [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://my-recipes.dvirlabs.com",
|
"https://my-recipes.dvirlabs.com",
|
||||||
"http://my-recipes.dvirlabs.com",
|
"http://my-recipes.dvirlabs.com",
|
||||||
@ -192,9 +220,8 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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 =============
|
# ============= Grocery Lists Endpoints =============
|
||||||
|
|
||||||
@app.get("/grocery-lists", response_model=List[GroceryList])
|
@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
|
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)
|
@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)):
|
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)"""
|
"""Toggle pin status for a grocery list (owner only)"""
|
||||||
@ -748,4 +922,4 @@ def delete_notification_endpoint(
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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
|
passlib[bcrypt]==1.7.4
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
bcrypt==4.1.2
|
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)
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
try:
|
try:
|
||||||
cur.execute(
|
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_id,)
|
||||||
)
|
)
|
||||||
user = cur.fetchone()
|
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
|
||||||
|
);
|
||||||
@ -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,
|
.sidebar,
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import ToastContainer from "./components/ToastContainer";
|
|||||||
import ThemeToggle from "./components/ThemeToggle";
|
import ThemeToggle from "./components/ThemeToggle";
|
||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import Register from "./components/Register";
|
import Register from "./components/Register";
|
||||||
|
import ChangePassword from "./components/ChangePassword";
|
||||||
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||||
import { getToken, removeToken, getMe } from "./authApi";
|
import { getToken, removeToken, getMe } from "./authApi";
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ function App() {
|
|||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
||||||
const [logoutModal, setLogoutModal] = useState(false);
|
const [logoutModal, setLogoutModal] = useState(false);
|
||||||
|
const [changePasswordModal, setChangePasswordModal] = useState(false);
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState([]);
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@ -324,7 +326,13 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 */}
|
{/* Show auth modal if needed */}
|
||||||
@ -373,45 +381,27 @@ function App() {
|
|||||||
<PinnedGroceryLists onShowToast={addToast} />
|
<PinnedGroceryLists onShowToast={addToast} />
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
<section className="sidebar">
|
<section className="content-wrapper">
|
||||||
<RecipeSearchList
|
<section className="content">
|
||||||
allRecipes={recipes}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
recipes={getFilteredRecipes()}
|
|
||||||
selectedId={selectedRecipe?.id}
|
|
||||||
onSelect={setSelectedRecipe}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
filterMealType={filterMealType}
|
|
||||||
onMealTypeChange={setFilterMealType}
|
|
||||||
filterMaxTime={filterMaxTime}
|
|
||||||
onMaxTimeChange={setFilterMaxTime}
|
|
||||||
filterTags={filterTags}
|
|
||||||
onTagsChange={setFilterTags}
|
|
||||||
filterOwner={filterOwner}
|
|
||||||
onOwnerChange={setFilterOwner}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="content">
|
{/* Random Recipe Suggester - Top Left */}
|
||||||
{error && <div className="error-banner">{error}</div>}
|
<section className="panel filter-panel">
|
||||||
|
<h3>חיפוש מתכון רנדומלי</h3>
|
||||||
{/* Random Recipe Suggester - Top Left */}
|
<div className="panel-grid">
|
||||||
<section className="panel filter-panel">
|
<div className="field">
|
||||||
<h3>חיפוש מתכון רנדומלי</h3>
|
<label>סוג ארוחה</label>
|
||||||
<div className="panel-grid">
|
<select
|
||||||
<div className="field">
|
value={mealTypeFilter}
|
||||||
<label>סוג ארוחה</label>
|
onChange={(e) => setMealTypeFilter(e.target.value)}
|
||||||
<select
|
>
|
||||||
value={mealTypeFilter}
|
<option value="">לא משנה</option>
|
||||||
onChange={(e) => setMealTypeFilter(e.target.value)}
|
<option value="breakfast">בוקר</option>
|
||||||
>
|
<option value="lunch">צהריים</option>
|
||||||
<option value="">לא משנה</option>
|
<option value="dinner">ערב</option>
|
||||||
<option value="breakfast">בוקר</option>
|
<option value="snack">קינוחים</option>
|
||||||
<option value="lunch">צהריים</option>
|
</select>
|
||||||
<option value="dinner">ערב</option>
|
</div>
|
||||||
<option value="snack">קינוחים</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>זמן מקסימלי (דקות)</label>
|
<label>זמן מקסימלי (דקות)</label>
|
||||||
@ -452,6 +442,26 @@ function App() {
|
|||||||
currentUser={user}
|
currentUser={user}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="sidebar">
|
||||||
|
<RecipeSearchList
|
||||||
|
allRecipes={recipes}
|
||||||
|
recipes={getFilteredRecipes()}
|
||||||
|
selectedId={selectedRecipe?.id}
|
||||||
|
onSelect={setSelectedRecipe}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
filterMealType={filterMealType}
|
||||||
|
onMealTypeChange={setFilterMealType}
|
||||||
|
filterMaxTime={filterMaxTime}
|
||||||
|
onMaxTimeChange={setFilterMaxTime}
|
||||||
|
filterTags={filterTags}
|
||||||
|
onTagsChange={setFilterTags}
|
||||||
|
filterOwner={filterOwner}
|
||||||
|
onOwnerChange={setFilterOwner}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@ -491,6 +501,16 @@ function App() {
|
|||||||
onCancel={() => setLogoutModal(false)}
|
onCancel={() => setLogoutModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{changePasswordModal && (
|
||||||
|
<ChangePassword
|
||||||
|
token={getToken()}
|
||||||
|
onClose={() => setChangePasswordModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
addToast("הסיסמה שונתה בהצלחה", "success");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -53,6 +53,40 @@ export async function getMe(token) {
|
|||||||
return res.json();
|
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
|
// Auth helpers
|
||||||
export function saveToken(token) {
|
export function saveToken(token) {
|
||||||
localStorage.setItem("auth_token", 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";
|
import { login, saveToken } from "../authApi";
|
||||||
|
|
||||||
function Login({ onSuccess, onSwitchToRegister }) {
|
function Login({ onSuccess, onSwitchToRegister }) {
|
||||||
@ -7,6 +7,18 @@ function Login({ onSuccess, onSwitchToRegister }) {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
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 (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-container">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
@ -61,6 +78,49 @@ function Login({ onSuccess, onSwitchToRegister }) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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">
|
<div className="auth-footer">
|
||||||
<p>
|
<p>
|
||||||
עדיין אין לך חשבון?{" "}
|
עדיין אין לך חשבון?{" "}
|
||||||
|
|||||||
@ -221,13 +221,15 @@ function NotificationBell({ onShowToast }) {
|
|||||||
width: 420px;
|
width: 420px;
|
||||||
max-height: 550px;
|
max-height: 550px;
|
||||||
background: var(--panel-bg);
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
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;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
opacity: 0.98;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-header {
|
.notification-header {
|
||||||
|
|||||||
@ -55,7 +55,7 @@ function PinnedGroceryLists({ onShowToast }) {
|
|||||||
<h3 className="note-title">{list.name}</h3>
|
<h3 className="note-title">{list.name}</h3>
|
||||||
<ul className="note-items">
|
<ul className="note-items">
|
||||||
{list.items.length === 0 ? (
|
{list.items.length === 0 ? (
|
||||||
<li className="empty-note">רשימה ריקה</li>
|
<li className="empty-note">הרשימה ריקה</li>
|
||||||
) : (
|
) : (
|
||||||
list.items.map((item, index) => {
|
list.items.map((item, index) => {
|
||||||
const isChecked = item.startsWith("✓ ");
|
const isChecked = item.startsWith("✓ ");
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import NotificationBell from "./NotificationBell";
|
import NotificationBell from "./NotificationBell";
|
||||||
|
|
||||||
function TopBar({ onAddClick, user, onLogout, onShowToast }) {
|
function TopBar({ onAddClick, user, onLogout, onChangePassword, onShowToast }) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
@ -20,6 +20,11 @@ function TopBar({ onAddClick, user, onLogout, onShowToast }) {
|
|||||||
+ מתכון חדש
|
+ מתכון חדש
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onChangePassword && (
|
||||||
|
<button className="btn ghost" onClick={onChangePassword}>
|
||||||
|
🔒 שינוי סיסמה
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<button className="btn ghost" onClick={onLogout}>
|
<button className="btn ghost" onClick={onLogout}>
|
||||||
יציאה
|
יציאה
|
||||||
|
|||||||
@ -71,10 +71,7 @@ export const deleteGroceryList = async (id) => {
|
|||||||
export const togglePinGroceryList = async (id) => {
|
export const togglePinGroceryList = async (id) => {
|
||||||
const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, {
|
const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: getAuthHeaders(),
|
||||||
...getAuthHeaders(),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let errorMessage = "Failed to toggle pin status";
|
let errorMessage = "Failed to toggle pin status";
|
||||||
|
|||||||
@ -5,4 +5,8 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
assetsInclude: ['**/*.svg'],
|
assetsInclude: ['**/*.svg'],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
// port: 5173, // Default port - uncomment to switch back
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user