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(
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
166
backend/main.py
166
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)
|
||||
|
||||
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
|
||||
|
||||
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 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
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;
|
||||
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%;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 מעלות"
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user