devaanand commited on
Commit
fa9d4fa
·
1 Parent(s): 0b0463d

Google sign-in via Supabase (server-side PKCE), feature-flagged

Browse files

/api/auth/google/{login,callback}: PKCE challenge with the verifier in a
short-lived signed cookie, stdlib-only token exchange, find-or-create
local user by email, same session cookie as password accounts. Button
appears only when SUPABASE_URL + SUPABASE_ANON_KEY are set; Google-only
accounts get a clear message if they try password login. .env template
with placeholders (gitignored) + .env.example updated. 64 tests passing
(5 new, exchange mocked).

.env.example CHANGED
@@ -43,10 +43,14 @@
43
  # Force the deterministic fake embedder (never production)
44
  # CCR_FAKE_EMBEDDINGS=1
45
 
 
 
 
 
 
 
 
 
46
  # ---- Phase 2 (not read yet; reserved names) ----
47
  # DATABASE_URL=postgresql://...
48
- # SUPABASE_URL=
49
- # SUPABASE_ANON_KEY=
50
- # OBJECT_STORAGE_ENDPOINT=
51
- # OBJECT_STORAGE_BUCKET=
52
  # ADMIN_EMAILS=devaanand@umass.edu,matari@umass.edu
 
43
  # Force the deterministic fake embedder (never production)
44
  # CCR_FAKE_EMBEDDINGS=1
45
 
46
+ # ---- Google sign-in via Supabase (optional; button hidden when unset) ----
47
+ # Supabase dashboard > Project Settings > API. The anon key is public-facing
48
+ # by design; the service_role key is never used and never leaves the dashboard.
49
+ # SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
50
+ # SUPABASE_ANON_KEY=
51
+ # Public base URL of this app (Google redirect target).
52
+ # CCR_APP_URL=http://127.0.0.1:8000
53
+
54
  # ---- Phase 2 (not read yet; reserved names) ----
55
  # DATABASE_URL=postgresql://...
 
 
 
 
56
  # ADMIN_EMAILS=devaanand@umass.edu,matari@umass.edu
.gitignore CHANGED
@@ -16,3 +16,4 @@ node_modules/
16
  # local AI-tool state (machine-specific)
17
  .codex/
18
  .agents/
 
 
16
  # local AI-tool state (machine-specific)
17
  .codex/
18
  .agents/
19
+ .env
DECISIONS.md CHANGED
@@ -104,3 +104,14 @@ Flipping production to R2 = CCR_STORAGE=s3 + four env values. Rejected: deferrin
104
  implementation to Phase 2 (turns a deploy-day config flip into deploy-day development),
105
  and presigned public URLs (private bucket + API streaming is simpler and safer at lab
106
  scale). Embedding caches deliberately stay on local disk: derived data, no durability need.
 
 
 
 
 
 
 
 
 
 
 
 
104
  implementation to Phase 2 (turns a deploy-day config flip into deploy-day development),
105
  and presigned public URLs (private bucket + API streaming is simpler and safer at lab
106
  scale). Embedding caches deliberately stay on local disk: derived data, no durability need.
107
+
108
+ ## 2026-07-13 - Google sign-in via Supabase PKCE, server-side, feature-flagged (Deva)
109
+ Implemented as a plain redirect flow: /api/auth/google/login sends the browser to
110
+ Supabase's Google authorize URL with a PKCE challenge (verifier in a short-lived signed
111
+ cookie); the callback exchanges the code server-side (stdlib urllib, no new deps), then
112
+ finds-or-creates a local user by email and issues the SAME session cookie as password
113
+ accounts. Inert until SUPABASE_URL + SUPABASE_ANON_KEY are set, so dev/tests need no
114
+ Supabase project. Google-only accounts have no password hash and password login points
115
+ them to the Google button. Rejected: supabase-js in the frontend (breaks the
116
+ react+react-dom-only dependency rule for one button) and provider-JWT sessions (would
117
+ fork the tier/ownership logic into two session formats for no benefit).
backend/app/auth_google.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google sign-in via Supabase Auth (server-side PKCE flow).
2
+
3
+ Feature-flagged: everything here is inert until SUPABASE_URL and
4
+ SUPABASE_ANON_KEY are set, so local dev and tests run unchanged without any
5
+ Supabase project. When configured, the flow is:
6
+
7
+ 1. GET /api/auth/google/login -> redirect to Supabase's Google authorize
8
+ URL with a PKCE challenge; the verifier rides in a short-lived signed
9
+ cookie (never stored server-side).
10
+ 2. Google -> Supabase -> GET /api/auth/google/callback?code=...
11
+ 3. The backend exchanges code+verifier for the Supabase user (stdlib
12
+ urllib - no new dependencies), finds-or-creates a local User row by
13
+ email, and issues OUR normal session cookie (auth.py).
14
+
15
+ Design consequence: Supabase verifies identity at sign-in time only; the
16
+ session, tiers, and ownership model are exactly the same as email/password
17
+ accounts. Google users have an empty password_hash and cannot password-login
18
+ (a clear message says to use Google). Because users are re-created on next
19
+ sign-in by email, an ephemeral-disk dev instance losing its SQLite file is a
20
+ nuisance, not a lockout.
21
+
22
+ No frontend SDK: the button is a plain link, keeping the react+react-dom-only
23
+ dependency rule intact.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import hashlib
30
+ import json
31
+ import os
32
+ import secrets
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+
37
+ VERIFIER_COOKIE = "ccr_pkce"
38
+ VERIFIER_TTL_SECONDS = 600
39
+
40
+
41
+ def configured() -> bool:
42
+ return bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_ANON_KEY"))
43
+
44
+
45
+ def _supabase_url() -> str:
46
+ return os.environ["SUPABASE_URL"].rstrip("/")
47
+
48
+
49
+ def app_url() -> str:
50
+ """Public base URL of THIS app (redirect target). Local default matches
51
+ the dev server; deployments set CCR_APP_URL."""
52
+ return os.environ.get("CCR_APP_URL", "http://127.0.0.1:8000").rstrip("/")
53
+
54
+
55
+ def begin() -> tuple[str, str]:
56
+ """Return (authorize_url, code_verifier)."""
57
+ verifier = secrets.token_urlsafe(64)
58
+ challenge = (
59
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
60
+ .decode()
61
+ .rstrip("=")
62
+ )
63
+ params = urllib.parse.urlencode(
64
+ {
65
+ "provider": "google",
66
+ "redirect_to": f"{app_url()}/api/auth/google/callback",
67
+ "code_challenge": challenge,
68
+ "code_challenge_method": "s256",
69
+ }
70
+ )
71
+ return f"{_supabase_url()}/auth/v1/authorize?{params}", verifier
72
+
73
+
74
+ def exchange(code: str, verifier: str) -> dict:
75
+ """Exchange the PKCE code for the Supabase user. Returns {email, name}.
76
+ Raises ValueError with a user-safe message on any failure."""
77
+ body = json.dumps({"auth_code": code, "code_verifier": verifier}).encode()
78
+ req = urllib.request.Request(
79
+ f"{_supabase_url()}/auth/v1/token?grant_type=pkce",
80
+ data=body,
81
+ headers={
82
+ "apikey": os.environ["SUPABASE_ANON_KEY"],
83
+ "Content-Type": "application/json",
84
+ },
85
+ method="POST",
86
+ )
87
+ try:
88
+ with urllib.request.urlopen(req, timeout=15) as resp:
89
+ payload = json.load(resp)
90
+ except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc:
91
+ raise ValueError("Google sign-in could not be completed. Please try again.") from exc
92
+
93
+ user = payload.get("user") or {}
94
+ email = (user.get("email") or "").strip().lower()
95
+ if not email:
96
+ raise ValueError("Google sign-in returned no email address.")
97
+ meta = user.get("user_metadata") or {}
98
+ name = (meta.get("full_name") or meta.get("name") or email.split("@")[0]).strip()
99
+ return {"email": email, "name": name}
backend/app/main.py CHANGED
@@ -21,7 +21,7 @@ from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
21
  from fastapi.staticfiles import StaticFiles
