Manage users
This commit is contained in:
parent
b35100c92f
commit
1d33e52100
BIN
backend/__pycache__/auth_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/auth_utils.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/user_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/user_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
80
backend/auth_utils.py
Normal file
80
backend/auth_utils.py
Normal file
@ -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
|
||||||
@ -55,7 +55,7 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, meal_type, time_minutes,
|
SELECT id, name, meal_type, time_minutes,
|
||||||
tags, ingredients, steps, image, made_by
|
tags, ingredients, steps, image, made_by, user_id
|
||||||
FROM recipes
|
FROM recipes
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
"""
|
"""
|
||||||
@ -85,7 +85,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
|||||||
image = %s,
|
image = %s,
|
||||||
made_by = %s
|
made_by = %s
|
||||||
WHERE id = %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"],
|
recipe_data["name"],
|
||||||
@ -133,9 +133,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO recipes (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)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %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"],
|
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", [])),
|
json.dumps(recipe_data.get("steps", [])),
|
||||||
recipe_data.get("image"),
|
recipe_data.get("image"),
|
||||||
recipe_data.get("made_by"),
|
recipe_data.get("made_by"),
|
||||||
|
recipe_data.get("user_id"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@ -163,7 +164,7 @@ def get_recipes_by_filters_db(
|
|||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT id, name, meal_type, time_minutes,
|
SELECT id, name, meal_type, time_minutes,
|
||||||
tags, ingredients, steps, image, made_by
|
tags, ingredients, steps, image, made_by, user_id
|
||||||
FROM recipes
|
FROM recipes
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
|
|||||||
166
backend/main.py
166
backend/main.py
@ -1,9 +1,10 @@
|
|||||||
import random
|
import random
|
||||||
from typing import List, Optional
|
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 fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, EmailStr
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@ -14,6 +15,21 @@ from db_utils import (
|
|||||||
get_recipes_by_filters_db,
|
get_recipes_by_filters_db,
|
||||||
update_recipe_db,
|
update_recipe_db,
|
||||||
delete_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):
|
class Recipe(RecipeBase):
|
||||||
id: int
|
id: int
|
||||||
|
user_id: Optional[int] = None # Recipe owner ID
|
||||||
|
|
||||||
|
|
||||||
class RecipeUpdate(RecipeBase):
|
class RecipeUpdate(RecipeBase):
|
||||||
pass
|
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(
|
app = FastAPI(
|
||||||
title="Random Recipes API",
|
title="Random Recipes API",
|
||||||
@ -77,6 +116,7 @@ def list_recipes():
|
|||||||
ingredients=r["ingredients"] or [],
|
ingredients=r["ingredients"] or [],
|
||||||
steps=r["steps"] or [],
|
steps=r["steps"] or [],
|
||||||
image=r.get("image"),
|
image=r.get("image"),
|
||||||
|
user_id=r.get("user_id"),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -84,9 +124,10 @@ def list_recipes():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/recipes", response_model=Recipe, status_code=201)
|
@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 = recipe_in.dict()
|
||||||
data["meal_type"] = data["meal_type"].lower()
|
data["meal_type"] = data["meal_type"].lower()
|
||||||
|
data["user_id"] = current_user["user_id"]
|
||||||
|
|
||||||
row = create_recipe_db(data)
|
row = create_recipe_db(data)
|
||||||
|
|
||||||
@ -100,10 +141,24 @@ def create_recipe(recipe_in: RecipeCreate):
|
|||||||
ingredients=row["ingredients"] or [],
|
ingredients=row["ingredients"] or [],
|
||||||
steps=row["steps"] or [],
|
steps=row["steps"] or [],
|
||||||
image=row.get("image"),
|
image=row.get("image"),
|
||||||
|
user_id=row.get("user_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.put("/recipes/{recipe_id}", response_model=Recipe)
|
@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 = recipe_in.dict()
|
||||||
data["meal_type"] = data["meal_type"].lower()
|
data["meal_type"] = data["meal_type"].lower()
|
||||||
|
|
||||||
@ -121,11 +176,25 @@ def update_recipe(recipe_id: int, recipe_in: RecipeUpdate):
|
|||||||
ingredients=row["ingredients"] or [],
|
ingredients=row["ingredients"] or [],
|
||||||
steps=row["steps"] or [],
|
steps=row["steps"] or [],
|
||||||
image=row.get("image"),
|
image=row.get("image"),
|
||||||
|
user_id=row.get("user_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/recipes/{recipe_id}", status_code=204)
|
@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)
|
deleted = delete_recipe_db(recipe_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="המתכון לא נמצא")
|
raise HTTPException(status_code=404, detail="המתכון לא נמצא")
|
||||||
@ -154,6 +223,7 @@ def random_recipe(
|
|||||||
ingredients=r["ingredients"] or [],
|
ingredients=r["ingredients"] or [],
|
||||||
steps=r["steps"] or [],
|
steps=r["steps"] or [],
|
||||||
image=r.get("image"),
|
image=r.get("image"),
|
||||||
|
user_id=r.get("user_id"),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -177,6 +247,88 @@ def random_recipe(
|
|||||||
return random.choice(recipes)
|
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__":
|
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=8000, reload=True)
|
||||||
|
|
||||||
@ -5,3 +5,8 @@ pydantic==2.7.4
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.9
|
||||||
|
|||||||
@ -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 recipes table
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@ -8,7 +20,9 @@ CREATE TABLE IF NOT EXISTS recipes (
|
|||||||
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
||||||
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
||||||
steps 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
|
-- Optional: index for filters
|
||||||
|
|||||||
83
backend/user_db_utils.py
Normal file
83
backend/user_db_utils.py
Normal file
@ -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()
|
||||||
@ -32,11 +32,21 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: flex-start;
|
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 {
|
.app-root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
padding-top: 4.5rem; /* Add space for fixed theme toggle */
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +84,15 @@ body {
|
|||||||
color: var(--text-muted);
|
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 */
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
@ -640,7 +659,7 @@ select {
|
|||||||
|
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1.5rem;
|
bottom: 5rem;
|
||||||
right: 1.5rem;
|
right: 1.5rem;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1264,4 +1283,69 @@ html {
|
|||||||
|
|
||||||
[data-theme="light"] .recipe-list-image {
|
[data-theme="light"] .recipe-list-image {
|
||||||
background: rgba(229, 231, 235, 0.5);
|
background: rgba(229, 231, 235, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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%;
|
||||||
|
}
|
||||||
|
|||||||
@ -8,9 +8,17 @@ import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
|||||||
import Modal from "./components/Modal";
|
import Modal from "./components/Modal";
|
||||||
import ToastContainer from "./components/ToastContainer";
|
import ToastContainer from "./components/ToastContainer";
|
||||||
import ThemeToggle from "./components/ThemeToggle";
|
import ThemeToggle from "./components/ThemeToggle";
|
||||||
|
import Login from "./components/Login";
|
||||||
|
import Register from "./components/Register";
|
||||||
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||||
|
import { getToken, removeToken, getMe } from "./authApi";
|
||||||
|
|
||||||
function App() {
|
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 [recipes, setRecipes] = useState([]);
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
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(() => {
|
useEffect(() => {
|
||||||
loadRecipes();
|
loadRecipes();
|
||||||
}, []);
|
}, []);
|
||||||
@ -134,7 +163,8 @@ function App() {
|
|||||||
|
|
||||||
const handleCreateRecipe = async (payload) => {
|
const handleCreateRecipe = async (payload) => {
|
||||||
try {
|
try {
|
||||||
const created = await createRecipe(payload);
|
const token = getToken();
|
||||||
|
const created = await createRecipe(payload, token);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setEditingRecipe(null);
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
@ -153,7 +183,8 @@ function App() {
|
|||||||
|
|
||||||
const handleUpdateRecipe = async (payload) => {
|
const handleUpdateRecipe = async (payload) => {
|
||||||
try {
|
try {
|
||||||
await updateRecipe(editingRecipe.id, payload);
|
const token = getToken();
|
||||||
|
await updateRecipe(editingRecipe.id, payload, token);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setEditingRecipe(null);
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
@ -177,7 +208,8 @@ function App() {
|
|||||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteRecipe(recipeId);
|
const token = getToken();
|
||||||
|
await deleteRecipe(recipeId, token);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
setSelectedRecipe(null);
|
setSelectedRecipe(null);
|
||||||
addToast("המתכון נמחק בהצלחה!", "success");
|
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 (
|
||||||
|
<div className="app-root">
|
||||||
|
<div style={{ textAlign: "center", padding: "3rem", color: "var(--text-muted)" }}>
|
||||||
|
טוען...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show main app (readonly if not authenticated)
|
||||||
return (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
||||||
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
|
||||||
|
{/* User greeting above TopBar */}
|
||||||
|
{isAuthenticated && user && (
|
||||||
|
<div className="user-greeting-header">
|
||||||
|
שלום, {user.username} 👋
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show login/register option in TopBar if not authenticated */}
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-left">
|
||||||
|
<span className="logo-emoji" role="img" aria-label="plate">🍽</span>
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-title">מה לבשל היום?</div>
|
||||||
|
<div className="brand-subtitle">מנהל המתכונים האישי שלך</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||||
|
<button className="btn ghost" onClick={() => setAuthView("login")}>
|
||||||
|
התחבר
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" onClick={() => setAuthView("register")}>
|
||||||
|
הירשם
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
) : (
|
||||||
|
<TopBar onAddClick={() => setDrawerOpen(true)} user={user} onLogout={handleLogout} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show auth modal if needed */}
|
||||||
|
{!isAuthenticated && authView !== null && (
|
||||||
|
<div className="drawer-backdrop" onClick={() => setAuthView(null)}>
|
||||||
|
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{authView === "login" ? (
|
||||||
|
<Login
|
||||||
|
onSuccess={handleLoginSuccess}
|
||||||
|
onSwitchToRegister={() => setAuthView("register")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Register
|
||||||
|
onSuccess={() => {
|
||||||
|
addToast("נרשמת בהצלחה! כעת התחבר", "success");
|
||||||
|
setAuthView("login");
|
||||||
|
}}
|
||||||
|
onSwitchToLogin={() => setAuthView("login")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="layout">
|
<main className="layout">
|
||||||
<section className="sidebar">
|
<section className="sidebar">
|
||||||
@ -289,19 +400,24 @@ function App() {
|
|||||||
recipe={selectedRecipe}
|
recipe={selectedRecipe}
|
||||||
onEditClick={handleEditRecipe}
|
onEditClick={handleEditRecipe}
|
||||||
onShowDeleteModal={handleShowDeleteModal}
|
onShowDeleteModal={handleShowDeleteModal}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
currentUser={user}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<RecipeFormDrawer
|
{isAuthenticated && (
|
||||||
open={drawerOpen}
|
<RecipeFormDrawer
|
||||||
onClose={() => {
|
open={drawerOpen}
|
||||||
setDrawerOpen(false);
|
onClose={() => {
|
||||||
setEditingRecipe(null);
|
setDrawerOpen(false);
|
||||||
}}
|
setEditingRecipe(null);
|
||||||
onSubmit={handleFormSubmit}
|
}}
|
||||||
editingRecipe={editingRecipe}
|
onSubmit={handleFormSubmit}
|
||||||
/>
|
editingRecipe={editingRecipe}
|
||||||
|
currentUser={user}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={deleteModal.isOpen}
|
isOpen={deleteModal.isOpen}
|
||||||
|
|||||||
@ -37,10 +37,14 @@ export async function getRandomRecipe(filters) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRecipe(recipe) {
|
export async function createRecipe(recipe, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes`, {
|
const res = await fetch(`${API_BASE}/recipes`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(recipe),
|
body: JSON.stringify(recipe),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -49,10 +53,14 @@ export async function createRecipe(recipe) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRecipe(id, payload) {
|
export async function updateRecipe(id, payload, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -61,9 +69,14 @@ export async function updateRecipe(id, payload) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecipe(id) {
|
export async function deleteRecipe(id, token) {
|
||||||
|
const headers = {};
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 204) {
|
if (!res.ok && res.status !== 204) {
|
||||||
throw new Error("Failed to delete recipe");
|
throw new Error("Failed to delete recipe");
|
||||||
|
|||||||
60
frontend/src/authApi.js
Normal file
60
frontend/src/authApi.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// Get API base from injected env.js or fallback to /api relative path
|
||||||
|
const getApiBase = () => {
|
||||||
|
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");
|
||||||
|
}
|
||||||
77
frontend/src/components/Login.jsx
Normal file
77
frontend/src/components/Login.jsx
Normal file
@ -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 (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-title">התחברות</h1>
|
||||||
|
<p className="auth-subtitle">ברוכים השבים למתכונים שלכם</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם משתמש</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן שם משתמש"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן סיסמה"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||||
|
{loading ? "מתחבר..." : "התחבר"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-footer">
|
||||||
|
<p>
|
||||||
|
עדיין אין לך חשבון?{" "}
|
||||||
|
<button className="link-btn" onClick={onSwitchToRegister}>
|
||||||
|
הירשם עכשיו
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import placeholderImage from "../assets/placeholder.svg";
|
import placeholderImage from "../assets/placeholder.svg";
|
||||||
|
|
||||||
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
|
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser }) {
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return (
|
return (
|
||||||
<section className="panel placeholder">
|
<section className="panel placeholder">
|
||||||
@ -13,6 +13,15 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
onShowDeleteModal(recipe.id, recipe.name);
|
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 (
|
return (
|
||||||
<section className="panel recipe-card">
|
<section className="panel recipe-card">
|
||||||
{/* Recipe Image */}
|
{/* Recipe Image */}
|
||||||
@ -66,14 +75,16 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="recipe-actions">
|
{isAuthenticated && currentUser && Number(recipe.user_id) === Number(currentUser.id) && (
|
||||||
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
<div className="recipe-actions">
|
||||||
✏️ ערוך
|
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||||
</button>
|
✏️ ערוך
|
||||||
<button className="btn ghost small" onClick={handleDelete}>
|
</button>
|
||||||
🗑 מחק
|
<button className="btn ghost small" onClick={handleDelete}>
|
||||||
</button>
|
🗑 מחק
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 [name, setName] = useState("");
|
||||||
const [mealType, setMealType] = useState("lunch");
|
const [mealType, setMealType] = useState("lunch");
|
||||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||||
@ -10,6 +10,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
|
|
||||||
const [ingredients, setIngredients] = useState([""]);
|
const [ingredients, setIngredients] = useState([""]);
|
||||||
const [steps, setSteps] = useState([""]);
|
const [steps, setSteps] = useState([""]);
|
||||||
|
|
||||||
|
const lastIngredientRef = useRef(null);
|
||||||
|
const lastStepRef = useRef(null);
|
||||||
|
|
||||||
const isEditMode = !!editingRecipe;
|
const isEditMode = !!editingRecipe;
|
||||||
|
|
||||||
@ -20,7 +23,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
setMealType(editingRecipe.meal_type || "lunch");
|
setMealType(editingRecipe.meal_type || "lunch");
|
||||||
setTimeMinutes(editingRecipe.time_minutes || 15);
|
setTimeMinutes(editingRecipe.time_minutes || 15);
|
||||||
setMadeBy(editingRecipe.made_by || "");
|
setMadeBy(editingRecipe.made_by || "");
|
||||||
setTags((editingRecipe.tags || []).join(", "));
|
setTags((editingRecipe.tags || []).join(" "));
|
||||||
setImage(editingRecipe.image || "");
|
setImage(editingRecipe.image || "");
|
||||||
setIngredients(editingRecipe.ingredients || [""]);
|
setIngredients(editingRecipe.ingredients || [""]);
|
||||||
setSteps(editingRecipe.steps || [""]);
|
setSteps(editingRecipe.steps || [""]);
|
||||||
@ -28,19 +31,23 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
setName("");
|
setName("");
|
||||||
setMealType("lunch");
|
setMealType("lunch");
|
||||||
setTimeMinutes(15);
|
setTimeMinutes(15);
|
||||||
setMadeBy("");
|
setMadeBy(currentUser?.username || "");
|
||||||
setTags("");
|
setTags("");
|
||||||
setImage("");
|
setImage("");
|
||||||
setIngredients([""]);
|
setIngredients([""]);
|
||||||
setSteps([""]);
|
setSteps([""]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, editingRecipe, isEditMode]);
|
}, [open, editingRecipe, isEditMode, currentUser]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleAddIngredient = () => {
|
const handleAddIngredient = () => {
|
||||||
setIngredients((prev) => [...prev, ""]);
|
setIngredients((prev) => [...prev, ""]);
|
||||||
|
setTimeout(() => {
|
||||||
|
lastIngredientRef.current?.focus();
|
||||||
|
lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeIngredient = (idx, value) => {
|
const handleChangeIngredient = (idx, value) => {
|
||||||
@ -53,6 +60,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
|
|
||||||
const handleAddStep = () => {
|
const handleAddStep = () => {
|
||||||
setSteps((prev) => [...prev, ""]);
|
setSteps((prev) => [...prev, ""]);
|
||||||
|
setTimeout(() => {
|
||||||
|
lastStepRef.current?.focus();
|
||||||
|
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeStep = (idx, value) => {
|
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 cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
|
||||||
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
||||||
const tagsArr = tags
|
const tagsArr = tags
|
||||||
.split(",")
|
.split(" ")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@ -95,12 +106,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
tags: tagsArr,
|
tags: tagsArr,
|
||||||
ingredients: cleanIngredients,
|
ingredients: cleanIngredients,
|
||||||
steps: cleanSteps,
|
steps: cleanSteps,
|
||||||
|
made_by: madeBy.trim() || currentUser?.username || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (madeBy.trim()) {
|
|
||||||
payload.made_by = madeBy.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
payload.image = image;
|
payload.image = image;
|
||||||
}
|
}
|
||||||
@ -191,11 +199,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>תגיות (מופרד בפסיקים)</label>
|
<label>תגיות (מופרד ברווחים)</label>
|
||||||
<input
|
<input
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => setTags(e.target.value)}
|
onChange={(e) => setTags(e.target.value)}
|
||||||
placeholder="מהיר, טבעוני, משפחתי..."
|
placeholder="מהיר טבעוני משפחתי..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -205,6 +213,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
{ingredients.map((val, idx) => (
|
{ingredients.map((val, idx) => (
|
||||||
<div key={idx} className="dynamic-row">
|
<div key={idx} className="dynamic-row">
|
||||||
<input
|
<input
|
||||||
|
ref={idx === ingredients.length - 1 ? lastIngredientRef : null}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
||||||
placeholder="למשל: 2 ביצים"
|
placeholder="למשל: 2 ביצים"
|
||||||
@ -234,6 +243,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
{steps.map((val, idx) => (
|
{steps.map((val, idx) => (
|
||||||
<div key={idx} className="dynamic-row">
|
<div key={idx} className="dynamic-row">
|
||||||
<input
|
<input
|
||||||
|
ref={idx === steps.length - 1 ? lastStepRef : null}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
||||||
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
||||||
|
|||||||
118
frontend/src/components/Register.jsx
Normal file
118
frontend/src/components/Register.jsx
Normal file
@ -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 (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-title">הרשמה</h1>
|
||||||
|
<p className="auth-subtitle">צור חשבון חדש והתחל לנהל את המתכונים שלך</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם משתמש</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="בחר שם משתמש"
|
||||||
|
autoComplete="username"
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>אימייל</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="בחר סיסמה חזקה"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>אימות סיסמה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן סיסמה שוב"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||||
|
{loading ? "נרשם..." : "הירשם"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-footer">
|
||||||
|
<p>
|
||||||
|
כבר יש לך חשבון?{" "}
|
||||||
|
<button className="link-btn" onClick={onSwitchToLogin}>
|
||||||
|
התחבר
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Register;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
function TopBar({ onAddClick }) {
|
function TopBar({ onAddClick, user, onLogout }) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
@ -12,9 +12,16 @@ function TopBar({ onAddClick }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||||
<button className="btn primary" onClick={onAddClick}>
|
{user && (
|
||||||
+ מתכון חדש
|
<button className="btn primary" onClick={onAddClick}>
|
||||||
</button>
|
+ מתכון חדש
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onLogout && (
|
||||||
|
<button className="btn ghost" onClick={onLogout}>
|
||||||
|
יציאה
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user