Seth commited on
Commit ·
f7686fe
1
Parent(s): cb49f6a
update
Browse files- SETUP.md +18 -0
- backend/app/auth_routes.py +146 -0
- backend/app/main.py +24 -1
- backend/requirements.txt +3 -1
- frontend/src/components/layout/AppShell.jsx +5 -1
- frontend/src/components/layout/GoogleAuthBar.jsx +142 -0
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">
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|