diff --git a/backend/.env b/backend/.env
index d78138b..0c20f81 100644
--- a/backend/.env
+++ b/backend/.env
@@ -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
\ No newline at end of file
+AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback
\ No newline at end of file
diff --git a/backend/.env.local b/backend/.env.local
new file mode 100644
index 0000000..e2ef4b4
--- /dev/null
+++ b/backend/.env.local
@@ -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
diff --git a/backend/MIGRATION_INSTRUCTIONS.md b/backend/MIGRATION_INSTRUCTIONS.md
new file mode 100644
index 0000000..e283be3
--- /dev/null
+++ b/backend/MIGRATION_INSTRUCTIONS.md
@@ -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.
diff --git a/backend/add_auth_provider_column.sql b/backend/add_auth_provider_column.sql
new file mode 100644
index 0000000..2a03518
--- /dev/null
+++ b/backend/add_auth_provider_column.sql
@@ -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;
diff --git a/backend/email_utils.py b/backend/email_utils.py
index e721713..bf2f890 100644
--- a/backend/email_utils.py
+++ b/backend/email_utils.py
@@ -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"""
+
+
+ איפוס סיסמה
+ קיבלנו בקשה לאיפוס הסיסמה שלך.
+ לחץ על הכפתור למטה כדי לאפס את הסיסמה:
+
+
+ הקישור תקף ל-30 דקות.
+
+
+
+ אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
+
+
+
+ """
+
+ 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]
diff --git a/backend/main.py b/backend/main.py
index 2216857..880a062 100644
--- a/backend/main.py
+++ b/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)
\ No newline at end of file
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
\ No newline at end of file
diff --git a/backend/user_db_utils.py b/backend/user_db_utils.py
index fd4af0c..a168d59 100644
--- a/backend/user_db_utils.py
+++ b/backend/user_db_utils.py
@@ -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()
diff --git a/frontend/index.html b/frontend/index.html
index ba57990..a6c3b1f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -7,7 +7,7 @@
My Recipes | המתכונים שלי
-
+
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 9bea9c0..d97c31e 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -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 1.2rem;
- margin-bottom: 1.6rem;
+ 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;
+ }
+}
+
.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: 1.2rem;
+ 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,16 +730,25 @@ 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: 90vw;
+ width: 100%;
+}
+
+@media (min-width: 768px) {
+ .modal {
+ border-radius: 18px;
+ padding: 1.5rem;
+ width: 90vw;
+ }
}
.modal-header {
@@ -582,13 +757,26 @@ select {
.modal-header h2 {
margin: 0;
- font-size: 1.1rem;
+ 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,7 +906,14 @@ select {
gap: 0.8rem;
animation: slideIn 0.3s ease-out;
pointer-events: auto;
- font-size: 0.9rem;
+ font-size: 0.85rem;
+}
+
+@media (min-width: 768px) {
+ .toast {
+ padding: 1rem 1.2rem;
+ font-size: 0.9rem;
+ }
}
.toast.success {
@@ -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);
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 6cc3749..c140a67 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
+ {/* Pinned notes toggle button - only visible on recipes view for authenticated users */}
+ {isAuthenticated && currentView === "recipes" && (
+
+ )}
+
{/* User greeting above TopBar */}
{isAuthenticated && user && (
@@ -336,14 +394,13 @@ function App() {
setDrawerOpen(true)}
user={user}
- onLogout={handleLogout}
- onChangePassword={() => setChangePasswordModal(true)}
+ onLogout={handleLogout}
onShowToast={addToast}
/>
)}
{/* Show auth modal if needed */}
- {!isAuthenticated && authView !== null && (
+ {!isAuthenticated && authView !== null && !resetToken && (
setAuthView(null)}>
e.stopPropagation()}>
{authView === "login" ? (
@@ -361,6 +418,26 @@ function App() {
)}
+ {/* Show reset password if token present */}
+ {!isAuthenticated && resetToken && (
+
+
+ {
+ setResetToken(null);
+ setAuthView("login");
+ addToast("הסיסמה עודכנה בהצלחה! כעת תוכל להתחבר עם הסיסמה החדשה", "success");
+ }}
+ onBack={() => {
+ setResetToken(null);
+ setAuthView("login");
+ }}
+ />
+
+
+ )}
+
{isAuthenticated && (