Seth commited on
Commit
5ff7dfd
·
1 Parent(s): 8134b4e
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(request: Request, invite: str | None = None):
 
 
 
 
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": "openid email profile",
307
  "state": state,
308
- "access_type": "online",
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": _invite_link(raw),
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({ url: data.invite_url });
 
 
 
 
 
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
- Invite a Google user by email. They must sign in with that Google account.
 
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
- 'Create invite'
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">Invite link (7 days):</p>
 
 
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 &quot;Send invite&quot; 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}