22
  from sqlalchemy.orm import Session
23
 
24
- from . import auth, retention, storage
25
  from . import jobs as jobs_module
26
  from . import registry
27
  from .ccr import FAKE_MODEL_NAME
@@ -221,6 +221,7 @@ def auth_me(
221
  "signed_in": False,
222
  "name": None,
223
  "email": None,
 
224
  "limits": {"max_bytes": auth.anon_max_bytes(), "max_rows": auth.anon_max_rows()},
225
  "usage": {
226
  "runs_used_today": auth.runs_used_today(request),
@@ -249,12 +250,66 @@ def register(body: RegisterIn, response: Response, db: Session = Depends(get_db)
249
  def login(body: LoginIn, response: Response, db: Session = Depends(get_db)):
250
  email = body.email.strip().lower()
251
  user = db.query(User).filter_by(email=email).first()
 
 
252
  if user is None or not auth.verify_password(body.password, user.password_hash):
253
  raise HTTPException(401, "Incorrect email or password.")
254
  _set_session_cookie(response, user)
255
  return {"signed_in": True, "name": user.name, "email": user.email}
256
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  @app.post("/api/auth/logout")
259
  def logout(response: Response):
260
  response.delete_cookie(auth.COOKIE_NAME)
 
21
  from fastapi.staticfiles import StaticFiles
22
  from sqlalchemy.orm import Session
23
 
24
+ from . import auth, auth_google, retention, storage
25
  from . import jobs as jobs_module
26
  from . import registry
27
  from .ccr import FAKE_MODEL_NAME
 
221
  "signed_in": False,
222
  "name": None,
223
  "email": None,
224
+ "google_available": auth_google.configured(),
225
  "limits": {"max_bytes": auth.anon_max_bytes(), "max_rows": auth.anon_max_rows()},
226
  "usage": {
227
  "runs_used_today": auth.runs_used_today(request),
 
250
  def login(body: LoginIn, response: Response, db: Session = Depends(get_db)):
251
  email = body.email.strip().lower()
252
  user = db.query(User).filter_by(email=email).first()
253
+ if user is not None and not user.password_hash:
254
+ raise HTTPException(401, "This account uses Google sign-in - use the Google button.")
255
  if user is None or not auth.verify_password(body.password, user.password_hash):
256
  raise HTTPException(401, "Incorrect email or password.")
257
  _set_session_cookie(response, user)
258
  return {"signed_in": True, "name": user.name, "email": user.email}
259
 
260
 
261
+ @app.get("/api/auth/google/login")
262
+ def google_login():
263
+ """Start the Google sign-in flow (Supabase PKCE). Plain redirect - the
264
+ frontend links here directly, no SDK involved."""
265
+ if not auth_google.configured():
266
+ raise HTTPException(503, "Google sign-in is not configured on this instance.")
267
+ from fastapi.responses import RedirectResponse
268
+
269
+ url, verifier = auth_google.begin()
270
+ resp = RedirectResponse(url, status_code=307)
271
+ resp.set_cookie(
272
+ auth_google.VERIFIER_COOKIE,
273
+ auth.sign_payload({"v": verifier}),
274
+ httponly=True,
275
+ samesite="lax",
276
+ secure=auth.cookies_secure(),
277
+ max_age=auth_google.VERIFIER_TTL_SECONDS,
278
+ )
279
+ return resp
280
+
281
+
282
+ @app.get("/api/auth/google/callback")
283
+ def google_callback(request: Request, code: str = "", db: Session = Depends(get_db)):
284
+ from fastapi.responses import RedirectResponse
285
+
286
+ def fail(msg: str):
287
+ return RedirectResponse(f"/?auth_error={msg}", status_code=307)
288
+
289
+ if not auth_google.configured():
290
+ return fail("google-not-configured")
291
+ payload = auth.verify_payload(request.cookies.get(auth_google.VERIFIER_COOKIE))
292
+ if not code or not payload or "v" not in payload:
293
+ return fail("sign-in-expired-try-again")
294
+ try:
295
+ info = auth_google.exchange(code, payload["v"])
296
+ except ValueError:
297
+ return fail("google-exchange-failed")
298
+
299
+ user = db.query(User).filter_by(email=info["email"]).first()
300
+ if user is None:
301
+ # Google-verified account: no local password (password login is refused
302
+ # with a pointer to the Google button).
303
+ user = User(email=info["email"], name=info["name"], password_hash="")
304
+ db.add(user)
305
+ db.commit()
306
+
307
+ resp = RedirectResponse("/", status_code=307)
308
+ resp.delete_cookie(auth_google.VERIFIER_COOKIE)
309
+ _set_session_cookie(resp, user)
310
+ return resp
311
+
312
+
313
  @app.post("/api/auth/logout")
314
  def logout(response: Response):
315
  response.delete_cookie(auth.COOKIE_NAME)
backend/static/assets/index-BAVSoV2O.css DELETED
@@ -1 +0,0 @@
1
- :root{--maroon: #7a1f3d;--maroon-dark: #5e1730;--ink: #1d2129;--muted: #667085;--line: #e5e7eb;--bg: #f7f7f8;--card: #ffffff;--ok: #157f3d;--err: #b42318;--accent-soft: #f6ebef;--control-height: 40px}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,sans-serif;color:var(--ink);background:var(--bg);font-size:14.5px;line-height:1.5}.app{display:flex;flex-direction:column;min-height:100vh}.header{background:var(--maroon);color:#fff;padding:14px 28px;display:flex;align-items:baseline;flex-wrap:wrap;gap:14px}.header h1{flex:0 0 auto;font-size:17px;margin:0;font-weight:650;letter-spacing:.2px;white-space:nowrap}.header .sub{flex:1 1 280px;min-width:0;font-size:12.5px;opacity:.85}.layout{display:flex;flex:1;min-width:0;min-height:0}.sidebar{width:250px;background:var(--card);border-right:1px solid var(--line);padding:18px 14px;flex-shrink:0;display:flex;flex-direction:column;min-height:0}.sidebar h2{font-size:11.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);margin:0 0 10px 4px;display:flex;align-items:center;gap:8px}.sidebar h2 .count{background:var(--bg);border:1px solid var(--line);border-radius:999px;padding:0 8px;font-size:10.5px;letter-spacing:0;color:var(--muted)}.sidebar-filter{width:100%;padding:7px 10px;margin-bottom:10px;border:1px solid var(--line);border-radius:8px;font:inherit;font-size:13px;background:#fff;color:var(--ink)}.sidebar-filter:focus{outline:none;border-color:var(--maroon)}.project-list{flex:1;min-height:0;overflow-y:auto;margin:0 -4px;padding:0 4px 4px}.group-label{font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin:10px 4px 5px}div:first-child>.group-label{margin-top:2px}.project-item{display:block;width:100%;text-align:left;padding:9px 12px;margin-bottom:5px;border:1px solid transparent;border-radius:8px;background:none;cursor:pointer;font:inherit;color:var(--ink)}.project-item:hover{background:var(--bg)}.project-item.active{background:var(--accent-soft);border-color:var(--maroon);font-weight:600}.project-item .project-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.project-item .date{display:block;font-size:11.5px;color:var(--muted);font-weight:400}.project-create{margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}.project-create>button{width:100%}.main{flex:1;padding:22px 28px;overflow-y:auto;min-width:0}.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:18px 20px;margin-bottom:16px}.card h3{margin:0 0 4px;font-size:15px}.card .hint{color:var(--muted);font-size:12.5px;margin:0 0 12px}.step-badge{display:inline-flex;align-items:center;justify-content:center;width:21px;height:21px;border-radius:50%;background:var(--maroon);color:#fff;font-size:12px;font-weight:700;margin-right:8px;vertical-align:-3px}button{white-space:nowrap}button.primary{background:var(--maroon);color:#fff;border:none;min-height:var(--control-height);display:inline-flex;align-items:center;justify-content:center;padding:0 18px;border-radius:8px;font:inherit;font-weight:600;cursor:pointer;line-height:1.2}button.primary:hover{background:var(--maroon-dark)}button.primary:disabled{background:#c9ccd1;cursor:not-allowed}button.ghost{background:none;border:1px solid var(--line);color:var(--ink);min-height:var(--control-height);display:inline-flex;align-items:center;justify-content:center;padding:0 14px;border-radius:8px;font:inherit;cursor:pointer;line-height:1.2}button.ghost:hover{border-color:var(--maroon);color:var(--maroon)}button.linkish{background:none;border:none;color:var(--maroon);font:inherit;cursor:pointer;padding:0;text-decoration:underline}input[type=text],input[type=email],input[type=password],textarea,select{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;font:inherit;background:#fff;color:var(--ink)}input[type=text],input[type=email],input[type=password],select{height:var(--control-height)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid rgba(122,31,61,.38);outline-offset:2px}input[type=file]{display:block;max-width:100%;margin-top:6px;font-size:13px;color:var(--muted)}input[type=file]::file-selector-button{background:#fff;border:1px solid var(--line);color:var(--ink);padding:7px 14px;border-radius:8px;font:inherit;font-size:13px;cursor:pointer;margin-right:10px}input[type=file]::file-selector-button:hover{border-color:var(--maroon);color:var(--maroon)}.row>button{align-self:flex-end;margin-bottom:1px}textarea{resize:vertical}label.field{display:block;margin-bottom:10px;font-size:13px;font-weight:600}label.field>*{margin-top:4px;font-weight:400}.field-hint{margin-top:0;font-weight:400;color:var(--muted);font-size:12px}.row{display:flex;gap:14px;flex-wrap:wrap}.row>*{min-width:0}.row>.grow{flex:1;min-width:min(220px,100%)}.language-control{min-width:170px}.model-control{min-width:260px}.run-settings{display:grid;grid-template-columns:minmax(160px,230px) minmax(320px,720px) max-content;justify-content:start;gap:14px;align-items:end}.run-settings .field{margin-bottom:0}.run-button{min-width:180px;height:var(--control-height)}.construct-row{display:grid;grid-template-columns:minmax(320px,1120px) max-content;justify-content:start;align-items:end;gap:14px}.results-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:14px;flex-wrap:wrap}.result-actions{justify-content:flex-end}.result-actions a{display:inline-flex;text-decoration:none}.pill{display:inline-block;padding:2px 10px;border-radius:999px;font-size:11.5px;font-weight:600}.pill.completed{background:#e6f4ea;color:var(--ok)}.pill.running{background:#fff3e0;color:#b45309}.pill.queued{background:#eef2f7;color:var(--muted)}.pill.failed{background:#fdecea;color:var(--err)}.progress-track{background:var(--line);border-radius:999px;height:7px;overflow:hidden}.progress-fill{background:var(--maroon);height:100%;transition:width .4s ease}.warnings{background:#fff8e6;border:1px solid #f2dfa8;color:#7a5b00;border-radius:8px;padding:10px 14px}.error-banner{background:#fdecea;color:var(--err);border:1px solid #f5c6c0;padding:10px 14px;border-radius:8px;margin-bottom:14px;font-size:13px}.table-wrap{width:100%;overflow-x:auto}table.docs{width:100%;border-collapse:collapse;font-size:13px}table.docs th{text-align:left;color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.5px;padding:6px 8px;border-bottom:1px solid var(--line)}table.docs td{padding:7px 8px;border-bottom:1px solid var(--bg);vertical-align:top;overflow-wrap:anywhere}table.docs td.score{font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap}.stat-grid{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:4px}.stat{flex:1;min-width:110px;background:var(--bg);border-radius:8px;padding:10px 14px}.stat .v{font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}.stat .k{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}.item-bar-row{display:flex;align-items:center;gap:10px;margin-bottom:7px}.item-bar-label{flex:1;font-size:12.5px;min-width:0;overflow-wrap:anywhere}.item-bar-track{flex:1.2;background:var(--bg);border-radius:999px;height:10px}.item-bar-fill{background:var(--maroon);opacity:.85;height:100%;border-radius:999px}.item-bar-val{width:52px;text-align:right;font-variant-numeric:tabular-nums;font-size:12.5px;font-weight:600}.construct-items{margin:8px 0 0;padding-left:20px;color:var(--muted);font-size:12.5px}.construct-items li{margin-bottom:2px}.meta-footer{font-size:12px;color:var(--muted);background:var(--bg);border-radius:8px;padding:10px 14px;margin-top:14px;font-variant-numeric:tabular-nums}.meta-footer code{font-size:11.5px}.muted{color:var(--muted)}.small{font-size:12.5px}.mt{margin-top:12px}.header-auth{margin-left:auto;display:flex;align-items:center;gap:10px;color:#fff}.header-btn{background:#ffffff1f;color:#fff;border:1px solid rgba(255,255,255,.45);padding:5px 14px;border-radius:7px;font:inherit;font-size:13px;cursor:pointer}.header-btn:hover{background:#ffffff38}.project-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}.project-title{font-size:17px;font-weight:650;margin-right:10px}button.danger{color:var(--err);border-color:#f0c4be}button.danger:hover{color:var(--err);border-color:var(--err)}button.danger-solid{background:var(--err)}button.danger-solid:hover{background:#93261b}button.danger-solid:disabled{background:#c9ccd1}.picker{position:relative}.picker-display{width:100%;display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;color:var(--ink);cursor:pointer;text-align:left}.picker-display:hover{border-color:var(--maroon)}.picker-display .picker-caret{margin-left:auto;color:var(--muted);font-size:11px}.picker-search{width:100%;padding:8px 12px;border:1px solid var(--maroon);border-radius:8px;font:inherit;background:#fff}.picker-search:focus{outline:none;box-shadow:0 0 0 3px #7a1f3d1f}.picker-panel{position:absolute;top:calc(100% + 6px);left:0;z-index:50;width:100%;max-width:640px;background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 14px 40px #0000002e;max-height:340px;overflow-y:auto;padding:4px 0 6px}.picker-group{font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);padding:8px 12px 3px;position:sticky;top:0;background:var(--card)}.picker-option{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:6px 12px;cursor:pointer;font-size:13.5px}.picker-option:hover,.picker-option.active{background:var(--accent-soft)}.picker-option.selected .picker-name{font-weight:650;color:var(--maroon)}.picker-name{min-width:0}.picker-meta{flex-shrink:0;font-size:11.5px;color:var(--muted);white-space:nowrap}.picker-empty{padding:12px;margin:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#14161a73;display:flex;align-items:center;justify-content:center;z-index:40;padding:16px}.modal{background:var(--card);border-radius:12px;padding:26px 28px 24px;width:100%;max-width:460px;box-shadow:0 12px 40px #0000002e}.modal h3{margin:0 0 8px;font-size:18px}.modal .hint{color:var(--muted);font-size:13px;line-height:1.5;margin:0 0 4px}.modal form.mt{margin-top:18px}.modal label.field{margin-bottom:16px}.modal label.field:last-of-type{margin-bottom:20px}.modal .row{gap:10px}.modal .row>.primary{flex:1}.modal p.small.muted.mt{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:12.5px;line-height:1.6}@media (max-width: 820px){body{font-size:14px}.header{padding:12px 16px;align-items:flex-start;gap:2px 12px}.header h1{font-size:16px}.header .sub{flex:1 1 210px;font-size:12px;line-height:1.35}.layout{display:block}.sidebar{width:100%;border-right:0;border-bottom:1px solid var(--line);padding:14px}.project-list{max-height:220px;margin-right:0;flex:none}.project-create{border-top:0}.main{width:100%;padding:16px 14px 28px;overflow:visible}.card{padding:16px;margin-bottom:14px}.row,.run-settings,.construct-row,.results-toolbar,.result-actions{gap:10px}.row,.results-toolbar,.result-actions{flex-direction:column;align-items:stretch}.run-settings,.construct-row{grid-template-columns:1fr}.row>.grow,.construct-row>.grow,.language-control,.model-control{width:100%;min-width:0}.main .row>button,.main .row>a,.main .row>a>button,.results-toolbar>button{align-self:stretch;margin-bottom:0;width:100%}.run-button{width:100%;min-width:0}.stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.stat{min-width:0}.item-bar-row{display:grid;grid-template-columns:minmax(0,1fr) 56px;gap:6px 10px}.item-bar-track{grid-column:1 / -1;width:100%}.item-bar-val{width:auto}table.docs{min-width:520px}}@media (max-width: 460px){.header .sub{flex-basis:100%}.stat-grid{grid-template-columns:1fr}}
 
 
backend/static/assets/index-CN_FzJfm.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{--maroon: #7a1f3d;--maroon-dark: #5e1730;--ink: #1d2129;--muted: #667085;--line: #e5e7eb;--bg: #f7f7f8;--card: #ffffff;--ok: #157f3d;--err: #b42318;--accent-soft: #f6ebef;--control-height: 40px}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,sans-serif;color:var(--ink);background:var(--bg);font-size:14.5px;line-height:1.5}.app{display:flex;flex-direction:column;min-height:100vh}.header{background:var(--maroon);color:#fff;padding:14px 28px;display:flex;align-items:baseline;flex-wrap:wrap;gap:14px}.header h1{flex:0 0 auto;font-size:17px;margin:0;font-weight:650;letter-spacing:.2px;white-space:nowrap}.header .sub{flex:1 1 280px;min-width:0;font-size:12.5px;opacity:.85}.layout{display:flex;flex:1;min-width:0;min-height:0}.sidebar{width:250px;background:var(--card);border-right:1px solid var(--line);padding:18px 14px;flex-shrink:0;display:flex;flex-direction:column;min-height:0}.sidebar h2{font-size:11.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);margin:0 0 10px 4px;display:flex;align-items:center;gap:8px}.sidebar h2 .count{background:var(--bg);border:1px solid var(--line);border-radius:999px;padding:0 8px;font-size:10.5px;letter-spacing:0;color:var(--muted)}.sidebar-filter{width:100%;padding:7px 10px;margin-bottom:10px;border:1px solid var(--line);border-radius:8px;font:inherit;font-size:13px;background:#fff;color:var(--ink)}.sidebar-filter:focus{outline:none;border-color:var(--maroon)}.project-list{flex:1;min-height:0;overflow-y:auto;margin:0 -4px;padding:0 4px 4px}.group-label{font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin:10px 4px 5px}div:first-child>.group-label{margin-top:2px}.project-item{display:block;width:100%;text-align:left;padding:9px 12px;margin-bottom:5px;border:1px solid transparent;border-radius:8px;background:none;cursor:pointer;font:inherit;color:var(--ink)}.project-item:hover{background:var(--bg)}.project-item.active{background:var(--accent-soft);border-color:var(--maroon);font-weight:600}.project-item .project-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.project-item .date{display:block;font-size:11.5px;color:var(--muted);font-weight:400}.project-create{margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}.project-create>button{width:100%}.main{flex:1;padding:22px 28px;overflow-y:auto;min-width:0}.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:18px 20px;margin-bottom:16px}.card h3{margin:0 0 4px;font-size:15px}.card .hint{color:var(--muted);font-size:12.5px;margin:0 0 12px}.step-badge{display:inline-flex;align-items:center;justify-content:center;width:21px;height:21px;border-radius:50%;background:var(--maroon);color:#fff;font-size:12px;font-weight:700;margin-right:8px;vertical-align:-3px}button{white-space:nowrap}button.primary{background:var(--maroon);color:#fff;border:none;min-height:var(--control-height);display:inline-flex;align-items:center;justify-content:center;padding:0 18px;border-radius:8px;font:inherit;font-weight:600;cursor:pointer;line-height:1.2}button.primary:hover{background:var(--maroon-dark)}button.primary:disabled{background:#c9ccd1;cursor:not-allowed}a.google-btn{background:var(--maroon);color:#fff;border:none;min-height:var(--control-height);display:flex;align-items:center;justify-content:center;padding:0 18px;border-radius:8px;font:inherit;font-weight:600;cursor:pointer;line-height:1.2;text-decoration:none;width:100%;box-sizing:border-box}a.google-btn:hover{background:var(--maroon-dark)}button.ghost{background:none;border:1px solid var(--line);color:var(--ink);min-height:var(--control-height);display:inline-flex;align-items:center;justify-content:center;padding:0 14px;border-radius:8px;font:inherit;cursor:pointer;line-height:1.2}button.ghost:hover{border-color:var(--maroon);color:var(--maroon)}button.linkish{background:none;border:none;color:var(--maroon);font:inherit;cursor:pointer;padding:0;text-decoration:underline}input[type=text],input[type=email],input[type=password],textarea,select{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;font:inherit;background:#fff;color:var(--ink)}input[type=text],input[type=email],input[type=password],select{height:var(--control-height)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid rgba(122,31,61,.38);outline-offset:2px}input[type=file]{display:block;max-width:100%;margin-top:6px;font-size:13px;color:var(--muted)}input[type=file]::file-selector-button{background:#fff;border:1px solid var(--line);color:var(--ink);padding:7px 14px;border-radius:8px;font:inherit;font-size:13px;cursor:pointer;margin-right:10px}input[type=file]::file-selector-button:hover{border-color:var(--maroon);color:var(--maroon)}.row>button{align-self:flex-end;margin-bottom:1px}textarea{resize:vertical}label.field{display:block;margin-bottom:10px;font-size:13px;font-weight:600}label.field>*{margin-top:4px;font-weight:400}.field-hint{margin-top:0;font-weight:400;color:var(--muted);font-size:12px}.row{display:flex;gap:14px;flex-wrap:wrap}.row>*{min-width:0}.row>.grow{flex:1;min-width:min(220px,100%)}.language-control{min-width:170px}.model-control{min-width:260px}.run-settings{display:grid;grid-template-columns:minmax(160px,230px) minmax(320px,720px) max-content;justify-content:start;gap:14px;align-items:end}.run-settings .field{margin-bottom:0}.run-button{min-width:180px;height:var(--control-height)}.construct-row{display:grid;grid-template-columns:minmax(320px,1120px) max-content;justify-content:start;align-items:end;gap:14px}.results-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:14px;flex-wrap:wrap}.result-actions{justify-content:flex-end}.result-actions a{display:inline-flex;text-decoration:none}.pill{display:inline-block;padding:2px 10px;border-radius:999px;font-size:11.5px;font-weight:600}.pill.completed{background:#e6f4ea;color:var(--ok)}.pill.running{background:#fff3e0;color:#b45309}.pill.queued{background:#eef2f7;color:var(--muted)}.pill.failed{background:#fdecea;color:var(--err)}.progress-track{background:var(--line);border-radius:999px;height:7px;overflow:hidden}.progress-fill{background:var(--maroon);height:100%;transition:width .4s ease}.warnings{background:#fff8e6;border:1px solid #f2dfa8;color:#7a5b00;border-radius:8px;padding:10px 14px}.error-banner{background:#fdecea;color:var(--err);border:1px solid #f5c6c0;padding:10px 14px;border-radius:8px;margin-bottom:14px;font-size:13px}.table-wrap{width:100%;overflow-x:auto}table.docs{width:100%;border-collapse:collapse;font-size:13px}table.docs th{text-align:left;color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.5px;padding:6px 8px;border-bottom:1px solid var(--line)}table.docs td{padding:7px 8px;border-bottom:1px solid var(--bg);vertical-align:top;overflow-wrap:anywhere}table.docs td.score{font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap}.stat-grid{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:4px}.stat{flex:1;min-width:110px;background:var(--bg);border-radius:8px;padding:10px 14px}.stat .v{font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}.stat .k{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}.item-bar-row{display:flex;align-items:center;gap:10px;margin-bottom:7px}.item-bar-label{flex:1;font-size:12.5px;min-width:0;overflow-wrap:anywhere}.item-bar-track{flex:1.2;background:var(--bg);border-radius:999px;height:10px}.item-bar-fill{background:var(--maroon);opacity:.85;height:100%;border-radius:999px}.item-bar-val{width:52px;text-align:right;font-variant-numeric:tabular-nums;font-size:12.5px;font-weight:600}.construct-items{margin:8px 0 0;padding-left:20px;color:var(--muted);font-size:12.5px}.construct-items li{margin-bottom:2px}.meta-footer{font-size:12px;color:var(--muted);background:var(--bg);border-radius:8px;padding:10px 14px;margin-top:14px;font-variant-numeric:tabular-nums}.meta-footer code{font-size:11.5px}.muted{color:var(--muted)}.small{font-size:12.5px}.mt{margin-top:12px}.header-auth{margin-left:auto;display:flex;align-items:center;gap:10px;color:#fff}.header-btn{background:#ffffff1f;color:#fff;border:1px solid rgba(255,255,255,.45);padding:5px 14px;border-radius:7px;font:inherit;font-size:13px;cursor:pointer}.header-btn:hover{background:#ffffff38}.project-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}.project-title{font-size:17px;font-weight:650;margin-right:10px}button.danger{color:var(--err);border-color:#f0c4be}button.danger:hover{color:var(--err);border-color:var(--err)}button.danger-solid{background:var(--err)}button.danger-solid:hover{background:#93261b}button.danger-solid:disabled{background:#c9ccd1}.picker{position:relative}.picker-display{width:100%;display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;color:var(--ink);cursor:pointer;text-align:left}.picker-display:hover{border-color:var(--maroon)}.picker-display .picker-caret{margin-left:auto;color:var(--muted);font-size:11px}.picker-search{width:100%;padding:8px 12px;border:1px solid var(--maroon);border-radius:8px;font:inherit;background:#fff}.picker-search:focus{outline:none;box-shadow:0 0 0 3px #7a1f3d1f}.picker-panel{position:absolute;top:calc(100% + 6px);left:0;z-index:50;width:100%;max-width:640px;background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 14px 40px #0000002e;max-height:340px;overflow-y:auto;padding:4px 0 6px}.picker-group{font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);padding:8px 12px 3px;position:sticky;top:0;background:var(--card)}.picker-option{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:6px 12px;cursor:pointer;font-size:13.5px}.picker-option:hover,.picker-option.active{background:var(--accent-soft)}.picker-option.selected .picker-name{font-weight:650;color:var(--maroon)}.picker-name{min-width:0}.picker-meta{flex-shrink:0;font-size:11.5px;color:var(--muted);white-space:nowrap}.picker-empty{padding:12px;margin:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#14161a73;display:flex;align-items:center;justify-content:center;z-index:40;padding:16px}.modal{background:var(--card);border-radius:12px;padding:26px 28px 24px;width:100%;max-width:460px;box-shadow:0 12px 40px #0000002e}.modal h3{margin:0 0 8px;font-size:18px}.modal .hint{color:var(--muted);font-size:13px;line-height:1.5;margin:0 0 4px}.modal form.mt{margin-top:18px}.modal label.field{margin-bottom:16px}.modal label.field:last-of-type{margin-bottom:20px}.modal .row{gap:10px}.modal .row>.primary{flex:1}.modal p.small.muted.mt{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:12.5px;line-height:1.6}@media (max-width: 820px){body{font-size:14px}.header{padding:12px 16px;align-items:flex-start;gap:2px 12px}.header h1{font-size:16px}.header .sub{flex:1 1 210px;font-size:12px;line-height:1.35}.layout{display:block}.sidebar{width:100%;border-right:0;border-bottom:1px solid var(--line);padding:14px}.project-list{max-height:220px;margin-right:0;flex:none}.project-create{border-top:0}.main{width:100%;padding:16px 14px 28px;overflow:visible}.card{padding:16px;margin-bottom:14px}.row,.run-settings,.construct-row,.results-toolbar,.result-actions{gap:10px}.row,.results-toolbar,.result-actions{flex-direction:column;align-items:stretch}.run-settings,.construct-row{grid-template-columns:1fr}.row>.grow,.construct-row>.grow,.language-control,.model-control{width:100%;min-width:0}.main .row>button,.main .row>a,.main .row>a>button,.results-toolbar>button{align-self:stretch;margin-bottom:0;width:100%}.run-button{width:100%;min-width:0}.stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.stat{min-width:0}.item-bar-row{display:grid;grid-template-columns:minmax(0,1fr) 56px;gap:6px 10px}.item-bar-track{grid-column:1 / -1;width:100%}.item-bar-val{width:auto}table.docs{min-width:520px}}@media (max-width: 460px){.header .sub{flex-basis:100%}.stat-grid{grid-template-columns:1fr}}
backend/static/assets/{index-UnZRNG5I.js → index-DrFcy6bH.js} RENAMED
The diff for this file is too large to render. See raw diff
 
backend/static/index.html CHANGED
@@ -4,8 +4,8 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>CCR Platform - Contextualized Construct Representations</title>
7
- <script type="module" crossorigin src="/assets/index-UnZRNG5I.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BAVSoV2O.css">
9
  </head>
10
  <body>
11
  <div id="root"></div>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>CCR Platform - Contextualized Construct Representations</title>
7
+ <script type="module" crossorigin src="/assets/index-DrFcy6bH.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-CN_FzJfm.css">
9
  </head>
10
  <body>
11
  <div id="root"></div>
backend/tests/test_google_auth.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google sign-in (Supabase PKCE): feature flag, redirect flow, callback
2
+ find-or-create, and password-login guard for Google-only accounts. The
3
+ Supabase exchange itself is mocked - no network in tests."""
4
+
5
+ import pytest
6
+ from fastapi.testclient import TestClient
7
+
8
+ from app import auth, auth_google
9
+ from app.main import app
10
+
11
+
12
+ @pytest.fixture()
13
+ def client():
14
+ with TestClient(app) as c:
15
+ yield c
16
+
17
+
18
+ @pytest.fixture()
19
+ def google_env(monkeypatch):
20
+ monkeypatch.setenv("SUPABASE_URL", "https://fakeproj.supabase.co")
21
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "fake-anon-key")
22
+ monkeypatch.setenv("CCR_APP_URL", "http://testserver")
23
+
24
+
25
+ def test_unconfigured_instance_hides_and_refuses_google(client):
26
+ assert client.get("/api/auth/me").json().get("google_available") is False
27
+ assert client.get("/api/auth/google/login", follow_redirects=False).status_code == 503
28
+
29
+
30
+ def test_login_redirects_to_supabase_with_pkce(client, google_env):
31
+ me = client.get("/api/auth/me").json()
32
+ assert me["google_available"] is True
33
+
34
+ resp = client.get("/api/auth/google/login", follow_redirects=False)
35
+ assert resp.status_code == 307
36
+ loc = resp.headers["location"]
37
+ assert loc.startswith("https://fakeproj.supabase.co/auth/v1/authorize?")
38
+ assert "provider=google" in loc
39
+ assert "code_challenge=" in loc and "code_challenge_method=s256" in loc
40
+ assert "redirect_to=http%3A%2F%2Ftestserver%2Fapi%2Fauth%2Fgoogle%2Fcallback" in loc
41
+ assert auth_google.VERIFIER_COOKIE in resp.cookies
42
+
43
+
44
+ def test_callback_creates_user_and_signs_in(client, google_env, monkeypatch):
45
+ monkeypatch.setattr(
46
+ auth_google, "exchange",
47
+ lambda code, verifier: {"email": "pi@lab.edu", "name": "The PI"},
48
+ )
49
+ client.cookies.set(auth_google.VERIFIER_COOKIE, auth.sign_payload({"v": "verifier123"}))
50
+
51
+ resp = client.get("/api/auth/google/callback?code=abc", follow_redirects=False)
52
+ assert resp.status_code == 307 and resp.headers["location"] == "/"
53
+
54
+ me = client.get("/api/auth/me").json()
55
+ assert me["signed_in"] is True and me["email"] == "pi@lab.edu" and me["name"] == "The PI"
56
+
57
+ # second sign-in reuses the same account (no duplicate users)
58
+ client.cookies.set(auth_google.VERIFIER_COOKIE, auth.sign_payload({"v": "verifier456"}))
59
+ client.get("/api/auth/google/callback?code=def", follow_redirects=False)
60
+ from app.db import SessionLocal
61
+ from app.models import User
62
+
63
+ db = SessionLocal()
64
+ try:
65
+ assert db.query(User).filter_by(email="pi@lab.edu").count() == 1
66
+ finally:
67
+ db.close()
68
+
69
+
70
+ def test_callback_without_verifier_fails_safely(client, google_env):
71
+ resp = client.get("/api/auth/google/callback?code=abc", follow_redirects=False)
72
+ assert resp.status_code == 307
73
+ assert "auth_error=" in resp.headers["location"]
74
+
75
+
76
+ def test_google_only_account_cannot_password_login(client, google_env, monkeypatch):
77
+ monkeypatch.setattr(
78
+ auth_google, "exchange",
79
+ lambda code, verifier: {"email": "gonly@lab.edu", "name": "G Only"},
80
+ )
81
+ client.cookies.set(auth_google.VERIFIER_COOKIE, auth.sign_payload({"v": "v1"}))
82
+ client.get("/api/auth/google/callback?code=abc", follow_redirects=False)
83
+ client.post("/api/auth/logout")
84
+
85
+ resp = client.post(
86
+ "/api/auth/login", json={"email": "gonly@lab.edu", "password": "password123"}
87
+ )
88
+ assert resp.status_code == 401
89
+ assert "Google" in resp.json()["detail"]
frontend/src/App.jsx CHANGED
@@ -60,6 +60,13 @@ export default function App() {
60
  useEffect(() => {
61
  loadProjects();
62
  loadAuth();
 
 
 
 
 
 
 
63
  }, []);
64
 
65
  async function handleAuthSubmit(e) {
@@ -158,6 +165,16 @@ export default function App() {
158
  and keeps your datasets and runs instead of deleting them after analysis.
159
  </p>
160
  {authError && <p className="small" style={{ color: "var(--danger, #b3261e)" }}>{authError}</p>}
 
 
 
 
 
 
 
 
 
 
161
  <form onSubmit={handleAuthSubmit} className="mt">
162
  {authMode === "register" && (
163
  <label className="field">
@@ -226,7 +243,9 @@ export default function App() {
226
  </button>
227
  </>
228
  )}
229
- {" "}· Google sign-in arrives with lab accounts. Forgot your password? Contact the lab admin.
 
 
230
  </p>
231
  </div>
232
  </div>
 
60
  useEffect(() => {
61
  loadProjects();
62
  loadAuth();
63
+ // Surface Google sign-in failures passed back via redirect.
64
+ const params = new URLSearchParams(window.location.search);
65
+ const authFail = params.get("auth_error");
66
+ if (authFail) {
67
+ setError(`Sign-in problem: ${authFail.replaceAll("-", " ")}.`);
68
+ window.history.replaceState({}, "", "/");
69
+ }
70
  }, []);
71
 
72
  async function handleAuthSubmit(e) {
 
165
  and keeps your datasets and runs instead of deleting them after analysis.
166
  </p>
167
  {authError && <p className="small" style={{ color: "var(--danger, #b3261e)" }}>{authError}</p>}
168
+ {auth?.google_available && (
169
+ <>
170
+ <a className="primary google-btn" href="/api/auth/google/login">
171
+ Continue with Google
172
+ </a>
173
+ <p className="small muted" style={{ textAlign: "center", margin: "8px 0" }}>
174
+ or use email and password
175
+ </p>
176
+ </>
177
+ )}
178
  <form onSubmit={handleAuthSubmit} className="mt">
179
  {authMode === "register" && (
180
  <label className="field">
 
243
  </button>
244
  </>
245
  )}
246
+ {auth?.google_available
247
+ ? " · Forgot your password? Contact the lab admin, or use Google."
248
+ : " · Google sign-in arrives with lab accounts. Forgot your password? Contact the lab admin."}
249
  </p>
250
  </div>
251
  </div>
frontend/src/styles.css CHANGED
@@ -138,6 +138,15 @@ button.primary {
138
  }
139
  button.primary:hover { background: var(--maroon-dark); }
140
  button.primary:disabled { background: #c9ccd1; cursor: not-allowed; }
 
 
 
 
 
 
 
 
 
141
  button.ghost {
142
  background: none; border: 1px solid var(--line); color: var(--ink);
143
  min-height: var(--control-height);
 
138
  }
139
  button.primary:hover { background: var(--maroon-dark); }
140
  button.primary:disabled { background: #c9ccd1; cursor: not-allowed; }
141
+ a.google-btn {
142
+ background: var(--maroon); color: #fff; border: none;
143
+ min-height: var(--control-height);
144
+ display: flex; align-items: center; justify-content: center;
145
+ padding: 0 18px; border-radius: 8px; font: inherit; font-weight: 600;
146
+ cursor: pointer; line-height: 1.2; text-decoration: none; width: 100%;
147
+ box-sizing: border-box;
148
+ }
149
+ a.google-btn:hover { background: var(--maroon-dark); }
150
  button.ghost {
151
  background: none; border: 1px solid var(--line); color: var(--ink);
152
  min-height: var(--control-height);