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 ( +
ברוכים השבים למתכונים שלכם
+ + + ++ עדיין אין לך חשבון?{" "} + +
+צור חשבון חדש והתחל לנהל את המתכונים שלך
+ + + ++ כבר יש לך חשבון?{" "} + +
+