Manage users

This commit is contained in:
dvirlabs 2025-12-08 07:04:50 +02:00
parent b35100c92f
commit 1d33e52100
19 changed files with 890 additions and 59 deletions

Binary file not shown.

Binary file not shown.

80
backend/auth_utils.py Normal file
View 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

View File

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

View File

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

View File

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

View File

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

83
backend/user_db_utils.py Normal file
View 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()

View File

@ -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);
}
}
/* 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%;
}

View File

@ -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 (
<div className="app-root">
<div style={{ textAlign: "center", padding: "3rem", color: "var(--text-muted)" }}>
טוען...
</div>
</div>
);
}
// Show main app (readonly if not authenticated)
return (
<div className="app-root">
<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">
<section className="sidebar">
@ -289,19 +400,24 @@ function App() {
recipe={selectedRecipe}
onEditClick={handleEditRecipe}
onShowDeleteModal={handleShowDeleteModal}
isAuthenticated={isAuthenticated}
currentUser={user}
/>
</section>
</main>
<RecipeFormDrawer
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
setEditingRecipe(null);
}}
onSubmit={handleFormSubmit}
editingRecipe={editingRecipe}
/>
{isAuthenticated && (
<RecipeFormDrawer
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
setEditingRecipe(null);
}}
onSubmit={handleFormSubmit}
editingRecipe={editingRecipe}
currentUser={user}
/>
)}
<Modal
isOpen={deleteModal.isOpen}

View File

@ -37,10 +37,14 @@ export async function getRandomRecipe(filters) {
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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers,
body: JSON.stringify(recipe),
});
if (!res.ok) {
@ -49,10 +53,14 @@ export async function createRecipe(recipe) {
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}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
headers,
body: JSON.stringify(payload),
});
if (!res.ok) {
@ -61,9 +69,14 @@ export async function updateRecipe(id, payload) {
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}`, {
method: "DELETE",
headers,
});
if (!res.ok && res.status !== 204) {
throw new Error("Failed to delete recipe");

60
frontend/src/authApi.js Normal file
View 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");
}

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

View File

@ -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 (
<section className="panel placeholder">
@ -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 (
<section className="panel recipe-card">
{/* Recipe Image */}
@ -66,14 +75,16 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
</footer>
)}
<div className="recipe-actions">
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
ערוך
</button>
<button className="btn ghost small" onClick={handleDelete}>
🗑 מחק
</button>
</div>
{isAuthenticated && currentUser && Number(recipe.user_id) === Number(currentUser.id) && (
<div className="recipe-actions">
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
ערוך
</button>
<button className="btn ghost small" onClick={handleDelete}>
🗑 מחק
</button>
</div>
)}
</section>
);
}

View File

@ -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 }) {
</div>
<div className="field">
<label>תגיות (מופרד בפסיקים)</label>
<label>תגיות (מופרד ברווחים)</label>
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="מהיר, טבעוני, משפחתי..."
placeholder="מהיר טבעוני משפחתי..."
/>
</div>
@ -205,6 +213,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
{ingredients.map((val, idx) => (
<div key={idx} className="dynamic-row">
<input
ref={idx === ingredients.length - 1 ? lastIngredientRef : null}
value={val}
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
placeholder="למשל: 2 ביצים"
@ -234,6 +243,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
{steps.map((val, idx) => (
<div key={idx} className="dynamic-row">
<input
ref={idx === steps.length - 1 ? lastStepRef : null}
value={val}
onChange={(e) => handleChangeStep(idx, e.target.value)}
placeholder="למשל: לחמם את התנור ל־180 מעלות"

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

View File

@ -1,4 +1,4 @@
function TopBar({ onAddClick }) {
function TopBar({ onAddClick, user, onLogout }) {
return (
<header className="topbar">
<div className="topbar-left">
@ -12,9 +12,16 @@ function TopBar({ onAddClick }) {
</div>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<button className="btn primary" onClick={onAddClick}>
+ מתכון חדש
</button>
{user && (
<button className="btn primary" onClick={onAddClick}>
+ מתכון חדש
</button>
)}
{onLogout && (
<button className="btn ghost" onClick={onLogout}>
יציאה
</button>
)}
</div>
</header>
);