diff --git a/GOOGLE_OAUTH_SETUP.md b/GOOGLE_OAUTH_SETUP.md new file mode 100644 index 0000000..f6c905f --- /dev/null +++ b/GOOGLE_OAUTH_SETUP.md @@ -0,0 +1,110 @@ +# Google OAuth Setup Guide + +## Prerequisites +1. A Google Cloud Platform account +2. A project created in Google Cloud Console + +## Step 1: Create OAuth 2.0 Credentials + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Select your project (or create a new one) +3. Navigate to **APIs & Services** > **Credentials** +4. Click **Create Credentials** > **OAuth 2.0 Client ID** +5. If prompted, configure the OAuth consent screen: + - Choose **External** user type + - Fill in the required fields: + - App name: Tasko + - User support email: Your email + - Developer contact information: Your email + - Add scopes (optional): `userinfo.email`, `userinfo.profile` + - Add test users if needed +6. Select **Application type**: Web application +7. Add **Authorized redirect URIs**: + - For local development: `http://localhost:8000/auth/google/callback` + - For production: `https://your-domain.com/auth/google/callback` +8. Click **Create** +9. Copy the **Client ID** and **Client Secret** + +## Step 2: Configure Backend + +1. Copy `.env.example` to `.env` in the backend directory: + ```bash + cd backend + cp .env.example .env + ``` + +2. Edit `.env` and add your Google OAuth credentials: + ```env + GOOGLE_CLIENT_ID=your_client_id_here.apps.googleusercontent.com + GOOGLE_CLIENT_SECRET=your_client_secret_here + GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback + FRONTEND_URL=http://localhost:5173 + ``` + +3. For production, update the URLs: + ```env + GOOGLE_REDIRECT_URI=https://api.tasko.dvirlabs.com/auth/google/callback + FRONTEND_URL=https://tasko.dvirlabs.com + ``` + +## Step 3: Install Dependencies + +```bash +cd backend +pip install -r requirements.txt +``` + +## Step 4: Test the Setup + +1. Start the backend server: + ```bash + cd backend + uvicorn main:app --reload + ``` + +2. Start the frontend: + ```bash + cd frontend + npm run dev + ``` + +3. Open your browser and navigate to the frontend URL +4. Click "Continue with Google" button +5. Complete the Google authentication flow + +## Features Implemented + +### 1. Case-Insensitive Username Login +- Users can now login with usernames in any case (e.g., "Dvir", "dvir", "DVIR" all work) +- Registration checks for existing usernames case-insensitively to prevent duplicates + +### 2. Google OAuth Integration +- Users can sign up and login using their Google account +- New users are automatically created with: + - Username derived from email (before @) + - Email from Google account + - Default task lists (Personal, Work, Shopping) +- Existing users (matched by email) can login with Google +- Seamless redirect back to the application after authentication + +## Troubleshooting + +### "redirect_uri_mismatch" Error +- Ensure the redirect URI in Google Cloud Console exactly matches the one in your `.env` file +- Include the protocol (http/https), domain, port, and full path + +### "Access blocked" Error +- Add your email as a test user in the OAuth consent screen +- If published, ensure your app is verified by Google + +### "Invalid credentials" Error +- Check that your `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are correct +- Ensure there are no extra spaces or quotes in the `.env` file + +## Security Notes + +1. Never commit your `.env` file to version control +2. Keep your `GOOGLE_CLIENT_SECRET` secure +3. Use HTTPS in production +4. Regularly rotate your OAuth credentials +5. Review and limit the OAuth scopes to only what's needed diff --git a/SETUP_COMPLETE.md b/SETUP_COMPLETE.md new file mode 100644 index 0000000..b450e95 --- /dev/null +++ b/SETUP_COMPLETE.md @@ -0,0 +1,51 @@ +# Quick Google OAuth Setup Instructions + +Your Google OAuth credentials have been configured in the backend! + +## ✅ Already Configured +- Client ID: `143092846986-b7fv9kucjugh9h5ojq60e1e44em57n1h.apps.googleusercontent.com` +- Client Secret: `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` +- Backend `.env` file created +- All dependencies installed + +## 🔧 Required: Google Cloud Console Setup + +**IMPORTANT:** You need to add the redirect URI in your Google Cloud Console: + +1. Go to: https://console.cloud.google.com/apis/credentials +2. Find your OAuth 2.0 Client ID: `143092846986-b7fv9kucjugh9h5ojq60e1e44em57n1h` +3. Click **Edit** (pencil icon) +4. Under **Authorized redirect URIs**, add: + ``` + http://localhost:8000/auth/google/callback + ``` +5. Click **Save** + +## 🚀 Test the Application + +### Start Backend: +```bash +cd backend +python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Start Frontend (in another terminal): +```bash +cd frontend +npm run dev +``` + +### Test: +1. Open http://localhost:5173 +2. Try logging in with username (case-insensitive: "Dvir" = "dvir" = "DVIR") +3. Click **Continue with Google** to test OAuth login + +## 📝 Features Implemented +- ✅ Case-insensitive username login +- ✅ Google OAuth login/signup +- ✅ Automatic user creation for new Google accounts +- ✅ Secure credential storage in `.env` + +## ⚠️ Security Note +The `.env` file contains sensitive credentials and is excluded from git via `.gitignore`. +Never commit this file to your repository! diff --git a/backend/.env.example b/backend/.env.example index 84fc4b7..2d0d36d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,2 +1,11 @@ PORT=8001 HOST=0.0.0.0 + +# Database Configuration +DATABASE_URL=postgresql://tasko_user:tasko_password@localhost:5432/tasko_db + +# Google OAuth Configuration +GOOGLE_CLIENT_ID=your_google_client_id_here.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your_google_client_secret_here +GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback +FRONTEND_URL=http://localhost:5173 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..98916e9 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment variables +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Database +*.db +*.sqlite +*.sqlite3 + +# OS +.DS_Store +Thumbs.db diff --git a/backend/main.py b/backend/main.py index c73c5e9..5a652f4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,17 @@ from fastapi import FastAPI, HTTPException, Header, Depends from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse from pydantic import BaseModel, Field from typing import List, Optional from datetime import datetime from sqlalchemy.orm import Session +from authlib.integrations.starlette_client import OAuth import uuid import hashlib +import os +from dotenv import load_dotenv + +load_dotenv() from database import init_db, get_db import database as db_models @@ -15,6 +21,18 @@ app = FastAPI(title="Tasko API", version="1.0.0") # Initialize database init_db() +# OAuth Configuration +oauth = OAuth() +oauth.register( + name='google', + client_id=os.getenv('GOOGLE_CLIENT_ID', ''), + client_secret=os.getenv('GOOGLE_CLIENT_SECRET', ''), + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': 'openid email profile' + } +) + allowed_origins = ["http://localhost:5173", "https://tasko.dvirlabs.com"] # Configure CORS @@ -122,9 +140,9 @@ def read_root(): @app.post("/register", response_model=AuthResponse) def register(user_data: UserRegister, db: Session = Depends(get_db)): """Register a new user""" - # Check if username or email exists + # Check if username or email exists (case-insensitive for username) existing_user = db.query(db_models.User).filter( - (db_models.User.username == user_data.username) | + (db_models.User.username.ilike(user_data.username)) | (db_models.User.email == user_data.email) ).first() @@ -180,10 +198,10 @@ def login(user_data: UserLogin, db: Session = Depends(get_db)): """Login a user with username or email""" password_hash = hash_password(user_data.password) - # Try to find user by email or username + # Try to find user by email or username (case-insensitive for username) user = db.query(db_models.User).filter( ((db_models.User.email == user_data.username_or_email) | - (db_models.User.username == user_data.username_or_email)), + (db_models.User.username.ilike(user_data.username_or_email))), db_models.User.password_hash == password_hash ).first() @@ -212,6 +230,102 @@ def logout(authorization: Optional[str] = Header(None), db: Session = Depends(ge db.commit() return {"message": "Logged out successfully"} +@app.get("/auth/google") +async def google_login(): + """Initiate Google OAuth login""" + redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8000/auth/google/callback') + return await oauth.google.authorize_redirect(redirect_uri) + +@app.get("/auth/google/callback") +async def google_callback(code: str, db: Session = Depends(get_db)): + """Handle Google OAuth callback""" + try: + # Get access token from Google + token = await oauth.google.authorize_access_token() + + # Get user info from Google + user_info = token.get('userinfo') + if not user_info: + raise HTTPException(status_code=400, detail="Failed to get user info from Google") + + email = user_info.get('email') + google_id = user_info.get('sub') + name = user_info.get('name', email.split('@')[0]) + + # Check if user exists + user = db.query(db_models.User).filter(db_models.User.email == email).first() + + if not user: + # Create new user + user_id = str(uuid.uuid4()) + # Use email username part as username, make it unique if needed + username = email.split('@')[0] + counter = 1 + original_username = username + while db.query(db_models.User).filter(db_models.User.username.ilike(username)).first(): + username = f"{original_username}{counter}" + counter += 1 + + user = db_models.User( + id=user_id, + username=username, + email=email, + password_hash=hashlib.sha256(google_id.encode()).hexdigest() # Use Google ID as password hash + ) + db.add(user) + + # Create default lists for new user + default_lists = [ + {"name": "Personal", "icon": "🏠", "color": "#667eea"}, + {"name": "Work", "icon": "💼", "color": "#f093fb"}, + {"name": "Shopping", "icon": "🛒", "color": "#4facfe"}, + ] + + for list_data in default_lists: + list_id = str(uuid.uuid4()) + new_list = db_models.TaskList( + id=list_id, + user_id=user_id, + name=list_data["name"], + icon=list_data["icon"], + color=list_data["color"] + ) + db.add(new_list) + + db.commit() + db.refresh(user) + + # Create auth token + token_str = str(uuid.uuid4()) + new_token = db_models.Token(token=token_str, user_id=user.id) + db.add(new_token) + db.commit() + + # Redirect to frontend with token + frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173') + return RedirectResponse(url=f"{frontend_url}?token={token_str}&user={user.id}") + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}") + +@app.get("/auth/google/url") +def get_google_auth_url(): + """Get Google OAuth URL for frontend""" + redirect_uri = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8000/auth/google/callback') + authorization_url = f"https://accounts.google.com/o/oauth2/v2/auth?client_id={os.getenv('GOOGLE_CLIENT_ID', '')}&redirect_uri={redirect_uri}&response_type=code&scope=openid%20email%20profile&access_type=offline&prompt=consent" + return {"url": authorization_url} + +@app.get("/api/user", response_model=UserResponse) +def get_current_user(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)): + """Get current user details""" + user_id = verify_token(authorization, db) + user = db.query(db_models.User).filter(db_models.User.id == user_id).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserResponse.model_validate(user) + # Task List endpoints @app.get("/lists", response_model=List[TaskListResponse]) @app.get("/api/lists", response_model=List[TaskListResponse]) diff --git a/backend/requirements.txt b/backend/requirements.txt index c4bdd0a..b73a9a6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,8 @@ -fastapi==0.109.0 -uvicorn==0.27.0 -pydantic==2.5.3 -sqlalchemy==2.0.25 -psycopg2-binary==2.9.9 +fastapi>=0.109.0 +uvicorn>=0.27.0 +pydantic>=2.5.0 +sqlalchemy>=2.0.25 +psycopg2-binary>=2.9.9 +authlib>=1.3.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 diff --git a/frontend/src/Auth.css b/frontend/src/Auth.css index b813435..5874ca8 100644 --- a/frontend/src/Auth.css +++ b/frontend/src/Auth.css @@ -131,6 +131,61 @@ cursor: not-allowed; } +.divider { + display: flex; + align-items: center; + text-align: center; + margin: 0.5rem 0; +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid #e0e0e0; +} + +.divider span { + padding: 0 1rem; + color: #999; + font-size: 0.85rem; + font-weight: 600; +} + +.google-login-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 1rem 2rem; + background: white; + color: #333; + border: 2px solid #e0e0e0; + border-radius: 12px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.google-login-btn:hover:not(:disabled) { + background: #f8f9fa; + border-color: #d0d0d0; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.google-login-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.google-login-btn svg { + width: 24px; + height: 24px; +} + /* Mobile responsive styles */ @media (max-width: 768px) { .auth-container { diff --git a/frontend/src/Auth.jsx b/frontend/src/Auth.jsx index 14360d7..4e5ad8f 100644 --- a/frontend/src/Auth.jsx +++ b/frontend/src/Auth.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import './Auth.css' const API_URL = import.meta.env.VITE_API_URL || 'http://10.188.50.221:8000' @@ -12,6 +12,34 @@ function Auth({ onLogin }) { const [error, setError] = useState('') const [loading, setLoading] = useState(false) + // Check for Google OAuth callback + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search) + const token = urlParams.get('token') + const userId = urlParams.get('user') + + if (token && userId) { + // Fetch user details with token + fetch(`${API_URL}/api/user`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + .then(res => res.json()) + .then(user => { + localStorage.setItem('token', token) + localStorage.setItem('user', JSON.stringify(user)) + onLogin(user, token) + // Clean up URL + window.history.replaceState({}, document.title, '/') + }) + .catch(err => { + console.error('Failed to fetch user:', err) + setError('Failed to complete Google login') + }) + } + }, [onLogin]) + const handleSubmit = async (e) => { e.preventDefault() setError('') @@ -62,6 +90,27 @@ function Auth({ onLogin }) { } } + const handleGoogleLogin = async () => { + setLoading(true) + setError('') + + try { + const response = await fetch(`${API_URL}/auth/google/url`) + const data = await response.json() + + if (data.url) { + window.location.href = data.url + } else { + setError('Failed to initiate Google login') + setLoading(false) + } + } catch (error) { + setError('Failed to connect to Google login') + setLoading(false) + console.error('Google login error:', error) + } + } + return (
@@ -143,6 +192,25 @@ function Auth({ onLogin }) { + +
+ OR +
+ +