Adapt to mobile, handle forget password and add auth_provider to DB
This commit is contained in:
parent
70f8ce1a6b
commit
3270788902
@ -15,11 +15,11 @@ SMTP_FROM=dvirlabs@gmail.com
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_REDIRECT_URI=https://api-my-recipes.dvirlabs.com/auth/google/callback
|
||||
FRONTEND_URL=https://my-recipes.dvirlabs.com
|
||||
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
|
||||
FRONTEND_URL=http://localhost:5174
|
||||
|
||||
# Microsoft Entra ID (Azure AD) OAuth
|
||||
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
|
||||
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
|
||||
AZURE_TENANT_ID=consumers
|
||||
AZURE_REDIRECT_URI=https://api-my-recipes.dvirlabs.com/auth/azure/callback
|
||||
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback
|
||||
28
backend/.env.local
Normal file
28
backend/.env.local
Normal file
@ -0,0 +1,28 @@
|
||||
DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db
|
||||
DB_PASSWORD=Aa123456
|
||||
DB_USER=recipes_user
|
||||
DB_NAME=recipes_db
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=dvirlabs@gmail.com
|
||||
SMTP_PASSWORD=agaanrhbbazbdytv
|
||||
SMTP_FROM=dvirlabs@gmail.com
|
||||
|
||||
# Secret Key for sessions (OAuth state token)
|
||||
SECRET_KEY=your-super-secret-key-min-32-chars-dev-only-change-in-prod
|
||||
|
||||
# Google OAuth (LOCAL - localhost redirect)
|
||||
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
|
||||
FRONTEND_URL=http://localhost:5174
|
||||
|
||||
# Microsoft Entra ID (Azure AD) OAuth
|
||||
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
|
||||
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
|
||||
AZURE_TENANT_ID=consumers
|
||||
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback
|
||||
21
backend/MIGRATION_INSTRUCTIONS.md
Normal file
21
backend/MIGRATION_INSTRUCTIONS.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Database Migration Instructions
|
||||
|
||||
## Add auth_provider column to users table
|
||||
|
||||
Run this command in your backend directory:
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
$env:PGPASSWORD="recipes_password"; psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
|
||||
|
||||
# Or using psql directly
|
||||
psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Add the `auth_provider` column to the users table (default: 'local')
|
||||
2. Update all existing users to have 'local' as their auth_provider
|
||||
3. Create an index for faster lookups
|
||||
4. Display the updated table structure
|
||||
|
||||
After running the migration, restart your backend server.
|
||||
30
backend/add_auth_provider_column.sql
Normal file
30
backend/add_auth_provider_column.sql
Normal file
@ -0,0 +1,30 @@
|
||||
-- Add auth_provider column to users table
|
||||
-- This tracks whether the user is local or uses OAuth (google, microsoft, etc.)
|
||||
|
||||
-- Add the column if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='users' AND column_name='auth_provider'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN auth_provider VARCHAR(50) DEFAULT 'local' NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Update existing users to have 'local' as their auth_provider
|
||||
UPDATE users SET auth_provider = 'local' WHERE auth_provider IS NULL;
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_auth_provider ON users(auth_provider);
|
||||
|
||||
-- Display the updated users table structure
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users'
|
||||
ORDER BY ordinal_position;
|
||||
@ -10,6 +10,7 @@ load_dotenv()
|
||||
|
||||
# In-memory storage for verification codes (in production, use Redis or database)
|
||||
verification_codes = {}
|
||||
password_reset_tokens = {}
|
||||
|
||||
def generate_verification_code():
|
||||
"""Generate a 6-digit verification code"""
|
||||
@ -104,3 +105,107 @@ def verify_code(user_id: int, code: str) -> bool:
|
||||
# Code is valid, remove it
|
||||
del verification_codes[user_id]
|
||||
return True
|
||||
|
||||
|
||||
async def send_password_reset_email(email: str, token: str, frontend_url: str):
|
||||
"""Send password reset link via email"""
|
||||
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||
smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||
smtp_user = os.getenv("SMTP_USER")
|
||||
smtp_password = os.getenv("SMTP_PASSWORD")
|
||||
smtp_from = os.getenv("SMTP_FROM", smtp_user)
|
||||
|
||||
if not smtp_user or not smtp_password:
|
||||
raise Exception("SMTP credentials not configured")
|
||||
|
||||
reset_link = f"{frontend_url}?reset_token={token}"
|
||||
|
||||
# Create message
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = "איפוס סיסמה - מתכונים שלי"
|
||||
message["From"] = smtp_from
|
||||
message["To"] = email
|
||||
|
||||
text = f"""
|
||||
שלום,
|
||||
|
||||
קיבלנו בקשה לאיפוס הסיסמה שלך.
|
||||
|
||||
לחץ על הקישור הבא כדי לאפס את הסיסמה (תקף ל-30 דקות):
|
||||
{reset_link}
|
||||
|
||||
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
|
||||
|
||||
בברכה,
|
||||
צוות מתכונים שלי
|
||||
"""
|
||||
|
||||
html = f"""
|
||||
<html dir="rtl">
|
||||
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
|
||||
<h2>איפוס סיסמה</h2>
|
||||
<p>קיבלנו בקשה לאיפוס הסיסמה שלך.</p>
|
||||
<p>לחץ על הכפתור למטה כדי לאפס את הסיסמה:</p>
|
||||
<div style="margin: 30px 0; text-align: center;">
|
||||
<a href="{reset_link}"
|
||||
style="background-color: #22c55e; color: white; padding: 12px 30px;
|
||||
text-decoration: none; border-radius: 6px; display: inline-block;
|
||||
font-weight: bold;">
|
||||
איפוס סיסמה
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
הקישור תקף ל-<strong>30 דקות</strong>.
|
||||
</p>
|
||||
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
part1 = MIMEText(text, "plain")
|
||||
part2 = MIMEText(html, "html")
|
||||
message.attach(part1)
|
||||
message.attach(part2)
|
||||
|
||||
# Send email
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=smtp_host,
|
||||
port=smtp_port,
|
||||
username=smtp_user,
|
||||
password=smtp_password,
|
||||
start_tls=True,
|
||||
)
|
||||
|
||||
|
||||
def store_password_reset_token(email: str, token: str):
|
||||
"""Store password reset token with expiry"""
|
||||
expiry = datetime.now() + timedelta(minutes=30)
|
||||
password_reset_tokens[token] = {
|
||||
"email": email,
|
||||
"expiry": expiry
|
||||
}
|
||||
|
||||
|
||||
def verify_reset_token(token: str) -> str:
|
||||
"""Verify reset token and return email if valid"""
|
||||
if token not in password_reset_tokens:
|
||||
return None
|
||||
|
||||
stored = password_reset_tokens[token]
|
||||
|
||||
# Check if expired
|
||||
if datetime.now() > stored["expiry"]:
|
||||
del password_reset_tokens[token]
|
||||
return None
|
||||
|
||||
return stored["email"]
|
||||
|
||||
|
||||
def consume_reset_token(token: str):
|
||||
"""Remove token after use"""
|
||||
if token in password_reset_tokens:
|
||||
del password_reset_tokens[token]
|
||||
|
||||
125
backend/main.py
125
backend/main.py
@ -31,6 +31,7 @@ from user_db_utils import (
|
||||
create_user,
|
||||
get_user_by_username,
|
||||
get_user_by_email,
|
||||
update_user_auth_provider,
|
||||
)
|
||||
|
||||
from grocery_db_utils import (
|
||||
@ -126,6 +127,7 @@ class UserResponse(BaseModel):
|
||||
last_name: Optional[str] = None
|
||||
display_name: str
|
||||
is_admin: bool = False
|
||||
auth_provider: str = "local"
|
||||
|
||||
|
||||
class RequestPasswordChangeCode(BaseModel):
|
||||
@ -138,6 +140,15 @@ class ChangePasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
# Grocery List models
|
||||
class GroceryListCreate(BaseModel):
|
||||
name: str
|
||||
@ -204,8 +215,8 @@ app = FastAPI(
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=os.getenv("SECRET_KEY", "your-secret-key-change-in-production-min-32-chars"),
|
||||
max_age=3600, # 1 hour
|
||||
same_site="lax", # Prevent session issues on frequent refreshes
|
||||
max_age=7200, # 2 hours for OAuth flow
|
||||
same_site="lax", # Use "lax" for localhost development
|
||||
https_only=False, # Set to True in production with HTTPS
|
||||
)
|
||||
|
||||
@ -436,7 +447,8 @@ def register(user: UserRegister):
|
||||
first_name=new_user.get("first_name"),
|
||||
last_name=new_user.get("last_name"),
|
||||
display_name=new_user["display_name"],
|
||||
is_admin=new_user.get("is_admin", False)
|
||||
is_admin=new_user.get("is_admin", False),
|
||||
auth_provider=new_user.get("auth_provider", "local")
|
||||
)
|
||||
|
||||
|
||||
@ -483,7 +495,8 @@ def get_me(current_user: dict = Depends(get_current_user)):
|
||||
first_name=user.get("first_name"),
|
||||
last_name=user.get("last_name"),
|
||||
display_name=user["display_name"],
|
||||
is_admin=user.get("is_admin", False)
|
||||
is_admin=user.get("is_admin", False),
|
||||
auth_provider=user.get("auth_provider", "local")
|
||||
)
|
||||
|
||||
|
||||
@ -562,7 +575,7 @@ def change_password(
|
||||
@app.get("/auth/google/login")
|
||||
async def google_login(request: Request):
|
||||
"""Redirect to Google OAuth login"""
|
||||
redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:5174/auth/google/callback")
|
||||
redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/auth/google/callback")
|
||||
return await oauth.google.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@ -591,9 +604,13 @@ async def google_callback(request: Request):
|
||||
existing_user = get_user_by_email(email)
|
||||
|
||||
if existing_user:
|
||||
# User exists, log them in
|
||||
# User exists, log them in and update auth_provider
|
||||
user_id = existing_user["id"]
|
||||
username = existing_user["username"]
|
||||
|
||||
# Update auth_provider if it's different
|
||||
if existing_user.get("auth_provider") != "google":
|
||||
update_user_auth_provider(user_id, "google")
|
||||
else:
|
||||
# Create new user
|
||||
# Generate username from email or name
|
||||
@ -618,7 +635,8 @@ async def google_callback(request: Request):
|
||||
first_name=given_name if given_name else None,
|
||||
last_name=family_name if family_name else None,
|
||||
display_name=name if name else username,
|
||||
is_admin=False
|
||||
is_admin=False,
|
||||
auth_provider="google"
|
||||
)
|
||||
user_id = new_user["id"]
|
||||
|
||||
@ -645,7 +663,7 @@ async def google_callback(request: Request):
|
||||
@app.get("/auth/azure/login")
|
||||
async def azure_login(request: Request):
|
||||
"""Redirect to Microsoft Entra ID OAuth login"""
|
||||
redirect_uri = os.getenv("AZURE_REDIRECT_URI", "http://localhost:8001/auth/azure/callback")
|
||||
redirect_uri = os.getenv("AZURE_REDIRECT_URI", "http://localhost:8000/auth/azure/callback")
|
||||
return await oauth.azure.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
@ -674,9 +692,13 @@ async def azure_callback(request: Request):
|
||||
existing_user = get_user_by_email(email)
|
||||
|
||||
if existing_user:
|
||||
# User exists, log them in
|
||||
# User exists, log them in and update auth_provider
|
||||
user_id = existing_user["id"]
|
||||
username = existing_user["username"]
|
||||
|
||||
# Update auth_provider if it's different
|
||||
if existing_user.get("auth_provider") != "azure":
|
||||
update_user_auth_provider(user_id, "azure")
|
||||
else:
|
||||
# Create new user
|
||||
# Generate username from email or name
|
||||
@ -701,7 +723,8 @@ async def azure_callback(request: Request):
|
||||
first_name=given_name if given_name else None,
|
||||
last_name=family_name if family_name else None,
|
||||
display_name=name if name else username,
|
||||
is_admin=False
|
||||
is_admin=False,
|
||||
auth_provider="azure"
|
||||
)
|
||||
user_id = new_user["id"]
|
||||
|
||||
@ -723,6 +746,86 @@ async def azure_callback(request: Request):
|
||||
raise HTTPException(status_code=400, detail=f"Microsoft authentication failed: {str(e)}")
|
||||
|
||||
|
||||
# ============= Password Reset Endpoints =============
|
||||
|
||||
@app.post("/forgot-password")
|
||||
async def forgot_password(request: ForgotPasswordRequest):
|
||||
"""Request password reset - send email with reset link"""
|
||||
try:
|
||||
# Check if user exists and is a local user
|
||||
user = get_user_by_email(request.email)
|
||||
|
||||
if not user:
|
||||
# Don't reveal if user exists or not
|
||||
return {"message": "אם המייל קיים במערכת, נשלח אליך קישור לאיפוס סיסמה"}
|
||||
|
||||
# Only allow password reset for local users
|
||||
if user.get("auth_provider") != "local":
|
||||
return {"message": "משתמשים שנרשמו דרך Google או Microsoft לא יכולים לאפס סיסמה"}
|
||||
|
||||
# Generate reset token
|
||||
import secrets
|
||||
reset_token = secrets.token_urlsafe(32)
|
||||
|
||||
# Store token
|
||||
from email_utils import store_password_reset_token, send_password_reset_email
|
||||
store_password_reset_token(request.email, reset_token)
|
||||
|
||||
# Send email
|
||||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5174")
|
||||
await send_password_reset_email(request.email, reset_token, frontend_url)
|
||||
|
||||
return {"message": "אם המייל קיים במערכת, נשלח אליך קישור לאיפוס סיסמה"}
|
||||
|
||||
except Exception as e:
|
||||
# Don't reveal errors to user
|
||||
print(f"Password reset error: {str(e)}")
|
||||
return {"message": "אם המייל קיים במערכת, נשלח אליך קישור לאיפוס סיסמה"}
|
||||
|
||||
|
||||
@app.post("/reset-password")
|
||||
async def reset_password(request: ResetPasswordRequest):
|
||||
"""Reset password using token from email"""
|
||||
try:
|
||||
from email_utils import verify_reset_token, consume_reset_token
|
||||
|
||||
# Verify token
|
||||
email = verify_reset_token(request.token)
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="הקישור לאיפוס סיסמה אינו תקף או שפג תוקפו")
|
||||
|
||||
# Get user
|
||||
user = get_user_by_email(email)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="משתמש לא נמצא")
|
||||
|
||||
# Only allow password reset for local users
|
||||
if user.get("auth_provider") != "local":
|
||||
raise HTTPException(status_code=400, detail="משתמשים שנרשמו דרך Google או Microsoft לא יכולים לאפס סיסמה")
|
||||
|
||||
# Update password
|
||||
password_hash = hash_password(request.new_password)
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE users SET password_hash = %s WHERE email = %s",
|
||||
(password_hash, email)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Consume token so it can't be reused
|
||||
consume_reset_token(request.token)
|
||||
|
||||
return {"message": "הסיסמה עודכנה בהצלחה"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"שגיאה באיפוס סיסמה: {str(e)}")
|
||||
|
||||
|
||||
# ============= Grocery Lists Endpoints =============
|
||||
|
||||
@app.get("/grocery-lists", response_model=List[GroceryList])
|
||||
@ -1007,4 +1110,4 @@ def delete_notification_endpoint(
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True)
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@ -14,7 +14,7 @@ def get_db_connection():
|
||||
)
|
||||
|
||||
|
||||
def create_user(username: str, email: str, password_hash: str, first_name: str = None, last_name: str = None, display_name: str = None, is_admin: bool = False):
|
||||
def create_user(username: str, email: str, password_hash: str, first_name: str = None, last_name: str = None, display_name: str = None, is_admin: bool = False, auth_provider: str = "local"):
|
||||
"""Create a new user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
@ -24,11 +24,11 @@ def create_user(username: str, email: str, password_hash: str, first_name: str =
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, username, email, first_name, last_name, display_name, is_admin, created_at
|
||||
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, username, email, first_name, last_name, display_name, is_admin, auth_provider, created_at
|
||||
""",
|
||||
(username, email, password_hash, first_name, last_name, final_display_name, is_admin)
|
||||
(username, email, password_hash, first_name, last_name, final_display_name, is_admin, auth_provider)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
conn.commit()
|
||||
@ -44,7 +44,7 @@ def get_user_by_username(username: str):
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, created_at FROM users WHERE username = %s",
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE username = %s",
|
||||
(username,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
@ -60,7 +60,7 @@ def get_user_by_email(email: str):
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, created_at FROM users WHERE email = %s",
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE email = %s",
|
||||
(email,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
@ -76,7 +76,7 @@ def get_user_by_id(user_id: int):
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, created_at FROM users WHERE id = %s",
|
||||
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
@ -100,3 +100,18 @@ def get_user_by_display_name(display_name: str):
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_user_auth_provider(user_id: int, auth_provider: str):
|
||||
"""Update user's auth provider"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"UPDATE users SET auth_provider = %s WHERE id = %s",
|
||||
(auth_provider, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
|
||||
<title>My Recipes | המתכונים שלי</title>
|
||||
<!-- Load environment variables before app starts -->
|
||||
<script src="/env.js"></script>
|
||||
<script src="/env.js?v=20251219"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -45,29 +45,55 @@ body {
|
||||
min-height: 100vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
padding-top: 4.5rem; /* Add space for fixed theme toggle */
|
||||
padding: 0.75rem;
|
||||
padding-top: 4rem; /* Add space for fixed theme toggle */
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-root {
|
||||
padding: 1.5rem;
|
||||
padding-top: 4.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
background: linear-gradient(90deg, #020617, #020617f2);
|
||||
border-radius: 999px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.8rem 1.2rem;
|
||||
margin-bottom: 1.6rem;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-emoji {
|
||||
@ -76,14 +102,60 @@ body {
|
||||
|
||||
.brand-title {
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.brand-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.brand-subtitle {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar-actions {
|
||||
gap: 0.6rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-compact buttons */
|
||||
.btn-mobile-compact .btn-text-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-mobile-compact .btn-text-mobile {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.btn-mobile-compact .btn-text-desktop {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.btn-mobile-compact .btn-text-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.user-greeting {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
@ -124,6 +196,58 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile pinned sidebar - slides in from left */
|
||||
@media (max-width: 959px) {
|
||||
.pinned-lists-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 90%;
|
||||
max-width: 360px;
|
||||
height: 100vh;
|
||||
background: var(--bg);
|
||||
z-index: 70;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
transition: left 0.3s ease;
|
||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.pinned-lists-sidebar.mobile-visible {
|
||||
display: block;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
z-index: 65;
|
||||
}
|
||||
|
||||
.close-sidebar-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: #5a4a2a;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.close-sidebar-btn:hover {
|
||||
color: #3a2a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@ -147,12 +271,19 @@ body {
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border-radius: 18px;
|
||||
padding: 1.1rem 1.2rem;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.panel {
|
||||
border-radius: 18px;
|
||||
padding: 1.1rem 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panel.secondary {
|
||||
background: var(--card-soft);
|
||||
}
|
||||
@ -210,21 +341,41 @@ select {
|
||||
border: 1px solid rgba(148, 163, 184, 0.6);
|
||||
background: #020617;
|
||||
color: var(--text-main);
|
||||
padding: 0.4rem 0.65rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: 44px; /* Better touch target for mobile */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
input,
|
||||
select {
|
||||
padding: 0.4rem 0.65rem;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.btn {
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 1.2rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.08s ease, box-shadow 0.08s ease,
|
||||
background-color 0.08s ease;
|
||||
min-height: 44px; /* Better touch target for mobile */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.btn {
|
||||
padding: 0.55rem 1.2rem;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.full {
|
||||
@ -427,9 +578,16 @@ select {
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.8rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.recipe-actions {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
@ -480,16 +638,24 @@ select {
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: min(420px, 90vw);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #020617;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
padding: 1rem 1rem 1rem 1.2rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.drawer {
|
||||
width: min(420px, 90vw);
|
||||
padding: 1rem 1rem 1rem 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -564,17 +730,26 @@ select {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--card);
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.9);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal {
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
width: 90vw;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 1rem;
|
||||
@ -582,13 +757,26 @@ select {
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal-header h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal-body {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@ -689,18 +877,27 @@ select {
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 5rem;
|
||||
right: 1.5rem;
|
||||
bottom: 1rem;
|
||||
right: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.toast-container {
|
||||
bottom: 5rem;
|
||||
right: 1.5rem;
|
||||
left: auto;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.2rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
@ -709,8 +906,15 @@ select {
|
||||
gap: 0.8rem;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
pointer-events: auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.toast {
|
||||
padding: 1rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
@ -744,16 +948,16 @@ select {
|
||||
/* Theme Toggle (fixed floating button) */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
z-index: 100;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--card);
|
||||
color: var(--text-main);
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -762,6 +966,16 @@ select {
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.theme-toggle {
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
|
||||
@ -771,6 +985,90 @@ select {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Pinned notes toggle button - mobile only - styled like a mini sticky note */
|
||||
.pinned-toggle-btn {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 100;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f5e6c8;
|
||||
background: linear-gradient(135deg, #fff9e6 0%, #fffaed 100%);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
transition: all 180ms ease;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.pinned-toggle-btn::before {
|
||||
content: '📌';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 8px;
|
||||
font-size: 1rem;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
.pinned-toggle-btn:hover {
|
||||
transform: rotate(0deg) scale(1.05);
|
||||
box-shadow:
|
||||
0 4px 8px rgba(0, 0, 0, 0.15),
|
||||
0 8px 20px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.pinned-toggle-btn:active {
|
||||
transform: rotate(0deg) scale(0.95);
|
||||
}
|
||||
|
||||
.note-icon-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.note-icon-lines span {
|
||||
height: 2px;
|
||||
background: rgba(90, 74, 42, 0.4);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.note-icon-lines span:nth-child(1) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note-icon-lines span:nth-child(2) {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
.note-icon-lines span:nth-child(3) {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
/* Mobile only utility class */
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.mobile-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Update body to apply bg properly in both themes */
|
||||
body {
|
||||
background: var(--bg);
|
||||
|
||||
@ -12,7 +12,7 @@ import ToastContainer from "./components/ToastContainer";
|
||||
import ThemeToggle from "./components/ThemeToggle";
|
||||
import Login from "./components/Login";
|
||||
import Register from "./components/Register";
|
||||
import ChangePassword from "./components/ChangePassword";
|
||||
import ResetPassword from "./components/ResetPassword";
|
||||
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||
import { getToken, removeToken, getMe } from "./authApi";
|
||||
|
||||
@ -21,6 +21,7 @@ function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [authView, setAuthView] = useState("login"); // "login" or "register"
|
||||
const [loadingAuth, setLoadingAuth] = useState(true);
|
||||
const [resetToken, setResetToken] = useState(null);
|
||||
const [currentView, setCurrentView] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("currentView") || "recipes";
|
||||
@ -52,7 +53,6 @@ function App() {
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
const [logoutModal, setLogoutModal] = useState(false);
|
||||
const [changePasswordModal, setChangePasswordModal] = useState(false);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [theme, setTheme] = useState(() => {
|
||||
try {
|
||||
@ -61,10 +61,52 @@ function App() {
|
||||
return "dark";
|
||||
}
|
||||
});
|
||||
const [showPinnedSidebar, setShowPinnedSidebar] = useState(false);
|
||||
|
||||
// Swipe gesture handling for mobile sidebar
|
||||
const [touchStart, setTouchStart] = useState(null);
|
||||
const [touchEnd, setTouchEnd] = useState(null);
|
||||
|
||||
// Minimum swipe distance (in px)
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
const onTouchStart = (e) => {
|
||||
setTouchEnd(null);
|
||||
setTouchStart(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const onTouchMove = (e) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return;
|
||||
|
||||
const distance = touchStart - touchEnd;
|
||||
const isLeftSwipe = distance > minSwipeDistance;
|
||||
|
||||
if (isLeftSwipe) {
|
||||
setShowPinnedSidebar(false);
|
||||
}
|
||||
|
||||
setTouchStart(null);
|
||||
setTouchEnd(null);
|
||||
};
|
||||
|
||||
// Check authentication on mount
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
// Check for reset token in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const resetTokenParam = urlParams.get('reset_token');
|
||||
if (resetTokenParam) {
|
||||
setResetToken(resetTokenParam);
|
||||
// Clean URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
setLoadingAuth(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
try {
|
||||
@ -306,6 +348,22 @@ function App() {
|
||||
<div className="app-root">
|
||||
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
||||
|
||||
{/* Pinned notes toggle button - only visible on recipes view for authenticated users */}
|
||||
{isAuthenticated && currentView === "recipes" && (
|
||||
<button
|
||||
className="pinned-toggle-btn mobile-only"
|
||||
onClick={() => setShowPinnedSidebar(!showPinnedSidebar)}
|
||||
aria-label="הצג תזכירים"
|
||||
title="תזכירים נעוצים"
|
||||
>
|
||||
<span className="note-icon-lines">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* User greeting above TopBar */}
|
||||
{isAuthenticated && user && (
|
||||
<div className="user-greeting-header">
|
||||
@ -337,13 +395,12 @@ function App() {
|
||||
onAddClick={() => setDrawerOpen(true)}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
onChangePassword={() => setChangePasswordModal(true)}
|
||||
onShowToast={addToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show auth modal if needed */}
|
||||
{!isAuthenticated && authView !== null && (
|
||||
{!isAuthenticated && authView !== null && !resetToken && (
|
||||
<div className="drawer-backdrop" onClick={() => setAuthView(null)}>
|
||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{authView === "login" ? (
|
||||
@ -361,6 +418,26 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show reset password if token present */}
|
||||
{!isAuthenticated && resetToken && (
|
||||
<div className="drawer-backdrop">
|
||||
<div className="auth-modal">
|
||||
<ResetPassword
|
||||
token={resetToken}
|
||||
onSuccess={() => {
|
||||
setResetToken(null);
|
||||
setAuthView("login");
|
||||
addToast("הסיסמה עודכנה בהצלחה! כעת תוכל להתחבר עם הסיסמה החדשה", "success");
|
||||
}}
|
||||
onBack={() => {
|
||||
setResetToken(null);
|
||||
setAuthView("login");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<nav className="main-navigation">
|
||||
<button
|
||||
@ -384,9 +461,29 @@ function App() {
|
||||
) : (
|
||||
<>
|
||||
{isAuthenticated && (
|
||||
<aside className="pinned-lists-sidebar">
|
||||
<>
|
||||
<aside
|
||||
className={`pinned-lists-sidebar ${showPinnedSidebar ? 'mobile-visible' : ''}`}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<button
|
||||
className="close-sidebar-btn mobile-only"
|
||||
onClick={() => setShowPinnedSidebar(false)}
|
||||
aria-label="סגור תזכירים"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<PinnedGroceryLists onShowToast={addToast} />
|
||||
</aside>
|
||||
{showPinnedSidebar && (
|
||||
<div
|
||||
className="sidebar-backdrop mobile-only"
|
||||
onClick={() => setShowPinnedSidebar(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<section className="content-wrapper">
|
||||
<section className="content">
|
||||
@ -508,16 +605,6 @@ function App() {
|
||||
onCancel={() => setLogoutModal(false)}
|
||||
/>
|
||||
|
||||
{changePasswordModal && (
|
||||
<ChangePassword
|
||||
token={getToken()}
|
||||
onClose={() => setChangePasswordModal(false)}
|
||||
onSuccess={() => {
|
||||
addToast("הסיסמה שונתה בהצלחה", "success");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// Get API base from injected env.js or fallback to /api relative path
|
||||
const getApiBase = () => {
|
||||
export const getApiBase = () => {
|
||||
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||
return window.__ENV__.API_BASE;
|
||||
}
|
||||
|
||||
100
frontend/src/components/ForgotPassword.jsx
Normal file
100
frontend/src/components/ForgotPassword.jsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import { getApiBase } from "../api";
|
||||
|
||||
function ForgotPassword({ onBack }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/forgot-password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage(data.message);
|
||||
setEmail("");
|
||||
} else {
|
||||
setError(data.detail || "שגיאה בשליחת הבקשה");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("שגיאה בשליחת הבקשה");
|
||||
} 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>}
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
background: "var(--success-bg, #dcfce7)",
|
||||
border: "1px solid var(--success-border, #22c55e)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--success-text, #166534)",
|
||||
marginBottom: "1rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>כתובת מייל</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="הזן כתובת מייל"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary full-width"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "שולח..." : "שלח קישור לאיפוס"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
<p>
|
||||
נזכרת בסיסמה?{" "}
|
||||
<button className="link-btn" onClick={onBack}>
|
||||
חזור להתחברות
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgotPassword;
|
||||
@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { login, saveToken } from "../authApi";
|
||||
import ForgotPassword from "./ForgotPassword";
|
||||
|
||||
function Login({ onSuccess, onSwitchToRegister }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
|
||||
// Check for token in URL (from Google OAuth redirect)
|
||||
useEffect(() => {
|
||||
@ -45,6 +47,10 @@ function Login({ onSuccess, onSwitchToRegister }) {
|
||||
window.location.href = `${apiBase}/auth/azure/login`;
|
||||
};
|
||||
|
||||
if (showForgotPassword) {
|
||||
return <ForgotPassword onBack={() => setShowForgotPassword(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
@ -81,6 +87,17 @@ function Login({ onSuccess, onSwitchToRegister }) {
|
||||
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||
{loading ? "מתחבר..." : "התחבר"}
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: "center", marginTop: "0.75rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
>
|
||||
שכחת סיסמה?
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style={{
|
||||
|
||||
108
frontend/src/components/ResetPassword.jsx
Normal file
108
frontend/src/components/ResetPassword.jsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getApiBase } from "../api";
|
||||
|
||||
function ResetPassword({ token, onSuccess, onBack }) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("הסיסמאות אינן תואמות");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/reset-password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token,
|
||||
new_password: password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(data.detail || "שגיאה באיפוס הסיסמה");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("שגיאה באיפוס הסיסמה");
|
||||
} 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="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={onBack}>
|
||||
חזור להתחברות
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResetPassword;
|
||||
@ -1,6 +1,6 @@
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
function TopBar({ onAddClick, user, onLogout, onChangePassword, onShowToast }) {
|
||||
function TopBar({ onAddClick, user, onLogout, onShowToast }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
@ -13,21 +13,18 @@ function TopBar({ onAddClick, user, onLogout, onChangePassword, onShowToast }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<div className="topbar-actions">
|
||||
{user && <NotificationBell onShowToast={onShowToast} />}
|
||||
{user && (
|
||||
<button className="btn primary" onClick={onAddClick}>
|
||||
+ מתכון חדש
|
||||
</button>
|
||||
)}
|
||||
{onChangePassword && (
|
||||
<button className="btn ghost" onClick={onChangePassword}>
|
||||
🔒 שינוי סיסמה
|
||||
<button className="btn primary btn-mobile-compact" onClick={onAddClick}>
|
||||
<span className="btn-text-desktop">+ מתכון חדש</span>
|
||||
<span className="btn-text-mobile">+</span>
|
||||
</button>
|
||||
)}
|
||||
{onLogout && (
|
||||
<button className="btn ghost" onClick={onLogout}>
|
||||
יציאה
|
||||
<button className="btn ghost btn-mobile-compact" onClick={onLogout}>
|
||||
<span className="btn-text-desktop">יציאה</span>
|
||||
<span className="btn-text-mobile">↩️</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const API_URL = window.__ENV__?.API_BASE || window.ENV?.VITE_API_URL || "http://localhost:8000";
|
||||
const API_URL = window.__ENV__?.API_BASE || window.ENV?.VITE_API_URL || "http://192.168.1.100:8000";
|
||||
|
||||
// Get auth token from localStorage
|
||||
const getAuthHeaders = () => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const API_BASE_URL = window.__ENV__?.API_BASE || window._env_?.VITE_API_URL || import.meta.env.VITE_API_URL || "http://localhost:8000";
|
||||
const API_BASE_URL = window.__ENV__?.API_BASE || window._env_?.VITE_API_URL || import.meta.env.VITE_API_URL || "http://192.168.1.100:8000";
|
||||
|
||||
function getAuthHeaders() {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user