From 32707889029b9c77408d34051672862176897643 Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Fri, 19 Dec 2025 04:13:20 +0200 Subject: [PATCH] Adapt to mobile, handle forget password and add auth_provider to DB --- backend/.env | 6 +- backend/.env.local | 28 ++ backend/MIGRATION_INSTRUCTIONS.md | 21 ++ backend/add_auth_provider_column.sql | 30 ++ backend/email_utils.py | 105 ++++++ backend/main.py | 125 +++++++- backend/user_db_utils.py | 31 +- frontend/index.html | 2 +- frontend/src/App.css | 356 +++++++++++++++++++-- frontend/src/App.jsx | 123 +++++-- frontend/src/api.js | 2 +- frontend/src/components/ForgotPassword.jsx | 100 ++++++ frontend/src/components/Login.jsx | 17 + frontend/src/components/ResetPassword.jsx | 108 +++++++ frontend/src/components/TopBar.jsx | 19 +- frontend/src/groceryApi.js | 2 +- frontend/src/notificationApi.js | 2 +- 17 files changed, 993 insertions(+), 84 deletions(-) create mode 100644 backend/.env.local create mode 100644 backend/MIGRATION_INSTRUCTIONS.md create mode 100644 backend/add_auth_provider_column.sql create mode 100644 frontend/src/components/ForgotPassword.jsx create mode 100644 frontend/src/components/ResetPassword.jsx 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 && (