Merge pull request 'test' (#3) from test into develop

Reviewed-on: #3
This commit is contained in:
dvirlabs 2025-12-14 04:05:38 +00:00
commit ba7d0c9121
27 changed files with 908 additions and 58 deletions

View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

106
backend/email_utils.py Normal file
View 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

View File

@ -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)
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True)

20
backend/oauth_utils.py Normal file
View 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'
}
)

View File

@ -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

View 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()

View File

@ -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
View 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
);

View File

@ -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;

View File

@ -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 */}
@ -373,45 +381,27 @@ function App() {
<PinnedGroceryLists onShowToast={addToast} />
</aside>
)}
<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 className="content-wrapper">
<section className="content">
{error && <div className="error-banner">{error}</div>}
<section className="content">
{error && <div className="error-banner">{error}</div>}
{/* Random Recipe Suggester - Top Left */}
<section className="panel filter-panel">
<h3>חיפוש מתכון רנדומלי</h3>
<div className="panel-grid">
<div className="field">
<label>סוג ארוחה</label>
<select
value={mealTypeFilter}
onChange={(e) => setMealTypeFilter(e.target.value)}
>
<option value="">לא משנה</option>
<option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option>
<option value="dinner">ערב</option>
<option value="snack">קינוחים</option>
</select>
</div>
{/* Random Recipe Suggester - Top Left */}
<section className="panel filter-panel">
<h3>חיפוש מתכון רנדומלי</h3>
<div className="panel-grid">
<div className="field">
<label>סוג ארוחה</label>
<select
value={mealTypeFilter}
onChange={(e) => setMealTypeFilter(e.target.value)}
>
<option value="">לא משנה</option>
<option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option>
<option value="dinner">ערב</option>
<option value="snack">קינוחים</option>
</select>
</div>
<div className="field">
<label>זמן מקסימלי (דקות)</label>
@ -452,6 +442,26 @@ function App() {
currentUser={user}
/>
</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>
@ -491,6 +501,16 @@ function App() {
onCancel={() => setLogoutModal(false)}
/>
{changePasswordModal && (
<ChangePassword
token={getToken()}
onClose={() => setChangePasswordModal(false)}
onSuccess={() => {
addToast("הסיסמה שונתה בהצלחה", "success");
}}
/>
)}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);

View File

@ -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);

View 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>
);
}

View File

@ -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>
עדיין אין לך חשבון?{" "}

View File

@ -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 {

View File

@ -55,7 +55,7 @@ function PinnedGroceryLists({ onShowToast }) {
<h3 className="note-title">{list.name}</h3>
<ul className="note-items">
{list.items.length === 0 ? (
<li className="empty-note">רשימה ריקה</li>
<li className="empty-note">הרשימה ריקה</li>
) : (
list.items.map((item, index) => {
const isChecked = item.startsWith("✓ ");

View File

@ -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}>
יציאה

View File

@ -71,10 +71,7 @@ export const deleteGroceryList = async (id) => {
export const togglePinGroceryList = async (id) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, {
method: "PATCH",
headers: {
...getAuthHeaders(),
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
});
if (!res.ok) {
let errorMessage = "Failed to toggle pin status";

View File

@ -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
},
})