Seth commited on
Commit
f7686fe
·
1 Parent(s): cb49f6a
SETUP.md CHANGED
@@ -45,6 +45,24 @@
45
  export OPENAI_API_KEY=your_api_key_here
46
  ```
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  3. **Run Backend**:
49
  ```bash
50
  cd backend
 
45
  export OPENAI_API_KEY=your_api_key_here
46
  ```
47
 
48
+ **Google Sign-In (optional)** — create an OAuth **Web application** client in [Google Cloud Console](https://console.cloud.google.com/apis/credentials) (APIs & Services → Credentials). Under **Authorized redirect URIs** add exactly:
49
+ - Local (Vite dev): `http://localhost:5173/api/auth/google/callback`
50
+ - Docker / Hugging Face / your public URL: `https://<your-host>/api/auth/google/callback`
51
+
52
+ Then set:
53
+ ```bash
54
+ export GOOGLE_CLIENT_ID=...
55
+ export GOOGLE_CLIENT_SECRET=...
56
+ # Must match the redirect URI above (recommended for dev):
57
+ export FRONTEND_ORIGIN=http://localhost:5173
58
+ # Or set the full callback explicitly:
59
+ # export GOOGLE_REDIRECT_URI=http://localhost:5173/api/auth/google/callback
60
+ # Strong random secret for signed session cookies (required in production):
61
+ export SESSION_SECRET=$(openssl rand -hex 32)
62
+ ```
63
+
64
+ For HTTPS deployments (e.g. Hugging Face Spaces), also set `HTTPS_ONLY_COOKIES=1` and add your public origin to `CORS_ORIGINS` if the browser origin differs from the API host.
65
+
66
  3. **Run Backend**:
