diff --git a/backend/api/app/config.py b/backend/api/app/config.py index bab5e44..48cc493 100644 --- a/backend/api/app/config.py +++ b/backend/api/app/config.py @@ -44,6 +44,10 @@ class Settings(BaseSettings): APPLE_SHARED_SECRET: str = "" # App Store Connect shared secret GOOGLE_SERVICE_ACCOUNT_JSON: str = "" # JSON string of service account key + # OAuth + GOOGLE_CLIENT_ID: str = "" + GOOGLE_CLIENT_SECRET: str = "" + # Feature flags ENABLE_AI_INSIGHTS: bool = True diff --git a/backend/api/app/routers/users.py b/backend/api/app/routers/users.py index 7e3e4eb..eac8843 100644 --- a/backend/api/app/routers/users.py +++ b/backend/api/app/routers/users.py @@ -6,9 +6,11 @@ from sqlalchemy import select from ..database import get_db from ..models import User -from ..schemas.user import UserCreate, UserLogin, UserUpdate, UserResponse, TokenResponse, ForgotPasswordRequest +from ..schemas.user import UserCreate, UserLogin, UserUpdate, UserResponse, TokenResponse, ForgotPasswordRequest, GoogleLoginRequest from ..services.auth import hash_password, verify_password, create_access_token, get_current_user +from ..config import get_settings import uuid +import httpx from datetime import datetime, timedelta router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -51,6 +53,79 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): ) +@router.post("/google", response_model=TokenResponse) +async def google_login(login_data: GoogleLoginRequest, db: AsyncSession = Depends(get_db)): + """Login with Google.""" + settings = get_settings() + + # Exchange code for tokens + async with httpx.AsyncClient() as client: + token_response = await client.post( + "https://oauth2.googleapis.com/token", + data={ + "code": login_data.code, + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": login_data.redirect_uri or "postmessage", + "grant_type": "authorization_code", + }, + ) + + if token_response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Google credentials", + ) + + token_data = token_response.json() + access_token = token_data.get("access_token") + + # Get user info + user_info_response = await client.get( + "https://www.googleapis.com/oauth2/v2/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + if user_info_response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Failed to get user info from Google", + ) + + user_info = user_info_response.json() + email = user_info.get("email") + + if not email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Google account must have an email", + ) + + # Find or create user + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if not user: + user = User( + email=email, + full_name=user_info.get("name"), + platform="web", + is_active=True, + is_verified=True, + ) + db.add(user) + await db.commit() + await db.refresh(user) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + user=UserResponse.model_validate(user) + ) + + @router.post("/forgot-password", status_code=status.HTTP_200_OK) async def forgot_password( request: ForgotPasswordRequest, diff --git a/backend/api/app/schemas/user.py b/backend/api/app/schemas/user.py index dcd8cc7..b3a0cb1 100644 --- a/backend/api/app/schemas/user.py +++ b/backend/api/app/schemas/user.py @@ -22,7 +22,7 @@ class UserCreate(BaseModel): email: EmailStr password: str = Field(..., min_length=8, max_length=100) full_name: Optional[str] = Field(None, max_length=255) - platform: str = Field(..., pattern="^(ios|android)$") + platform: str = Field(..., pattern="^(ios|android|web)$") device_token: Optional[str] = None @field_validator("password") @@ -44,6 +44,12 @@ class UserLogin(BaseModel): password: str +class GoogleLoginRequest(BaseModel): + """Schema for Google login.""" + code: str + redirect_uri: Optional[str] = None + + class ForgotPasswordRequest(BaseModel): """Schema for forgot password request.""" email: EmailStr diff --git a/web-demo/index.html b/web-demo/index.html index 960b612..a721c3e 100644 --- a/web-demo/index.html +++ b/web-demo/index.html @@ -307,6 +307,7 @@

Preferences

+ \ No newline at end of file diff --git a/web-demo/js/api.js b/web-demo/js/api.js index 55f97d7..2ff8834 100644 --- a/web-demo/js/api.js +++ b/web-demo/js/api.js @@ -17,7 +17,7 @@ class APIClient { async request(endpoint, options = {}) { // Use mock data for demo mode - if (this.useMockData) { + if (this.useMockData && !options.forceReal) { return this.getMockData(endpoint); } @@ -180,6 +180,17 @@ class APIClient { return response; } + async googleLogin(code) { + const response = await this.request('/api/auth/google', { + method: 'POST', + auth: false, + body: JSON.stringify({ code }), + forceReal: true + }); + this.setToken(response.access_token); + return response; + } + async getCurrentUser() { return await this.request('/users/me'); } diff --git a/web-demo/js/app.js b/web-demo/js/app.js index 65e7352..135c030 100644 --- a/web-demo/js/app.js +++ b/web-demo/js/app.js @@ -180,39 +180,35 @@ class ClimaAI { } async handleGoogleSignIn() { + if (typeof google === 'undefined' || !google.accounts) { + this.showToast('Google Sign-In script not loaded', 'error'); + return; + } + try { this.showToast('🔐 Signing in with Google...', 'info'); - // In production, this would trigger Google OAuth flow: - // 1. Redirect to Google OAuth consent screen - // 2. User grants permissions - // 3. Google redirects back with authorization code - // 4. Backend exchanges code for tokens - // 5. Backend creates/updates user and returns JWT - - // For demo purposes, we'll simulate successful OAuth with demo account - setTimeout(async () => { - try { - // Auto-login with demo account - const response = await api.login('demo@climaai.com', 'Test1234'); - this.user = response.user; - this.showToast('✅ Welcome! Signed in with Google', 'success'); - this.showScreen('homeScreen'); - this.loadWeatherData(); - this.checkSubscription(); - } catch (error) { - this.showToast('Google Sign-In succeeded! Welcome!', 'success'); - // Create a demo user object - this.user = { - email: 'google-user@gmail.com', - full_name: 'Google User', - is_premium: true - }; - this.isPremium = true; - this.showScreen('homeScreen'); - this.loadWeatherData(); - } - }, 1500); // Simulate OAuth redirect delay + const client = google.accounts.oauth2.initCodeClient({ + client_id: 'YOUR_GOOGLE_CLIENT_ID_HERE', + scope: 'email profile', + ux_mode: 'popup', + callback: async (response) => { + if (response.code) { + try { + const apiResponse = await api.googleLogin(response.code); + this.user = apiResponse.user; + this.showToast('✅ Welcome! Signed in with Google', 'success'); + this.showScreen('homeScreen'); + this.loadWeatherData(); + this.checkSubscription(); + } catch (error) { + this.showToast(error.message || 'Google Sign-In failed', 'error'); + } + } + }, + }); + + client.requestCode(); } catch (error) { this.showToast(error.message || 'Google Sign-In failed', 'error');