Seth commited on
Commit ·
5ff7dfd
1
Parent(s): 8134b4e
update
Browse files- backend/app/auth_routes.py +23 -3
- backend/app/database.py +7 -0
- backend/app/gmail_invite.py +71 -0
- backend/app/tenant_routes.py +56 -2
- frontend/src/pages/Settings.jsx +33 -4
backend/app/auth_routes.py
CHANGED
|
@@ -247,12 +247,18 @@ async def auth_me(request: Request, db: Session = Depends(get_db)):
|
|
| 247 |
cur_tid = int(t0.id)
|
| 248 |
current_role = m0.role
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
return {
|
| 251 |
**profile,
|
| 252 |
"user_id": uid,
|
| 253 |
"tenants": tenants_out,
|
| 254 |
"current_tenant_id": int(cur_tid) if cur_tid is not None else None,
|
| 255 |
"current_role": current_role,
|
|
|
|
| 256 |
}
|
| 257 |
|
| 258 |
|
|
@@ -285,8 +291,16 @@ async def auth_switch_tenant(
|
|
| 285 |
return {"ok": True, "tenant_id": body.tenant_id, "role": m.role}
|
| 286 |
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
@router.get("/google")
|
| 289 |
-
async def google_login(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
if not _client_configured():
|
| 291 |
raise HTTPException(
|
| 292 |
status_code=503,
|
|
@@ -303,11 +317,14 @@ async def google_login(request: Request, invite: str | None = None):
|
|
| 303 |
"client_id": client_id,
|
| 304 |
"redirect_uri": redirect_uri,
|
| 305 |
"response_type": "code",
|
| 306 |
-
"scope":
|
| 307 |
"state": state,
|
| 308 |
-
"access_type": "
|
| 309 |
"include_granted_scopes": "true",
|
| 310 |
}
|
|
|
|
|
|
|
|
|
|
| 311 |
return RedirectResponse(url=f"{GOOGLE_AUTH_URI}?{urlencode(params)}")
|
| 312 |
|
| 313 |
|
|
@@ -391,6 +408,9 @@ async def google_callback(
|
|
| 391 |
"picture": id_info.get("picture"),
|
| 392 |
"email_verified": id_info.get("email_verified"),
|
| 393 |
}
|
|
|
|
|
|
|
|
|
|
| 394 |
db.commit()
|
| 395 |
except Exception:
|
| 396 |
db.rollback()
|
|
|
|
| 247 |
cur_tid = int(t0.id)
|
| 248 |
current_role = m0.role
|
| 249 |
|
| 250 |
+
urow = db.query(User).filter(User.id == uid).first()
|
| 251 |
+
gmail_invites_ready = bool(
|
| 252 |
+
urow and getattr(urow, "google_refresh_token", None) and str(urow.google_refresh_token).strip()
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
return {
|
| 256 |
**profile,
|
| 257 |
"user_id": uid,
|
| 258 |
"tenants": tenants_out,
|
| 259 |
"current_tenant_id": int(cur_tid) if cur_tid is not None else None,
|
| 260 |
"current_role": current_role,
|
| 261 |
+
"gmail_invites_ready": gmail_invites_ready,
|
| 262 |
}
|
| 263 |
|
| 264 |
|
|
|
|
| 291 |
return {"ok": True, "tenant_id": body.tenant_id, "role": m.role}
|
| 292 |
|
| 293 |
|
| 294 |
+
# Scopes: profile + send email on behalf of the signed-in user (workspace invite messages).
|
| 295 |
+
GOOGLE_OAUTH_SCOPES = "openid email profile https://www.googleapis.com/auth/gmail.send"
|
| 296 |
+
|
| 297 |
+
|
| 298 |
@router.get("/google")
|
| 299 |
+
async def google_login(
|
| 300 |
+
request: Request,
|
| 301 |
+
invite: str | None = None,
|
| 302 |
+
reauth_gmail: str | None = None,
|
| 303 |
+
):
|
| 304 |
if not _client_configured():
|
| 305 |
raise HTTPException(
|
| 306 |
status_code=503,
|
|
|
|
| 317 |
"client_id": client_id,
|
| 318 |
"redirect_uri": redirect_uri,
|
| 319 |
"response_type": "code",
|
| 320 |
+
"scope": GOOGLE_OAUTH_SCOPES,
|
| 321 |
"state": state,
|
| 322 |
+
"access_type": "offline",
|
| 323 |
"include_granted_scopes": "true",
|
| 324 |
}
|
| 325 |
+
# Force consent so Google returns a refresh_token when (re)adding gmail.send for existing users.
|
| 326 |
+
if reauth_gmail and reauth_gmail.strip() not in ("0", "false", "no"):
|
| 327 |
+
params["prompt"] = "consent"
|
| 328 |
return RedirectResponse(url=f"{GOOGLE_AUTH_URI}?{urlencode(params)}")
|
| 329 |
|
| 330 |
|
|
|
|
| 408 |
"picture": id_info.get("picture"),
|
| 409 |
"email_verified": id_info.get("email_verified"),
|
| 410 |
}
|
| 411 |
+
rt = tokens.get("refresh_token")
|
| 412 |
+
if rt and isinstance(rt, str) and rt.strip():
|
| 413 |
+
user.google_refresh_token = rt.strip()
|
| 414 |
db.commit()
|
| 415 |
except Exception:
|
| 416 |
db.rollback()
|
backend/app/database.py
CHANGED
|
@@ -33,6 +33,7 @@ class User(Base):
|
|
| 33 |
email = Column(String, index=True)
|
| 34 |
name = Column(String, nullable=True)
|
| 35 |
picture = Column(String, nullable=True)
|
|
|
|
| 36 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 37 |
|
| 38 |
|
|
@@ -245,6 +246,12 @@ def run_migrations(connection_engine):
|
|
| 245 |
{"tid": default_tid},
|
| 246 |
)
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
# Create tables then migrate legacy SQLite schemas
|
| 250 |
Base.metadata.create_all(bind=engine)
|
|
|
|
| 33 |
email = Column(String, index=True)
|
| 34 |
name = Column(String, nullable=True)
|
| 35 |
picture = Column(String, nullable=True)
|
| 36 |
+
google_refresh_token = Column(Text, nullable=True) # for Gmail send (invite emails); treat as secret
|
| 37 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 38 |
|
| 39 |
|
|
|
|
| 246 |
{"tid": default_tid},
|
| 247 |
)
|
| 248 |
|
| 249 |
+
insp = inspect(connection_engine)
|
| 250 |
+
if insp.has_table("users"):
|
| 251 |
+
ucols = [c["name"] for c in insp.get_columns("users")]
|
| 252 |
+
if "google_refresh_token" not in ucols:
|
| 253 |
+
conn.execute(text("ALTER TABLE users ADD COLUMN google_refresh_token TEXT"))
|
| 254 |
+
|
| 255 |
|
| 256 |
# Create tables then migrate legacy SQLite schemas
|
| 257 |
Base.metadata.create_all(bind=engine)
|
backend/app/gmail_invite.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Send messages via Gmail API using a stored OAuth refresh token (gmail.send scope)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import os
|
| 7 |
+
from email.message import EmailMessage
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
|
| 12 |
+
GMAIL_SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def refresh_access_token(refresh_token: str) -> str:
|
| 16 |
+
client_id = os.environ.get("GOOGLE_CLIENT_ID", "").strip()
|
| 17 |
+
client_secret = os.environ.get("GOOGLE_CLIENT_SECRET", "").strip()
|
| 18 |
+
if not client_id or not client_secret:
|
| 19 |
+
raise ValueError("Google OAuth is not configured")
|
| 20 |
+
async with httpx.AsyncClient() as client:
|
| 21 |
+
r = await client.post(
|
| 22 |
+
GOOGLE_TOKEN_URI,
|
| 23 |
+
data={
|
| 24 |
+
"client_id": client_id,
|
| 25 |
+
"client_secret": client_secret,
|
| 26 |
+
"refresh_token": refresh_token,
|
| 27 |
+
"grant_type": "refresh_token",
|
| 28 |
+
},
|
| 29 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 30 |
+
timeout=30.0,
|
| 31 |
+
)
|
| 32 |
+
if r.status_code != 200:
|
| 33 |
+
raise ValueError(f"Token refresh failed ({r.status_code}): {r.text[:500]}")
|
| 34 |
+
data = r.json()
|
| 35 |
+
at = data.get("access_token")
|
| 36 |
+
if not at:
|
| 37 |
+
raise ValueError("No access_token in refresh response")
|
| 38 |
+
return str(at)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _rfc822_raw(sender: str, to: str, subject: str, body: str) -> str:
|
| 42 |
+
msg = EmailMessage()
|
| 43 |
+
msg["Subject"] = subject
|
| 44 |
+
msg["From"] = sender
|
| 45 |
+
msg["To"] = to
|
| 46 |
+
msg.set_content(body)
|
| 47 |
+
return base64.urlsafe_b64encode(msg.as_bytes()).decode()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def send_invite_email_via_gmail(
|
| 51 |
+
refresh_token: str,
|
| 52 |
+
from_email: str,
|
| 53 |
+
to_email: str,
|
| 54 |
+
subject: str,
|
| 55 |
+
body: str,
|
| 56 |
+
) -> None:
|
| 57 |
+
"""Raises ValueError on configuration or API errors."""
|
| 58 |
+
if not from_email or "@" not in from_email:
|
| 59 |
+
raise ValueError("Inviter has no email address for Gmail send")
|
| 60 |
+
access = await refresh_access_token(refresh_token)
|
| 61 |
+
raw = _rfc822_raw(from_email, to_email, subject, body)
|
| 62 |
+
async with httpx.AsyncClient() as client:
|
| 63 |
+
r = await client.post(
|
| 64 |
+
GMAIL_SEND_URL,
|
| 65 |
+
headers={"Authorization": f"Bearer {access}"},
|
| 66 |
+
json={"raw": raw},
|
| 67 |
+
timeout=30.0,
|
| 68 |
+
)
|
| 69 |
+
if r.status_code not in (200, 201):
|
| 70 |
+
err = r.text[:1500] if r.text else ""
|
| 71 |
+
raise ValueError(f"Gmail send failed ({r.status_code}): {err}")
|
backend/app/tenant_routes.py
CHANGED
|
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
|
| 14 |
from sqlalchemy import func
|
| 15 |
|
| 16 |
from .database import Invitation, Tenant, TenantMembership, User, get_db
|
|
|
|
| 17 |
from .tenant_deps import TenantContext, require_tenant_admin
|
| 18 |
|
| 19 |
|
|
@@ -39,6 +40,22 @@ def _invite_link(raw_token: str) -> str:
|
|
| 39 |
return f"/?invite={raw_token}"
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
@router.get("")
|
| 43 |
def list_my_workspaces(request: Request, db: Session = Depends(get_db)):
|
| 44 |
uid = request.session.get("user_id")
|
|
@@ -66,7 +83,7 @@ def list_my_workspaces(request: Request, db: Session = Depends(get_db)):
|
|
| 66 |
|
| 67 |
|
| 68 |
@router.post("/invite")
|
| 69 |
-
def create_invitation(body: InviteBody, tc: TenantContext = Depends(require_tenant_admin)):
|
| 70 |
db = tc.db
|
| 71 |
email_n = body.email.strip().lower()
|
| 72 |
if not email_n or "@" not in email_n:
|
|
@@ -98,11 +115,48 @@ def create_invitation(body: InviteBody, tc: TenantContext = Depends(require_tena
|
|
| 98 |
)
|
| 99 |
db.add(inv)
|
| 100 |
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
return {
|
| 102 |
"ok": True,
|
| 103 |
-
"invite_url":
|
| 104 |
"expires_at": exp.isoformat() + "Z",
|
| 105 |
"email": email_n,
|
|
|
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
|
|
|
|
| 14 |
from sqlalchemy import func
|
| 15 |
|
| 16 |
from .database import Invitation, Tenant, TenantMembership, User, get_db
|
| 17 |
+
from .gmail_invite import send_invite_email_via_gmail
|
| 18 |
from .tenant_deps import TenantContext, require_tenant_admin
|
| 19 |
|
| 20 |
|
|
|
|
| 40 |
return f"/?invite={raw_token}"
|
| 41 |
|
| 42 |
|
| 43 |
+
def _invite_email_body(
|
| 44 |
+
inviter_name: str,
|
| 45 |
+
inviter_email: str,
|
| 46 |
+
workspace_name: str,
|
| 47 |
+
invite_url: str,
|
| 48 |
+
invitee_email: str,
|
| 49 |
+
) -> str:
|
| 50 |
+
by = inviter_name or inviter_email or "Your teammate"
|
| 51 |
+
return (
|
| 52 |
+
f"{by} invited you to join the workspace \"{workspace_name}\" on SequenceAI.\n\n"
|
| 53 |
+
f"Accept the invitation by opening this link while signed in with Google as {invitee_email}:\n"
|
| 54 |
+
f"{invite_url}\n\n"
|
| 55 |
+
f"This link expires in 7 days.\n"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
@router.get("")
|
| 60 |
def list_my_workspaces(request: Request, db: Session = Depends(get_db)):
|
| 61 |
uid = request.session.get("user_id")
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
@router.post("/invite")
|
| 86 |
+
async def create_invitation(body: InviteBody, tc: TenantContext = Depends(require_tenant_admin)):
|
| 87 |
db = tc.db
|
| 88 |
email_n = body.email.strip().lower()
|
| 89 |
if not email_n or "@" not in email_n:
|
|
|
|
| 115 |
)
|
| 116 |
db.add(inv)
|
| 117 |
db.commit()
|
| 118 |
+
|
| 119 |
+
invite_url = _invite_link(raw)
|
| 120 |
+
tenant_row = db.query(Tenant).filter(Tenant.id == tc.tenant_id).first()
|
| 121 |
+
workspace_name = tenant_row.name if tenant_row else "Workspace"
|
| 122 |
+
inviter = db.query(User).filter(User.id == tc.user_id).first()
|
| 123 |
+
|
| 124 |
+
email_sent = False
|
| 125 |
+
email_error: str | None = None
|
| 126 |
+
rt = (inviter.google_refresh_token or "").strip() if inviter else ""
|
| 127 |
+
if not rt:
|
| 128 |
+
email_error = (
|
| 129 |
+
"Gmail is not connected for your account. Use “Reconnect Google for invites” below, "
|
| 130 |
+
"then try again."
|
| 131 |
+
)
|
| 132 |
+
elif inviter:
|
| 133 |
+
try:
|
| 134 |
+
subject = f'Invitation to join "{workspace_name}" on SequenceAI'
|
| 135 |
+
body_text = _invite_email_body(
|
| 136 |
+
inviter.name or "",
|
| 137 |
+
inviter.email or "",
|
| 138 |
+
workspace_name,
|
| 139 |
+
invite_url,
|
| 140 |
+
email_n,
|
| 141 |
+
)
|
| 142 |
+
await send_invite_email_via_gmail(
|
| 143 |
+
rt,
|
| 144 |
+
inviter.email or "",
|
| 145 |
+
email_n,
|
| 146 |
+
subject,
|
| 147 |
+
body_text,
|
| 148 |
+
)
|
| 149 |
+
email_sent = True
|
| 150 |
+
except Exception as e:
|
| 151 |
+
email_error = str(e)
|
| 152 |
+
|
| 153 |
return {
|
| 154 |
"ok": True,
|
| 155 |
+
"invite_url": invite_url,
|
| 156 |
"expires_at": exp.isoformat() + "Z",
|
| 157 |
"email": email_n,
|
| 158 |
+
"email_sent": email_sent,
|
| 159 |
+
"email_error": email_error,
|
| 160 |
}
|
| 161 |
|
| 162 |
|
frontend/src/pages/Settings.jsx
CHANGED
|
@@ -128,8 +128,14 @@ export default function Settings() {
|
|
| 128 |
});
|
| 129 |
return;
|
| 130 |
}
|
| 131 |
-
setInviteResult({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
setInviteEmail('');
|
|
|
|
| 133 |
} catch (e) {
|
| 134 |
setInviteResult({ error: String(e) });
|
| 135 |
} finally {
|
|
@@ -257,8 +263,21 @@ export default function Settings() {
|
|
| 257 |
Invite people
|
| 258 |
</div>
|
| 259 |
<p className="text-sm text-slate-600 mb-4">
|
| 260 |
-
|
|
|
|
| 261 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
<div className="flex flex-col sm:flex-row gap-2 max-w-xl">
|
| 263 |
<Input
|
| 264 |
type="email"
|
|
@@ -276,16 +295,26 @@ export default function Settings() {
|
|
| 276 |
{inviteBusy ? (
|
| 277 |
<Loader2 className="h-4 w-4 animate-spin" />
|
| 278 |
) : (
|
| 279 |
-
'
|
| 280 |
)}
|
| 281 |
</Button>
|
| 282 |
</div>
|
| 283 |
{inviteResult?.error ? (
|
| 284 |
<p className="text-sm text-red-600 mt-3">{inviteResult.error}</p>
|
| 285 |
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
{inviteResult?.url ? (
|
| 287 |
<div className="mt-4 space-y-2">
|
| 288 |
-
<p className="text-xs text-slate-600">
|
|
|
|
|
|
|
| 289 |
<Input readOnly value={inviteResult.url} className="text-xs h-9 font-mono" />
|
| 290 |
</div>
|
| 291 |
) : null}
|
|
|
|
| 128 |
});
|
| 129 |
return;
|
| 130 |
}
|
| 131 |
+
setInviteResult({
|
| 132 |
+
url: data.invite_url,
|
| 133 |
+
emailSent: !!data.email_sent,
|
| 134 |
+
emailError: data.email_error || null,
|
| 135 |
+
inviteeEmail: data.email,
|
| 136 |
+
});
|
| 137 |
setInviteEmail('');
|
| 138 |
+
loadMe();
|
| 139 |
} catch (e) {
|
| 140 |
setInviteResult({ error: String(e) });
|
| 141 |
} finally {
|
|
|
|
| 263 |
Invite people
|
| 264 |
</div>
|
| 265 |
<p className="text-sm text-slate-600 mb-4">
|
| 266 |
+
We email an invitation from your Google account. The invitee must sign in with the
|
| 267 |
+
same email address.
|
| 268 |
</p>
|
| 269 |
+
{me?.gmail_invites_ready === false ? (
|
| 270 |
+
<p className="text-xs text-amber-900 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">
|
| 271 |
+
Connect Gmail once so "Send invite" can send mail from your address:{' '}
|
| 272 |
+
<a
|
| 273 |
+
href="/api/auth/google?reauth_gmail=1"
|
| 274 |
+
className="font-medium text-violet-700 underline"
|
| 275 |
+
>
|
| 276 |
+
Reconnect Google for invites
|
| 277 |
+
</a>
|
| 278 |
+
.
|
| 279 |
+
</p>
|
| 280 |
+
) : null}
|
| 281 |
<div className="flex flex-col sm:flex-row gap-2 max-w-xl">
|
| 282 |
<Input
|
| 283 |
type="email"
|
|
|
|
| 295 |
{inviteBusy ? (
|
| 296 |
<Loader2 className="h-4 w-4 animate-spin" />
|
| 297 |
) : (
|
| 298 |
+
'Send invite'
|
| 299 |
)}
|
| 300 |
</Button>
|
| 301 |
</div>
|
| 302 |
{inviteResult?.error ? (
|
| 303 |
<p className="text-sm text-red-600 mt-3">{inviteResult.error}</p>
|
| 304 |
) : null}
|
| 305 |
+
{inviteResult?.emailSent ? (
|
| 306 |
+
<p className="text-sm text-green-700 mt-3">
|
| 307 |
+
Invitation email sent to {inviteResult.inviteeEmail}.
|
| 308 |
+
</p>
|
| 309 |
+
) : null}
|
| 310 |
+
{inviteResult?.emailError ? (
|
| 311 |
+
<p className="text-sm text-amber-800 mt-3">{inviteResult.emailError}</p>
|
| 312 |
+
) : null}
|
| 313 |
{inviteResult?.url ? (
|
| 314 |
<div className="mt-4 space-y-2">
|
| 315 |
+
<p className="text-xs text-slate-600">
|
| 316 |
+
Invite link (copy if email did not arrive; expires in 7 days):
|
| 317 |
+
</p>
|
| 318 |
<Input readOnly value={inviteResult.url} className="text-xs h-9 font-mono" />
|
| 319 |
</div>
|
| 320 |
) : null}
|