67
  ```bash
68
  cd backend
backend/app/auth_routes.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google OAuth2 (OpenID Connect) sign-in: authorization code flow with server-side session cookie.
3
+ Configure GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and either GOOGLE_REDIRECT_URI or FRONTEND_ORIGIN.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import secrets
10
+ from urllib.parse import urlencode
11
+
12
+ import httpx
13
+ from fastapi import APIRouter, HTTPException, Request
14
+ from fastapi.responses import RedirectResponse
15
+ from google.auth.transport import requests as google_auth_requests
16
+ from google.oauth2 import id_token
17
+
18
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
19
+
20
+ GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth"
21
+ GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
22
+
23
+
24
+ def _client_configured() -> bool:
25
+ return bool(os.environ.get("GOOGLE_CLIENT_ID", "").strip() and os.environ.get("GOOGLE_CLIENT_SECRET", "").strip())
26
+
27
+
28
+ def _redirect_uri(request: Request) -> str:
29
+ """Callback URL; must match an entry in Google Cloud Console → OAuth client."""
30
+ explicit = os.environ.get("GOOGLE_REDIRECT_URI", "").strip()
31
+ if explicit:
32
+ return explicit
33
+ fe = os.environ.get("FRONTEND_ORIGIN", "").strip()
34
+ if fe:
35
+ return fe.rstrip("/") + "/api/auth/google/callback"
36
+ return str(request.base_url).rstrip("/") + "/api/auth/google/callback"
37
+
38
+
39
+ def _post_login_url(request: Request) -> str:
40
+ fe = os.environ.get("FRONTEND_ORIGIN", "").strip()
41
+ if fe:
42
+ return fe.rstrip("/") + "/"
43
+ return str(request.base_url).rstrip("/") + "/"
44
+
45
+
46
+ @router.get("/status")
47
+ async def auth_status():
48
+ return {"googleConfigured": _client_configured()}
49
+
50
+
51
+ @router.get("/me")
52
+ async def auth_me(request: Request):
53
+ user = request.session.get("user")
54
+ if not user:
55
+ raise HTTPException(status_code=401, detail="Not signed in")
56
+ return user
57
+
58
+
59
+ @router.post("/logout")
60
+ async def auth_logout(request: Request):
61
+ request.session.clear()
62
+ return {"ok": True}
63
+
64
+
65
+ @router.get("/google")
66
+ async def google_login(request: Request):
67
+ if not _client_configured():
68
+ raise HTTPException(
69
+ status_code=503,
70
+ detail="Google sign-in is not configured (set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET).",
71
+ )
72
+ client_id = os.environ["GOOGLE_CLIENT_ID"].strip()
73
+ state = secrets.token_urlsafe(32)
74
+ redirect_uri = _redirect_uri(request)
75
+ request.session["google_oauth_state"] = state
76
+ request.session["google_oauth_redirect_uri"] = redirect_uri
77
+ params = {
78
+ "client_id": client_id,
79
+ "redirect_uri": redirect_uri,
80
+ "response_type": "code",
81
+ "scope": "openid email profile",
82
+ "state": state,
83
+ "access_type": "online",
84
+ "include_granted_scopes": "true",
85
+ }
86
+ return RedirectResponse(url=f"{GOOGLE_AUTH_URI}?{urlencode(params)}")
87
+
88
+
89
+ @router.get("/google/callback")
90
+ async def google_callback(
91
+ request: Request,
92
+ code: str | None = None,
93
+ state: str | None = None,
94
+ error: str | None = None,
95
+ ):
96
+ dest = _post_login_url(request)
97
+ if error:
98
+ return RedirectResponse(url=f"{dest}?auth_error={error}")
99
+ if not code or not state:
100
+ raise HTTPException(status_code=400, detail="Missing code or state")
101
+ expected = request.session.pop("google_oauth_state", None)
102
+ redirect_uri = request.session.pop("google_oauth_redirect_uri", None) or _redirect_uri(request)
103
+ if not expected or state != expected:
104
+ raise HTTPException(status_code=400, detail="Invalid OAuth state")
105
+ if not _client_configured():
106
+ raise HTTPException(status_code=503, detail="Google sign-in is not configured")
107
+
108
+ client_id = os.environ["GOOGLE_CLIENT_ID"].strip()
109
+ client_secret = os.environ["GOOGLE_CLIENT_SECRET"].strip()
110
+
111
+ async with httpx.AsyncClient() as client:
112
+ token_res = await client.post(
113
+ GOOGLE_TOKEN_URI,
114
+ data={
115
+ "code": code,
116
+ "client_id": client_id,
117
+ "client_secret": client_secret,
118
+ "redirect_uri": redirect_uri,
119
+ "grant_type": "authorization_code",
120
+ },
121
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
122
+ )
123
+ if token_res.status_code != 200:
124
+ raise HTTPException(status_code=502, detail="Token exchange failed")
125
+ tokens = token_res.json()
126
+ id_tok = tokens.get("id_token")
127
+ if not id_tok:
128
+ raise HTTPException(status_code=502, detail="No id_token in response")
129
+
130
+ try:
131
+ id_info = id_token.verify_oauth2_token(
132
+ id_tok,
133
+ google_auth_requests.Request(),
134
+ client_id,
135
+ )
136
+ except ValueError as e:
137
+ raise HTTPException(status_code=400, detail=f"Invalid id token: {e}") from e
138
+
139
+ request.session["user"] = {
140
+ "sub": id_info.get("sub"),
141
+ "email": id_info.get("email"),
142
+ "name": id_info.get("name"),
143
+ "picture": id_info.get("picture"),
144
+ "email_verified": id_info.get("email_verified"),
145
+ }
146
+ return RedirectResponse(url=dest)
backend/app/main.py CHANGED
@@ -2,6 +2,7 @@ from fastapi import FastAPI, UploadFile, File, Depends, HTTPException, Query, Re
2
  from fastapi.responses import FileResponse, StreamingResponse
3
  from fastapi.staticfiles import StaticFiles
4
  from fastapi.middleware.cors import CORSMiddleware
 
5
  from pathlib import Path
6
  from sqlalchemy.orm import Session
7
  from sqlalchemy import func, or_
@@ -45,17 +46,39 @@ from .models import (
45
  )
46
  from .gpt_service import generate_email_sequence, enrich_manual_contact_profile
47
  from .smartlead_client import SmartleadClient
 
48
 
49
  app = FastAPI()
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  app.add_middleware(
52
  CORSMiddleware,
53
- allow_origins=["*"],
54
  allow_credentials=True,
55
  allow_methods=["*"],
56
  allow_headers=["*"],
57
  )
58
 
 
 
59
  # Create uploads directory
60
  UPLOAD_DIR = Path("/data/uploads")
61
  UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
 
2
  from fastapi.responses import FileResponse, StreamingResponse
3
  from fastapi.staticfiles import StaticFiles
4
  from fastapi.middleware.cors import CORSMiddleware
5
+ from starlette.middleware.sessions import SessionMiddleware
6
  from pathlib import Path
7
  from sqlalchemy.orm import Session
8
  from sqlalchemy import func, or_
 
46
  )
47
  from .gpt_service import generate_email_sequence, enrich_manual_contact_profile
48
  from .smartlead_client import SmartleadClient
49
+ from .auth_routes import router as auth_router
50
 
51
  app = FastAPI()
52
 
53
+ # Session cookie for Google OAuth (must be before CORS if using credentials across origins)
54
+ _session_secret = os.environ.get("SESSION_SECRET", "dev-session-secret-change-in-production")
55
+ _is_https = os.environ.get("HTTPS_ONLY_COOKIES", "").strip() in ("1", "true", "yes")
56
+ app.add_middleware(
57
+ SessionMiddleware,
58
+ secret_key=_session_secret,
59
+ same_site="lax",
60
+ https_only=_is_https,
61
+ max_age=14 * 24 * 60 * 60,
62
+ )
63
+
64
+ _cors_raw = os.environ.get(
65
+ "CORS_ORIGINS",
66
+ "http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://127.0.0.1:3000",
67
+ )
68
+ _cors_origins = [o.strip() for o in _cors_raw.split(",") if o.strip()]
69
+ if not _cors_origins:
70
+ _cors_origins = ["*"]
71
+
72
  app.add_middleware(
73
  CORSMiddleware,
74
+ allow_origins=_cors_origins,
75
  allow_credentials=True,
76
  allow_methods=["*"],
77
  allow_headers=["*"],
78
  )
79
 
80
+ app.include_router(auth_router)
81
+
82
  # Create uploads directory
83
  UPLOAD_DIR = Path("/data/uploads")
84
  UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
backend/requirements.txt CHANGED
@@ -7,4 +7,6 @@ aiofiles
7
  sqlalchemy
8
  requests
9
  duckduckgo-search>=6.3.0
10
- google-genai>=1.5.0
 
 
 
7
  sqlalchemy
8
  requests
9
  duckduckgo-search>=6.3.0
10
+ google-genai>=1.5.0
11
+ httpx
12
+ google-auth
frontend/src/components/layout/AppShell.jsx CHANGED
@@ -11,6 +11,7 @@ import {
11
  } from 'lucide-react';
12
  import { Button } from '@/components/ui/button';
13
  import { cn } from '@/lib/utils';
 
14
 
15
  const NAV_ITEMS = [
16
  { label: 'Generator', href: '/', icon: LayoutDashboard },
@@ -81,7 +82,10 @@ export default function AppShell({ title, subtitle, rightContent, children }) {
81
  <h2 className="text-xl font-bold text-slate-800">{title}</h2>
82
  {subtitle && <p className="text-sm text-slate-500">{subtitle}</p>}
83
  </div>
84
- <div className="flex shrink-0 items-center gap-2">{rightContent}</div>
 
 
 
85
  </div>
86
  </div>
87
  <nav className="flex items-center gap-2 border-t border-slate-100 bg-white/90 px-4 py-2 md:hidden">
 
11
  } from 'lucide-react';
12
  import { Button } from '@/components/ui/button';
13
  import { cn } from '@/lib/utils';
14
+ import GoogleAuthBar from '@/components/layout/GoogleAuthBar';
15
 
16
  const NAV_ITEMS = [
17
  { label: 'Generator', href: '/', icon: LayoutDashboard },
 
82
  <h2 className="text-xl font-bold text-slate-800">{title}</h2>
83
  {subtitle && <p className="text-sm text-slate-500">{subtitle}</p>}
84
  </div>
85
+ <div className="flex shrink-0 items-center gap-2">
86
+ <GoogleAuthBar />
87
+ {rightContent}
88
+ </div>
89
  </div>
90
  </div>
91
  <nav className="flex items-center gap-2 border-t border-slate-100 bg-white/90 px-4 py-2 md:hidden">
frontend/src/components/layout/GoogleAuthBar.jsx ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+ import { LogOut } from 'lucide-react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { cn } from '@/lib/utils';
6
+
7
+ /**
8
+ * Header sign-in: session cookie set by GET /api/auth/google → Google → /api/auth/google/callback.
9
+ */
10
+ export default function GoogleAuthBar() {
11
+ const [searchParams, setSearchParams] = useSearchParams();
12
+ const [phase, setPhase] = useState('loading'); // loading | ready | error
13
+ const [googleOn, setGoogleOn] = useState(false);
14
+ const [user, setUser] = useState(null);
15
+
16
+ const refresh = useCallback(async () => {
17
+ try {
18
+ const [st, me] = await Promise.all([
19
+ fetch('/api/auth/status').then((r) => r.json()),
20
+ fetch('/api/auth/me', { credentials: 'include' }).then((r) =>
21
+ r.ok ? r.json() : null
22
+ ),
23
+ ]);
24
+ setGoogleOn(!!st.googleConfigured);
25
+ setUser(me);
26
+ setPhase('ready');
27
+ } catch {
28
+ setPhase('error');
29
+ }
30
+ }, []);
31
+
32
+ useEffect(() => {
33
+ refresh();
34
+ }, [refresh]);
35
+
36
+ useEffect(() => {
37
+ const err = searchParams.get('auth_error');
38
+ if (!err) return;
39
+ if (err === 'access_denied') {
40
+ console.info('Google sign-in was cancelled.');
41
+ } else {
42
+ console.warn('Google sign-in error:', err);
43
+ }
44
+ const next = new URLSearchParams(searchParams);
45
+ next.delete('auth_error');
46
+ setSearchParams(next, { replace: true });
47
+ }, [searchParams, setSearchParams]);
48
+
49
+ const logout = async () => {
50
+ try {
51
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
52
+ setUser(null);
53
+ } catch (e) {
54
+ console.error(e);
55
+ }
56
+ };
57
+
58
+ if (phase === 'loading' || (phase === 'ready' && !googleOn)) {
59
+ if (phase === 'loading') {
60
+ return (
61
+ <div
62
+ className="h-9 w-24 shrink-0 rounded-md bg-slate-100/80 animate-pulse"
63
+ aria-hidden
64
+ />
65
+ );
66
+ }
67
+ return null;
68
+ }
69
+
70
+ if (phase === 'error') {
71
+ return null;
72
+ }
73
+
74
+ if (user) {
75
+ return (
76
+ <div className="flex max-w-[min(100vw-8rem,20rem)] items-center gap-2">
77
+ {user.picture ? (
78
+ <img
79
+ src={user.picture}
80
+ alt=""
81
+ className="h-8 w-8 shrink-0 rounded-full border border-slate-200 object-cover"
82
+ referrerPolicy="no-referrer"
83
+ />
84
+ ) : null}
85
+ <span className="hidden min-w-0 truncate text-sm text-slate-600 sm:inline">
86
+ {user.name || user.email || 'Signed in'}
87
+ </span>
88
+ <Button
89
+ type="button"
90
+ variant="outline"
91
+ size="sm"
92
+ className="shrink-0 gap-1"
93
+ onClick={logout}
94
+ title="Sign out"
95
+ >
96
+ <LogOut className="h-3.5 w-3.5" />
97
+ <span className="hidden sm:inline">Sign out</span>
98
+ </Button>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <Button
105
+ asChild
106
+ variant="outline"
107
+ size="sm"
108
+ className={cn(
109
+ 'shrink-0 gap-2 border-slate-200 bg-white text-slate-800 shadow-sm',
110
+ 'hover:bg-slate-50'
111
+ )}
112
+ >
113
+ <a href="/api/auth/google" className="inline-flex items-center gap-2">
114
+ <GoogleMark className="h-4 w-4 shrink-0" />
115
+ <span className="whitespace-nowrap">Sign in with Google</span>
116
+ </a>
117
+ </Button>
118
+ );
119
+ }
120
+
121
+ function GoogleMark({ className }) {
122
+ return (
123
+ <svg className={className} viewBox="0 0 24 24" aria-hidden>
124
+ <path
125
+ fill="#4285F4"
126
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
127
+ />
128
+ <path
129
+ fill="#34A853"
130
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
131
+ />
132
+ <path
133
+ fill="#FBBC05"
134
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
135
+ />
136
+ <path
137
+ fill="#EA4335"
138
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
139
+ />
140
+ </svg>
141
+ );
142
+ }