diff --git a/backend/__pycache__/auth_utils.cpython-313.pyc b/backend/__pycache__/auth_utils.cpython-313.pyc new file mode 100644 index 0000000..0e56bed Binary files /dev/null and b/backend/__pycache__/auth_utils.cpython-313.pyc differ diff --git a/backend/__pycache__/db_utils.cpython-313.pyc b/backend/__pycache__/db_utils.cpython-313.pyc index dfc8c39..752c851 100644 Binary files a/backend/__pycache__/db_utils.cpython-313.pyc and b/backend/__pycache__/db_utils.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index 225a930..735674e 100644 Binary files a/backend/__pycache__/main.cpython-313.pyc and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/__pycache__/user_db_utils.cpython-313.pyc b/backend/__pycache__/user_db_utils.cpython-313.pyc new file mode 100644 index 0000000..e6126ee Binary files /dev/null and b/backend/__pycache__/user_db_utils.cpython-313.pyc differ diff --git a/backend/auth_utils.py b/backend/auth_utils.py new file mode 100644 index 0000000..0168ac9 --- /dev/null +++ b/backend/auth_utils.py @@ -0,0 +1,80 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +import bcrypt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import os + +# Secret key for JWT (use environment variable in production) +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days + +security = HTTPBearer() + + +def hash_password(password: str) -> str: + """Hash a password for storing.""" + # Bcrypt has a 72 byte limit, truncate if necessary + password_bytes = password.encode('utf-8')[:72] + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password_bytes, salt) + return hashed.decode('utf-8') + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a stored password against one provided by user""" + # Bcrypt has a 72 byte limit, truncate if necessary + password_bytes = plain_password.encode('utf-8')[:72] + hashed_bytes = hashed_password.encode('utf-8') + return bcrypt.checkpw(password_bytes, hashed_bytes) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Create JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> dict: + """Decode and verify JWT token""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict: + """Get current user from JWT token (for protected routes)""" + token = credentials.credentials + payload = decode_token(token) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + ) + return {"user_id": int(user_id), "username": payload.get("username")} + + +# Optional dependency - returns None if no token provided +def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[dict]: + """Get current user if authenticated, otherwise None""" + if not credentials: + return None + try: + return get_current_user(credentials) + except HTTPException: + return None diff --git a/backend/db_utils.py b/backend/db_utils.py index c5ba8ba..a8f6141 100644 --- a/backend/db_utils.py +++ b/backend/db_utils.py @@ -55,7 +55,7 @@ def list_recipes_db() -> List[Dict[str, Any]]: cur.execute( """ SELECT id, name, meal_type, time_minutes, - tags, ingredients, steps, image, made_by + tags, ingredients, steps, image, made_by, user_id FROM recipes ORDER BY id """ @@ -85,7 +85,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di image = %s, made_by = %s WHERE id = %s - RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by + RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id """, ( recipe_data["name"], @@ -133,9 +133,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]: with conn.cursor() as cur: cur.execute( """ - INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by + INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id """, ( recipe_data["name"], @@ -146,6 +146,7 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]: json.dumps(recipe_data.get("steps", [])), recipe_data.get("image"), recipe_data.get("made_by"), + recipe_data.get("user_id"), ), ) row = cur.fetchone() @@ -163,7 +164,7 @@ def get_recipes_by_filters_db( try: query = """ SELECT id, name, meal_type, time_minutes, - tags, ingredients, steps, image, made_by + tags, ingredients, steps, image, made_by, user_id FROM recipes WHERE 1=1 """ diff --git a/backend/main.py b/backend/main.py index 14e3183..e6a755e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,10 @@ import random from typing import List, Optional +from datetime import timedelta -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, Depends from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr import os import uvicorn @@ -14,6 +15,21 @@ from db_utils import ( get_recipes_by_filters_db, update_recipe_db, delete_recipe_db, + get_conn, +) + +from auth_utils import ( + hash_password, + verify_password, + create_access_token, + get_current_user, + ACCESS_TOKEN_EXPIRE_MINUTES, +) + +from user_db_utils import ( + create_user, + get_user_by_username, + get_user_by_email, ) @@ -34,12 +50,35 @@ class RecipeCreate(RecipeBase): class Recipe(RecipeBase): id: int + user_id: Optional[int] = None # Recipe owner ID class RecipeUpdate(RecipeBase): pass +# User models +class UserRegister(BaseModel): + username: str + email: EmailStr + password: str + + +class UserLogin(BaseModel): + username: str + password: str + + +class Token(BaseModel): + access_token: str + token_type: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + app = FastAPI( title="Random Recipes API", @@ -77,6 +116,7 @@ def list_recipes(): ingredients=r["ingredients"] or [], steps=r["steps"] or [], image=r.get("image"), + user_id=r.get("user_id"), ) for r in rows ] @@ -84,9 +124,10 @@ def list_recipes(): @app.post("/recipes", response_model=Recipe, status_code=201) -def create_recipe(recipe_in: RecipeCreate): +def create_recipe(recipe_in: RecipeCreate, current_user: dict = Depends(get_current_user)): data = recipe_in.dict() data["meal_type"] = data["meal_type"].lower() + data["user_id"] = current_user["user_id"] row = create_recipe_db(data) @@ -100,10 +141,24 @@ def create_recipe(recipe_in: RecipeCreate): ingredients=row["ingredients"] or [], steps=row["steps"] or [], image=row.get("image"), + user_id=row.get("user_id"), ) @app.put("/recipes/{recipe_id}", response_model=Recipe) -def update_recipe(recipe_id: int, recipe_in: RecipeUpdate): +def update_recipe(recipe_id: int, recipe_in: RecipeUpdate, current_user: dict = Depends(get_current_user)): + # Check ownership BEFORE updating + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute("SELECT user_id FROM recipes WHERE id = %s", (recipe_id,)) + recipe = cur.fetchone() + if not recipe: + raise HTTPException(status_code=404, detail="המתכון לא נמצא") + if recipe["user_id"] != current_user["user_id"]: + raise HTTPException(status_code=403, detail="אין לך הרשאה לערוך מתכון זה") + finally: + conn.close() + data = recipe_in.dict() data["meal_type"] = data["meal_type"].lower() @@ -121,11 +176,25 @@ def update_recipe(recipe_id: int, recipe_in: RecipeUpdate): ingredients=row["ingredients"] or [], steps=row["steps"] or [], image=row.get("image"), + user_id=row.get("user_id"), ) @app.delete("/recipes/{recipe_id}", status_code=204) -def delete_recipe(recipe_id: int): +def delete_recipe(recipe_id: int, current_user: dict = Depends(get_current_user)): + # Get recipe first to check ownership + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute("SELECT user_id FROM recipes WHERE id = %s", (recipe_id,)) + recipe = cur.fetchone() + if not recipe: + raise HTTPException(status_code=404, detail="המתכון לא נמצא") + if recipe["user_id"] != current_user["user_id"]: + raise HTTPException(status_code=403, detail="אין לך הרשאה למחוק מתכון זה") + finally: + conn.close() + deleted = delete_recipe_db(recipe_id) if not deleted: raise HTTPException(status_code=404, detail="המתכון לא נמצא") @@ -154,6 +223,7 @@ def random_recipe( ingredients=r["ingredients"] or [], steps=r["steps"] or [], image=r.get("image"), + user_id=r.get("user_id"), ) for r in rows ] @@ -177,6 +247,88 @@ def random_recipe( return random.choice(recipes) +# Authentication endpoints +@app.post("/auth/register", response_model=UserResponse, status_code=201) +def register(user: UserRegister): + """Register a new user""" + print(f"[REGISTER] Starting registration for username: {user.username}") + + # Check if username already exists + print(f"[REGISTER] Checking if username exists...") + existing_user = get_user_by_username(user.username) + if existing_user: + print(f"[REGISTER] Username already exists") + raise HTTPException( + status_code=400, + detail="שם המשתמש כבר קיים במערכת" + ) + + # Check if email already exists + print(f"[REGISTER] Checking if email exists...") + existing_email = get_user_by_email(user.email) + if existing_email: + print(f"[REGISTER] Email already exists") + raise HTTPException( + status_code=400, + detail="האימייל כבר רשום במערכת" + ) + + # Hash password and create user + print(f"[REGISTER] Hashing password...") + password_hash = hash_password(user.password) + print(f"[REGISTER] Creating user in database...") + new_user = create_user(user.username, user.email, password_hash) + print(f"[REGISTER] User created successfully: {new_user['id']}") + + return UserResponse( + id=new_user["id"], + username=new_user["username"], + email=new_user["email"] + ) + + +@app.post("/auth/login", response_model=Token) +def login(user: UserLogin): + """Login user and return JWT token""" + # Get user from database + db_user = get_user_by_username(user.username) + if not db_user: + raise HTTPException( + status_code=401, + detail="שם משתמש או סיסמה שגויים" + ) + + # Verify password + if not verify_password(user.password, db_user["password_hash"]): + raise HTTPException( + status_code=401, + detail="שם משתמש או סיסמה שגויים" + ) + + # Create access token + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": str(db_user["id"]), "username": db_user["username"]}, + expires_delta=access_token_expires + ) + + return Token(access_token=access_token, token_type="bearer") + + +@app.get("/auth/me", response_model=UserResponse) +def get_me(current_user: dict = Depends(get_current_user)): + """Get current logged-in user info""" + from user_db_utils import get_user_by_id + user = get_user_by_id(current_user["user_id"]) + if not user: + raise HTTPException(status_code=404, detail="משתמש לא נמצא") + + return UserResponse( + id=user["id"], + username=user["username"], + email=user["email"] + ) + + if __name__ == "__main__": - uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) - \ No newline at end of file + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 8933c6e..6d77712 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,8 @@ pydantic==2.7.4 python-dotenv==1.0.1 psycopg2-binary==2.9.9 + +# Authentication +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 diff --git a/backend/schema.sql b/backend/schema.sql index aa7547c..e644fd6 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -1,3 +1,15 @@ +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + -- Create recipes table CREATE TABLE IF NOT EXISTS recipes ( id SERIAL PRIMARY KEY, @@ -8,7 +20,9 @@ CREATE TABLE IF NOT EXISTS recipes ( tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"] ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"] steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...] - image TEXT -- Base64-encoded image or image URL + image TEXT, -- Base64-encoded image or image URL + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Optional: index for filters diff --git a/backend/user_db_utils.py b/backend/user_db_utils.py new file mode 100644 index 0000000..fd37d36 --- /dev/null +++ b/backend/user_db_utils.py @@ -0,0 +1,83 @@ +import os +import psycopg2 +from psycopg2.extras import RealDictCursor + + +def get_db_connection(): + """Get database connection""" + return psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "recipes_db"), + user=os.getenv("DB_USER", "recipes_user"), + password=os.getenv("DB_PASSWORD", "recipes_password"), + ) + + +def create_user(username: str, email: str, password_hash: str): + """Create a new user""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + """ + INSERT INTO users (username, email, password_hash) + VALUES (%s, %s, %s) + RETURNING id, username, email, created_at + """, + (username, email, password_hash) + ) + user = cur.fetchone() + conn.commit() + return dict(user) + finally: + cur.close() + conn.close() + + +def get_user_by_username(username: str): + """Get user by username""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT id, username, email, password_hash, created_at FROM users WHERE username = %s", + (username,) + ) + user = cur.fetchone() + return dict(user) if user else None + finally: + cur.close() + conn.close() + + +def get_user_by_email(email: str): + """Get user by email""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT id, username, email, password_hash, created_at FROM users WHERE email = %s", + (email,) + ) + user = cur.fetchone() + return dict(user) if user else None + finally: + cur.close() + conn.close() + + +def get_user_by_id(user_id: int): + """Get user by ID""" + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + cur.execute( + "SELECT id, username, email, created_at FROM users WHERE id = %s", + (user_id,) + ) + user = cur.fetchone() + return dict(user) if user else None + finally: + cur.close() + conn.close() diff --git a/frontend/src/App.css b/frontend/src/App.css index 9776687..4780b9d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -32,11 +32,21 @@ body { justify-content: center; align-items: flex-start; } + +.user-greeting-header { + text-align: center; + padding: 0.5rem 1rem; + font-size: 1.3rem; + font-weight: 600; + color: var(--text-main); +} + .app-root { min-height: 100vh; max-width: 1200px; margin: 0 auto; padding: 1.5rem; + padding-top: 4.5rem; /* Add space for fixed theme toggle */ direction: rtl; } @@ -74,6 +84,15 @@ body { color: var(--text-muted); } +.user-greeting { + font-size: 1.1rem; + font-weight: 600; + color: var(--accent); + padding: 0.5rem 1rem; + background: rgba(79, 70, 229, 0.1); + border-radius: 8px; +} + /* Layout */ .layout { @@ -640,7 +659,7 @@ select { .toast-container { position: fixed; - bottom: 1.5rem; + bottom: 5rem; right: 1.5rem; z-index: 60; display: flex; @@ -1264,4 +1283,69 @@ html { [data-theme="light"] .recipe-list-image { background: rgba(229, 231, 235, 0.5); -} \ No newline at end of file +} + +/* Auth Pages */ +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: var(--bg); +} + +.auth-card { + width: 100%; + max-width: 420px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); +} + +.auth-title { + font-size: 2rem; + font-weight: 800; + margin-bottom: 0.5rem; + text-align: center; + color: var(--text-main); +} + +.auth-subtitle { + text-align: center; + color: var(--text-muted); + margin-bottom: 2rem; + font-size: 0.95rem; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-footer { + margin-top: 1.5rem; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; +} + +.link-btn { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + text-decoration: underline; + font-weight: 600; +} + +.link-btn:hover { + color: var(--accent-hover); +} + +.full-width { + width: 100%; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b1c403c..5948a98 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,9 +8,17 @@ import RecipeFormDrawer from "./components/RecipeFormDrawer"; import Modal from "./components/Modal"; import ToastContainer from "./components/ToastContainer"; import ThemeToggle from "./components/ThemeToggle"; +import Login from "./components/Login"; +import Register from "./components/Register"; import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api"; +import { getToken, removeToken, getMe } from "./authApi"; function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + const [authView, setAuthView] = useState("login"); // "login" or "register" + const [loadingAuth, setLoadingAuth] = useState(true); + const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); @@ -42,6 +50,27 @@ function App() { } }); + // Check authentication on mount + useEffect(() => { + const checkAuth = async () => { + const token = getToken(); + if (token) { + try { + const userData = await getMe(token); + setUser(userData); + setIsAuthenticated(true); + } catch (err) { + // Token invalid or expired + removeToken(); + setIsAuthenticated(false); + } + } + setLoadingAuth(false); + }; + checkAuth(); + }, []); + + // Load recipes for everyone (readonly for non-authenticated) useEffect(() => { loadRecipes(); }, []); @@ -134,7 +163,8 @@ function App() { const handleCreateRecipe = async (payload) => { try { - const created = await createRecipe(payload); + const token = getToken(); + const created = await createRecipe(payload, token); setDrawerOpen(false); setEditingRecipe(null); await loadRecipes(); @@ -153,7 +183,8 @@ function App() { const handleUpdateRecipe = async (payload) => { try { - await updateRecipe(editingRecipe.id, payload); + const token = getToken(); + await updateRecipe(editingRecipe.id, payload, token); setDrawerOpen(false); setEditingRecipe(null); await loadRecipes(); @@ -177,7 +208,8 @@ function App() { setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" }); try { - await deleteRecipe(recipeId); + const token = getToken(); + await deleteRecipe(recipeId, token); await loadRecipes(); setSelectedRecipe(null); addToast("המתכון נמחק בהצלחה!", "success"); @@ -208,10 +240,89 @@ function App() { } }; + const handleLoginSuccess = async () => { + const token = getToken(); + const userData = await getMe(token); + setUser(userData); + setIsAuthenticated(true); + await loadRecipes(); + }; + + const handleLogout = () => { + removeToken(); + setUser(null); + setIsAuthenticated(false); + setRecipes([]); + setSelectedRecipe(null); + }; + + // Show loading state while checking auth + if (loadingAuth) { + return ( +
+
+ טוען... +
+
+ ); + } + + // Show main app (readonly if not authenticated) return (
setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} /> - setDrawerOpen(true)} /> + + {/* User greeting above TopBar */} + {isAuthenticated && user && ( +
+ שלום, {user.username} 👋 +
+ )} + + {/* Show login/register option in TopBar if not authenticated */} + {!isAuthenticated ? ( +
+
+ 🍽 +
+
מה לבשל היום?
+
מנהל המתכונים האישי שלך
+
+
+
+ + +
+
+ ) : ( + setDrawerOpen(true)} user={user} onLogout={handleLogout} /> + )} + + {/* Show auth modal if needed */} + {!isAuthenticated && authView !== null && ( +
setAuthView(null)}> +
e.stopPropagation()}> + {authView === "login" ? ( + setAuthView("register")} + /> + ) : ( + { + addToast("נרשמת בהצלחה! כעת התחבר", "success"); + setAuthView("login"); + }} + onSwitchToLogin={() => setAuthView("login")} + /> + )} +
+
+ )}
@@ -289,19 +400,24 @@ function App() { recipe={selectedRecipe} onEditClick={handleEditRecipe} onShowDeleteModal={handleShowDeleteModal} + isAuthenticated={isAuthenticated} + currentUser={user} />
- { - setDrawerOpen(false); - setEditingRecipe(null); - }} - onSubmit={handleFormSubmit} - editingRecipe={editingRecipe} - /> + {isAuthenticated && ( + { + setDrawerOpen(false); + setEditingRecipe(null); + }} + onSubmit={handleFormSubmit} + editingRecipe={editingRecipe} + currentUser={user} + /> + )} { + if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) { + return window.__ENV__.API_BASE; + } + return "/api"; +}; + +const API_BASE = getApiBase(); + +export async function register(username, email, password) { + const res = await fetch(`${API_BASE}/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, email, password }), + }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Failed to register"); + } + return res.json(); +} + +export async function login(username, password) { + const res = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || "Failed to login"); + } + return res.json(); +} + +export async function getMe(token) { + const res = await fetch(`${API_BASE}/auth/me`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) { + throw new Error("Failed to get user info"); + } + return res.json(); +} + +// Auth helpers +export function saveToken(token) { + localStorage.setItem("auth_token", token); +} + +export function getToken() { + return localStorage.getItem("auth_token"); +} + +export function removeToken() { + localStorage.removeItem("auth_token"); +} diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx new file mode 100644 index 0000000..ec3618b --- /dev/null +++ b/frontend/src/components/Login.jsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { login, saveToken } from "../authApi"; + +function Login({ onSuccess, onSwitchToRegister }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const data = await login(username, password); + saveToken(data.access_token); + onSuccess(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

התחברות

+

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

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

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

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

הרשמה

+

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

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

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

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