Restore Google sign-in popup + add tutor Fast/Smart model toggle - #73

Merged
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle
May 4, 2026
Merged

Restore Google sign-in popup + add tutor Fast/Smart model toggle#73
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Conversation

@AndresL230

@AndresL230AndresL230 commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Two independent, complementary changes ship together on this branch.


1. Rebuild Google sign-in popup on BroadcastChannel

Restores the popup sign-in flow that was reverted in 5669639 (fix(auth): use top-level redirect for Google OAuth instead of popup). That earlier revert was correct for the bug at the time — COOP severs window.opener the moment the popup hops to a cross-origin URL (Railway, then Google), so postMessage from /auth/callback never reached the opener and the modal hung on "Waiting for Google…".

This rebuild does not depend on window.opener — so COOP severing the opener handle is harmless.

How it works:

  • The opener (SignInModal.tsx) generates a per-attempt popup_id (crypto.randomUUID) and subscribes to BroadcastChannel("sapling_signin:<id>")before opening the window.
  • The id is threaded through the OAuth state blob (backend/routes/auth.py::google_login) so the backend can echo it back.
  • /api/auth/google/callback decodes popup_id from state and includes it on the redirect to /auth/callback.
  • /auth/callback (frontend/src/app/auth/callback/page.tsx) detects popup mode by the popup_id query (not window.opener, which COOP nulls), broadcasts the result on the same channel, then window.close()s.
  • BroadcastChannel is same-origin, so the cross-origin nav through Google is irrelevant.

Failure paths also broadcast.google_not_configured, invalid_domain, and not_approved now route through /auth/callback?error=...&popup_id=... when popup_id is set, so the popup can broadcast the error and self-close instead of stranding the opener on /auth (which doesn't exist post-modal-refactor) or /pending. Same-tab behavior is unchanged.

Fallbacks / safety:

  • If window.open returns null (pop-up blocker) or BroadcastChannel / crypto.randomUUID are unavailable → falls back to today's full same-tab redirect (no regression).
  • A 3-minute watchdog plus a Cancel link reset the modal if the user closes the popup themselves. popup.closed isn't reliably observable under COOP, so we can't poll for it.

2. Tutor chat Fast/Smart model toggle (default Fast)

Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action (commit 6f431d6). Reasoning quality is great; latency is enough to feel blocked. Adds a per-user toggle in the chat header so the student decides when speed matters.

Backend (backend/routes/learn.py, backend/models/__init__.py):

  • StartSessionBody / ChatBody / ActionBody accept an optional model_pref: "fast" | "smart".
  • New _resolve_tutor_model maps "fast"MODEL_DEFAULT (gemini-2.5-flash), "smart"MODEL_SMART (gemini-2.5-pro). Unknown / missing → fast.
  • All three call sites (start_session, chat, action) routed through the resolver.

Frontend (frontend/src/components/ModelToggle.tsx (new), screens/Learn.tsx, lib/api.ts):

  • New ModelToggle component mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern (sapling_model_pref key).
  • useModelPref defaults to "fast" on first load; persists user's choice across reloads.
  • "Smart" is the highlighted opt-in state (matches the existing convention that the highlighted toggle = the optional thing).
  • Mounted in the chat TopBar between the AI disclaimer chip and Class intel toggle.
  • modelPref threaded through startSession, sendChat, and learnAction.

Defaults are airtight on both sides. A fresh user opening their first chat: useState<ModelPref>("fast") is the initial render value, so the first startSession call explicitly sends model_pref: "fast". Even if that field were ever omitted, the backend resolver still returns MODEL_DEFAULT.


Test plan

Auth popup

  • Click "Continue with Google" on the landing page modal → popup opens centered, ~520×640.
  • Complete sign-in → popup self-closes, modal closes, redirected to /dashboard (or onboarding if not completed).
  • With pop-up blocker enabled → falls back to same-tab redirect (no hang).
  • Click Cancel while waiting → modal resets cleanly.
  • Sign in with non-@bu.edu account → popup self-closes, modal shows "Sign-in is limited to approved school accounts" (instead of stranding the popup on /auth).
  • Sign in with an unapproved @bu.edu account → popup self-closes, modal shows "Your account is pending approval" (instead of leaving popup on /pending).
  • Existing same-tab redirect path (no popup_id) still works end-to-end if anyone hits /api/auth/google directly.

Tutor model toggle

  • Open /learn as a fresh user (clear localStorage first) → toggle reads "Fast" (un-highlighted).
  • Start a chat → the first POST /api/learn/start-session body includes model_pref: "fast" (Network tab).
  • Verify backend logs / Logfire trace show gemini-2.5-flash was used.
  • Flip to "Smart" → toggle highlights, value persists across page refresh.
  • Send a message → POST /api/learn/chat body includes model_pref: "smart", backend uses gemini-2.5-pro.
  • Hint / Confused / Skip actions also respect the current toggle.
  • Verified end-to-end: _resolve_tutor_model(None | "" | "fast" | "garbage")MODEL_DEFAULT; _resolve_tutor_model("smart")MODEL_SMART.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tutor model selection: UI toggle to choose "fast" or "smart"; preference is persisted and sent with learning requests.
    • Improved Google sign-in popup flow: popup handoff now uses a broadcast channel with better popup-id handling, fallback, timeout, and cleanup.
  • Tests

    • Added end-to-end and unit tests for auth state handling and tutor model resolution.

AndresL230and others added 2 commits May 4, 2026 04:15
Restores the popup sign-in flow that was reverted in 5669639 (commit
"use top-level redirect for Google OAuth instead of popup"). That earlier
revert was correct for the bug at the time -- COOP severs window.opener
the moment the popup hops to a cross-origin URL (Railway, then Google),
so postMessage from /auth/callback never reached the opener and the modal
hung on "Waiting for Google...".
This rebuild sidesteps the opener handle entirely. The opener generates a
per-attempt popup_id, subscribes to BroadcastChannel("sapling_signin:<id>")
*before* opening the window, and threads the id through the OAuth state
blob. /api/auth/google/callback echoes the id back on the redirect to
/auth/callback, which detects popup mode by the id's presence (not by
window.opener, which COOP nulls), broadcasts the result on the same
channel, then self-closes. BroadcastChannel is same-origin and survives
the cross-origin nav, so COOP is harmless.
Failure paths (google_not_configured, invalid_domain, not_approved) now
also route through /auth/callback when popup_id is set, so the popup can
broadcast the error and self-close instead of stranding the opener on
/auth or /pending.
Falls back to today's same-tab redirect when window.open returns null
(pop-up blocker) or BroadcastChannel/crypto.randomUUID is unavailable.
A 3-minute watchdog plus a Cancel link reset the modal if the user
abandons the popup -- popup.closed isn't observable under COOP, so we
can't poll for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session,
chat, and action -- great for reasoning quality, sluggish enough that
users felt blocked. Adds a per-user UI toggle in the chat TopBar so the
student decides when speed matters.
Backend: StartSessionBody / ChatBody / ActionBody accept optional
model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" ->
MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro),
unknown / missing -> fast. The default is fast on both sides so first-time
chats are snappy by default; users opt in to Smart when they want depth.
Frontend: new ModelToggle component (mirrors SharedContextToggle's
look-and-feel and localStorage-persisted hook pattern, key
sapling_model_pref). Mounted in the chat header next to the Class intel
toggle. modelPref is threaded through startSession, sendChat, and
learnAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 4, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an optional per-request model preference ("fast" | "smart") propagated from frontend toggle through API to backend model resolver, and replaces opener-based OAuth popup messaging with a popup + BroadcastChannel flow plus server-side popup-aware OAuth state cookie handling and routing.

Changes

Model Preference Selection System

Layer / File(s)Summary
Data Shape
backend/models/__init__.py
Adds model_pref: Optional[Literal["fast","smart"]] = None to StartSessionBody, ChatBody, and ActionBody.
API Types / Client
frontend/src/lib/api.ts
Adds ModelPref type and optional modelPref?: ModelPref params to startSession, sendChat, and learnAction; request bodies conditionally include model_pref.
UI State & Component
frontend/src/components/ModelToggle.tsx
New ModelPref type, useModelPref() hook with localStorage persistence, and ModelToggle switch component (tooltip, accessible attributes).
Screen Integration
frontend/src/components/screens/Learn.tsx
Adds useModelPref() usage, surface ModelToggle in TopBar, and forwards modelPref into startSession/sendChat/learnAction calls; updates send deps.
Backend Model Resolution
backend/routes/learn.py
Adds _MODEL_PREF_TO_MODEL and _resolve_tutor_model(...); /start-session, /chat, and /action use resolved model (fallback to MODEL_DEFAULT) instead of fixed MODEL_SMART.
Tests
backend/tests/test_learn_routes.py
Adds tests verifying _resolve_tutor_model mapping/behaviour for various inputs.

Popup-Based OAuth Flow

Layer / File(s)Summary
Parameter Validation & Utilities
backend/routes/auth.py
Adds popup_id validation regex, in-memory fallback nonce store, cookie name/ttl constants, and helper functions (_clean_popup_id, _encode_oauth_cookie, _decode_oauth_cookie, _prune_fallback_store).
OAuth Login Endpoint
backend/routes/auth.py
google_login(popup_id: str = Query(None)) now stores code_verifier and cleaned popup_id in signed/nonce cookie and sets OAuth state to a nonce-only payload.
Callback Handling & Routing
backend/routes/auth.py
google_callback decodes/verifies cookie, validates state nonce, centralizes error routing via _fail_redirect(error_code, ...) which clears cookie and conditionally appends popup_id; success redirect also conditionally includes popup_id.
Frontend Callback Broadcast
frontend/src/app/auth/callback/page.tsx
Replaces window.opener postMessage with BroadcastChannel keyed by popup_id; isPopup requires popup_id; broadcasts signin/failure then attempts window.close().
Popup Sign-in Lifecycle
frontend/src/components/SignInModal.tsx
Generates per-attempt popup_id (crypto.randomUUID), opens centered popup, listens on BroadcastChannel, implements watchdog timeout, centralized cleanupPopupListeners() and a Cancel button to abort; falls back to same-tab redirect when popup/BroadcastChannel unsupported.
Tests
backend/tests/test_auth_state.py
New tests covering cookie encode/decode round-trip, tamper detection, in-memory fallback behavior, _clean_popup_id validation, _decode_state handling, and callback redirect/error cases (including popup-aware redirects).
sequenceDiagram
actor User
participant SignInModal as SignInModal (Main Page)
participant Popup as Popup Window (auth/callback)
participant GoogleOAuth as Google OAuth
participant Backend as Backend (/auth)
participant BroadcastCh as BroadcastChannel
User->>SignInModal: Click "Sign in with Google"
SignInModal->>SignInModal: Generate popup_id (crypto.randomUUID)
SignInModal->>Popup: Open popup to /auth/google?popup_id=...
Popup->>Backend: GET /auth/google?popup_id=...
Backend->>Backend: Store code_verifier + popup_id in signed cookie (nonce-only state)
Backend->>GoogleOAuth: Redirect to Google consent screen
GoogleOAuth->>Backend: Redirect to /auth/google/callback?code=...&state=...
Backend->>Backend: Decode cookie, verify state nonce, exchange code
Backend->>Popup: Redirect to /auth/callback?popup_id=... (final client page)
Popup->>Popup: Fetch session / finalize auth
Popup->>BroadcastCh: Post { type: 'sapling_signin', success: true, ... } on popup_id channel
Popup->>Popup: window.close()
SignInModal->>BroadcastCh: Listening on popup_id channel
BroadcastCh->>SignInModal: Deliver signin payload
SignInModal->>SignInModal: Update UI/session and navigate appropriately
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#60: Modifies backend/routes/auth.py and OAuth state/callback handling; closely related to the popup/state changes here.

Poem

🐰 I hopped and toggled, fast or smart,
Poked popups, bound the pieces part,
Cookies signed and channels sung,
A rabbit's toggle—new paths sprung,
Hop in, choose—both do their part.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly and concisely summarizes the two main changes: restoring the Google sign-in popup and adding a tutor model toggle feature.
Description check✅ PassedThe description follows the template structure with detailed explanations of both features, changes made, test plan, and comprehensive technical context.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend3f99981Commit Preview URL

Branch Preview URL
May 04 2026, 08:24 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

160-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route Google callback failures through _fail_redirect.

If the user cancels on Google's consent screen, Google returns error=access_denied instead of code. With code required here, that becomes a 422; similarly, fetch_token() / userinfo() exceptions will 500. In both cases the popup never gets oauth_denied or signin_failed, so the new popup flow strands the user on an error page instead of closing cleanly.

Possible fix
-@router.get("/google/callback")-def google_callback(code: str = Query(...), state: str = Query(None)):+@router.get("/google/callback")+def google_callback(+ code: str | None = Query(None),+ state: str | None = Query(None),+ error: str | None = Query(None),+):
"""Exchange auth code for tokens, validate `@bu.edu`, upsert user."""
state_data = _decode_state(state) if state else {}
code_verifier = state_data.get("cv")
popup_id = state_data.get("popup_id")
@@
+ if error == "access_denied":+ return _fail_redirect("oauth_denied")+ if not code:+ return _fail_redirect("signin_failed")+
flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES)
flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI
- flow.fetch_token(code=code, code_verifier=code_verifier)- creds = flow.credentials-- # Fetch user info from Google- service = build("oauth2", "v2", credentials=creds)- user_info = service.userinfo().get().execute()+ try:+ flow.fetch_token(code=code, code_verifier=code_verifier)+ creds = flow.credentials+ service = build("oauth2", "v2", credentials=creds)+ user_info = service.userinfo().get().execute()+ except Exception:+ return _fail_redirect("signin_failed")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 160 - 185, The google_callback route
should route all auth failures through _fail_redirect: check for an incoming
'error' query param (e.g. access_denied) at the start of google_callback and
call _fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and
the Google userinfo call (service.userinfo().get().execute()) in try/except and
on any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
🧹 Nitpick comments (1)
frontend/src/lib/api.ts (1)

82-82: ⚡ Quick win

ModelPref is declared twice — import from a single source

ModelPref = 'smart' | 'fast' is defined identically here (line 82) and in frontend/src/components/ModelToggle.tsx (line 5). TypeScript structural typing hides this today, but a future addition to one definition (e.g., a third tier) won't automatically update the other, causing silent divergence.

Since ModelPref belongs to the API contract, keep the definition in api.ts and import it in ModelToggle.tsx:

♻️ Proposed fix

In api.ts, keep line 82 as-is.

In frontend/src/components/ModelToggle.tsx:

-export type ModelPref = "smart" | "fast";+export type { ModelPref } from "@/lib/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/lib/api.ts` at line 82, Remove the duplicate ModelPref type
declaration and import the canonical type from the API module: keep the export
type ModelPref = 'smart' | 'fast' in api.ts and in the ModelToggle component
(ModelToggle.tsx) delete its local ModelPref declaration and add an import of
ModelPref from the API module, then update any references in the ModelToggle
component to use the imported ModelPref type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/models/__init__.py`:
- Line 13: Update the misleading comment on model_pref to reflect that the
actual default is the "fast" model (gemini-2.5-flash) not "smart"; modify the
comment near the model_pref variable to state that valid values are "smart"
(gemini-2.5-pro) or "fast" (gemini-2.5-flash) and that None/unknown resolves to
the MODEL_DEFAULT (gemini-2.5-flash) via _resolve_tutor_model. Ensure the
wording matches the PR documentation ("default Fast") and references
MODEL_DEFAULT/_resolve_tutor_model behavior so readers aren’t misled.
In `@backend/routes/auth.py`:
- Around line 147-149: The state construction (state_payload with keys "action",
"cv" (code_verifier), and optional "popup_id") must be changed to include only a
server-bound nonce rather than the PKCE verifier; generate a strong random nonce
when initiating OAuth, store it server-side (e.g., in a session store or
HttpOnly cookie keyed to the browser session), set state_payload to {"n": nonce}
(omit cv and popup_id), and store the code_verifier and popup_id server-side
tied to that nonce; then update the OAuth callback handler (the code that reads
state and exchanges the code) to first validate the returned nonce against the
server-side store/cookie and only after a successful match retrieve the stored
code_verifier and popup_id to perform the token exchange and complete signin,
rejecting any callback with a missing/invalid nonce.
In `@frontend/src/components/ModelToggle.tsx`:
- Around line 41-56: The switch button's tooltip text is inaccessible because
the tooltip div is conditionally rendered and aria-describedby may point to a
non-existent element; update the button (the element using isSmart and onChange)
to include a static aria-label that combines the visible state and the
explanatory text (e.g., "Smart: stronger reasoning" vs "Fast: quicker replies")
so screen readers always receive the description, while leaving the visual
tooltip rendering unchanged for sighted users.
In `@frontend/src/components/SignInModal.tsx`:
- Around line 117-118: The SignInModal currently always appends popup_id to the
Google auth URL (const popupId = crypto.randomUUID(); const url =
`${API_URL}/api/auth/google?popup_id=...`) which breaks the same-tab fallback
when window.open is blocked; change the logic so that you only include popup_id
when the auth flow is actually launched in a popup. Concretely, construct two
URLs (or build the query conditionally): one with popup_id for the popup path
used by window.open, and one without popup_id for the same-tab fallback
navigation; ensure the code paths in the SignInModal component that call
window.open use the popup URL, while the fallback window.location.href (or
equivalent) uses the URL without popup_id, and apply the same conditional change
to the other occurrence noted (the code block around lines 151-157).
- Around line 123-143: The handler on channel.onmessage (inside the SignInModal
component) incorrectly requires data.name for a successful signin; change the
success condition to check only data.success and data.userId (i.e. if
(data.success && data.userId) ), and call setActiveUser(data.userId, data.name
|| "") so missing/empty display names are accepted; keep
cleanupPopupListeners(), setWaiting(false), confirmApproved(), the
router.replace("/dashboard") vs
sessionStorage.setItem("sapling_onboarding_pending", "1") logic, and onClose()
unchanged, and only call setLocalError when the success condition fails.
---
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 160-185: The google_callback route should route all auth failures
through _fail_redirect: check for an incoming 'error' query param (e.g.
access_denied) at the start of google_callback and call
_fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and the
Google userinfo call (service.userinfo().get().execute()) in try/except and on
any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
---
Nitpick comments:
In `@frontend/src/lib/api.ts`:
- Line 82: Remove the duplicate ModelPref type declaration and import the
canonical type from the API module: keep the export type ModelPref = 'smart' |
'fast' in api.ts and in the ModelToggle component (ModelToggle.tsx) delete its
local ModelPref declaration and add an import of ModelPref from the API module,
then update any references in the ModelToggle component to use the imported
ModelPref type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 577d888d-5173-4b24-94a1-8cff9ad25fe1

📥 Commits

Reviewing files that changed from the base of the PR and between e146125 and 90ba796.

📒 Files selected for processing (8)
  • backend/models/__init__.py
  • backend/routes/auth.py
  • backend/routes/learn.py
  • frontend/src/app/auth/callback/page.tsx
  • frontend/src/components/ModelToggle.tsx
  • frontend/src/components/SignInModal.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/models/__init__.py Outdated
Comment threadbackend/routes/auth.py Outdated
Comment threadfrontend/src/components/ModelToggle.tsx
Comment threadfrontend/src/components/SignInModal.tsx Outdated
Comment threadfrontend/src/components/SignInModal.tsx
Address PR #73 review feedback:
- auth: bind state nonce to HttpOnly cookie carrying {nonce, cv, popup_id}
(HMAC-signed via SESSION_SECRET); reject mismatched/missing state with
invalid_state. Closes login-CSRF where attacker-crafted state could log
victim into attacker's account.
- auth: wrap flow.fetch_token in try/except → oauth_exchange_failed so
popup self-closes instead of returning 500 on replayed/expired codes.
- auth: validate popup_id charset/length ([A-Za-z0-9_-]{1,128}); invalid
input degrades to non-popup mode.
- SignInModal: split popupUrl (with popup_id) from sameTabUrl (without)
so popup-blocker fallback redirects locally instead of broadcasting
into a dead channel.
- SignInModal: don't require data.name on success — /auth/callback
legitimately broadcasts empty name when /api/auth/me has no display.
- models: tighten model_pref to Optional[Literal["fast","smart"]] so
garbage input 422s; fix misleading default-comment.
- ModelToggle: static aria-label so screen readers hear the description
even when the visual tooltip is hidden.
- tests: add 23 unit tests for auth state cookie/nonce/popup_id helpers
and 6 for _resolve_tutor_model.
import json
import base64

import pytest
@AndresL230
AndresL230 merged commit a578006 into mainMay 4, 2026
3 of 4 checks passed
@AndresL230
AndresL230 deleted the fix/auth-popup-broadcast-channel-and-tutor-model-toggle branch May 4, 2026 20:25
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
Closes the asymmetry I flagged in the post-merge review: chat tutor
got `model_pref: Literal["fast", "smart"]` per-request via PR #73,
quiz had only env-var-driven model selection. Now both routes accept
the same body field and route to the same model strings.
Wiring
- backend/models/__init__.py: GenerateQuizBody.model_pref accepts
"fast" | "smart" | None. Default None falls through to the agent's
task-default (model_for("quiz") = gemini-2.5-flash-lite per
ADR 0008).
- backend/routes/quiz.py:
* New _resolve_model_pref(pref) builds an optional GoogleModel
override from the body's preference, returning None for
None/empty/unknown so the agent's default wins on degraded input.
* _quiz_via_agent threads `model_pref` through and passes
`model=...` to quiz_agent.run only when an override is present
(no model kwarg = agent default).
* _legacy_generate_quiz now picks MODEL_SMART when pref="smart",
falls back to MODEL_LITE otherwise. So the fast/smart toggle
works on BOTH paths, including when the agent path trips and we
degrade.
- _PREF_MODEL_NAMES table at module scope so the {fast→flash,
smart→pro} mapping is one place. Mirrors the chat tutor's mapping
in services/gemini_service.py (MODEL_DEFAULT/MODEL_SMART).
Tests (TestQuizModelPref, 5 new)
- model_pref="smart" → quiz_agent.run gets model=GoogleModel('gemini-2.5-pro')
- model_pref="fast" → quiz_agent.run gets model=GoogleModel('gemini-2.5-flash')
- model_pref=None → quiz_agent.run gets NO model kwarg (agent default wins)
- model_pref="auto" → _resolve_model_pref returns None (graceful unknown)
- legacy fallback with smart → call_gemini_json gets model=MODEL_SMART
Documentation: ADR 0013 addendum captures the design decision (env var
for ops defaults + body field for per-request overrides; they compose).
Tests
- tests/test_quiz_routes.py: 28/28 (was 23, +5).
- Full backend suite: 531 passed, 3 pre-existing live-Supabase
failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Restore Google sign-in popup + add tutor Fast/Smart model toggle - #73

Merged
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle
May 4, 2026
Merged

Restore Google sign-in popup + add tutor Fast/Smart model toggle#73
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Conversation

@AndresL230

@AndresL230AndresL230 commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Two independent, complementary changes ship together on this branch.


1. Rebuild Google sign-in popup on BroadcastChannel

Restores the popup sign-in flow that was reverted in 5669639 (fix(auth): use top-level redirect for Google OAuth instead of popup). That earlier revert was correct for the bug at the time — COOP severs window.opener the moment the popup hops to a cross-origin URL (Railway, then Google), so postMessage from /auth/callback never reached the opener and the modal hung on "Waiting for Google…".

This rebuild does not depend on window.opener — so COOP severing the opener handle is harmless.

How it works:

  • The opener (SignInModal.tsx) generates a per-attempt popup_id (crypto.randomUUID) and subscribes to BroadcastChannel("sapling_signin:<id>")before opening the window.
  • The id is threaded through the OAuth state blob (backend/routes/auth.py::google_login) so the backend can echo it back.
  • /api/auth/google/callback decodes popup_id from state and includes it on the redirect to /auth/callback.
  • /auth/callback (frontend/src/app/auth/callback/page.tsx) detects popup mode by the popup_id query (not window.opener, which COOP nulls), broadcasts the result on the same channel, then window.close()s.
  • BroadcastChannel is same-origin, so the cross-origin nav through Google is irrelevant.

Failure paths also broadcast.google_not_configured, invalid_domain, and not_approved now route through /auth/callback?error=...&popup_id=... when popup_id is set, so the popup can broadcast the error and self-close instead of stranding the opener on /auth (which doesn't exist post-modal-refactor) or /pending. Same-tab behavior is unchanged.

Fallbacks / safety:

  • If window.open returns null (pop-up blocker) or BroadcastChannel / crypto.randomUUID are unavailable → falls back to today's full same-tab redirect (no regression).
  • A 3-minute watchdog plus a Cancel link reset the modal if the user closes the popup themselves. popup.closed isn't reliably observable under COOP, so we can't poll for it.

2. Tutor chat Fast/Smart model toggle (default Fast)

Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action (commit 6f431d6). Reasoning quality is great; latency is enough to feel blocked. Adds a per-user toggle in the chat header so the student decides when speed matters.

Backend (backend/routes/learn.py, backend/models/__init__.py):

  • StartSessionBody / ChatBody / ActionBody accept an optional model_pref: "fast" | "smart".
  • New _resolve_tutor_model maps "fast"MODEL_DEFAULT (gemini-2.5-flash), "smart"MODEL_SMART (gemini-2.5-pro). Unknown / missing → fast.
  • All three call sites (start_session, chat, action) routed through the resolver.

Frontend (frontend/src/components/ModelToggle.tsx (new), screens/Learn.tsx, lib/api.ts):

  • New ModelToggle component mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern (sapling_model_pref key).
  • useModelPref defaults to "fast" on first load; persists user's choice across reloads.
  • "Smart" is the highlighted opt-in state (matches the existing convention that the highlighted toggle = the optional thing).
  • Mounted in the chat TopBar between the AI disclaimer chip and Class intel toggle.
  • modelPref threaded through startSession, sendChat, and learnAction.

Defaults are airtight on both sides. A fresh user opening their first chat: useState<ModelPref>("fast") is the initial render value, so the first startSession call explicitly sends model_pref: "fast". Even if that field were ever omitted, the backend resolver still returns MODEL_DEFAULT.


Test plan

Auth popup

  • Click "Continue with Google" on the landing page modal → popup opens centered, ~520×640.
  • Complete sign-in → popup self-closes, modal closes, redirected to /dashboard (or onboarding if not completed).
  • With pop-up blocker enabled → falls back to same-tab redirect (no hang).
  • Click Cancel while waiting → modal resets cleanly.
  • Sign in with non-@bu.edu account → popup self-closes, modal shows "Sign-in is limited to approved school accounts" (instead of stranding the popup on /auth).
  • Sign in with an unapproved @bu.edu account → popup self-closes, modal shows "Your account is pending approval" (instead of leaving popup on /pending).
  • Existing same-tab redirect path (no popup_id) still works end-to-end if anyone hits /api/auth/google directly.

Tutor model toggle

  • Open /learn as a fresh user (clear localStorage first) → toggle reads "Fast" (un-highlighted).
  • Start a chat → the first POST /api/learn/start-session body includes model_pref: "fast" (Network tab).
  • Verify backend logs / Logfire trace show gemini-2.5-flash was used.
  • Flip to "Smart" → toggle highlights, value persists across page refresh.
  • Send a message → POST /api/learn/chat body includes model_pref: "smart", backend uses gemini-2.5-pro.
  • Hint / Confused / Skip actions also respect the current toggle.
  • Verified end-to-end: _resolve_tutor_model(None | "" | "fast" | "garbage")MODEL_DEFAULT; _resolve_tutor_model("smart")MODEL_SMART.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tutor model selection: UI toggle to choose "fast" or "smart"; preference is persisted and sent with learning requests.
    • Improved Google sign-in popup flow: popup handoff now uses a broadcast channel with better popup-id handling, fallback, timeout, and cleanup.
  • Tests

    • Added end-to-end and unit tests for auth state handling and tutor model resolution.

AndresL230and others added 2 commits May 4, 2026 04:15
Restores the popup sign-in flow that was reverted in 5669639 (commit
"use top-level redirect for Google OAuth instead of popup"). That earlier
revert was correct for the bug at the time -- COOP severs window.opener
the moment the popup hops to a cross-origin URL (Railway, then Google),
so postMessage from /auth/callback never reached the opener and the modal
hung on "Waiting for Google...".
This rebuild sidesteps the opener handle entirely. The opener generates a
per-attempt popup_id, subscribes to BroadcastChannel("sapling_signin:<id>")
*before* opening the window, and threads the id through the OAuth state
blob. /api/auth/google/callback echoes the id back on the redirect to
/auth/callback, which detects popup mode by the id's presence (not by
window.opener, which COOP nulls), broadcasts the result on the same
channel, then self-closes. BroadcastChannel is same-origin and survives
the cross-origin nav, so COOP is harmless.
Failure paths (google_not_configured, invalid_domain, not_approved) now
also route through /auth/callback when popup_id is set, so the popup can
broadcast the error and self-close instead of stranding the opener on
/auth or /pending.
Falls back to today's same-tab redirect when window.open returns null
(pop-up blocker) or BroadcastChannel/crypto.randomUUID is unavailable.
A 3-minute watchdog plus a Cancel link reset the modal if the user
abandons the popup -- popup.closed isn't observable under COOP, so we
can't poll for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session,
chat, and action -- great for reasoning quality, sluggish enough that
users felt blocked. Adds a per-user UI toggle in the chat TopBar so the
student decides when speed matters.
Backend: StartSessionBody / ChatBody / ActionBody accept optional
model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" ->
MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro),
unknown / missing -> fast. The default is fast on both sides so first-time
chats are snappy by default; users opt in to Smart when they want depth.
Frontend: new ModelToggle component (mirrors SharedContextToggle's
look-and-feel and localStorage-persisted hook pattern, key
sapling_model_pref). Mounted in the chat header next to the Class intel
toggle. modelPref is threaded through startSession, sendChat, and
learnAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 4, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an optional per-request model preference ("fast" | "smart") propagated from frontend toggle through API to backend model resolver, and replaces opener-based OAuth popup messaging with a popup + BroadcastChannel flow plus server-side popup-aware OAuth state cookie handling and routing.

Changes

Model Preference Selection System

Layer / File(s)Summary
Data Shape
backend/models/__init__.py
Adds model_pref: Optional[Literal["fast","smart"]] = None to StartSessionBody, ChatBody, and ActionBody.
API Types / Client
frontend/src/lib/api.ts
Adds ModelPref type and optional modelPref?: ModelPref params to startSession, sendChat, and learnAction; request bodies conditionally include model_pref.
UI State & Component
frontend/src/components/ModelToggle.tsx
New ModelPref type, useModelPref() hook with localStorage persistence, and ModelToggle switch component (tooltip, accessible attributes).
Screen Integration
frontend/src/components/screens/Learn.tsx
Adds useModelPref() usage, surface ModelToggle in TopBar, and forwards modelPref into startSession/sendChat/learnAction calls; updates send deps.
Backend Model Resolution
backend/routes/learn.py
Adds _MODEL_PREF_TO_MODEL and _resolve_tutor_model(...); /start-session, /chat, and /action use resolved model (fallback to MODEL_DEFAULT) instead of fixed MODEL_SMART.
Tests
backend/tests/test_learn_routes.py
Adds tests verifying _resolve_tutor_model mapping/behaviour for various inputs.

Popup-Based OAuth Flow

Layer / File(s)Summary
Parameter Validation & Utilities
backend/routes/auth.py
Adds popup_id validation regex, in-memory fallback nonce store, cookie name/ttl constants, and helper functions (_clean_popup_id, _encode_oauth_cookie, _decode_oauth_cookie, _prune_fallback_store).
OAuth Login Endpoint
backend/routes/auth.py
google_login(popup_id: str = Query(None)) now stores code_verifier and cleaned popup_id in signed/nonce cookie and sets OAuth state to a nonce-only payload.
Callback Handling & Routing
backend/routes/auth.py
google_callback decodes/verifies cookie, validates state nonce, centralizes error routing via _fail_redirect(error_code, ...) which clears cookie and conditionally appends popup_id; success redirect also conditionally includes popup_id.
Frontend Callback Broadcast
frontend/src/app/auth/callback/page.tsx
Replaces window.opener postMessage with BroadcastChannel keyed by popup_id; isPopup requires popup_id; broadcasts signin/failure then attempts window.close().
Popup Sign-in Lifecycle
frontend/src/components/SignInModal.tsx
Generates per-attempt popup_id (crypto.randomUUID), opens centered popup, listens on BroadcastChannel, implements watchdog timeout, centralized cleanupPopupListeners() and a Cancel button to abort; falls back to same-tab redirect when popup/BroadcastChannel unsupported.
Tests
backend/tests/test_auth_state.py
New tests covering cookie encode/decode round-trip, tamper detection, in-memory fallback behavior, _clean_popup_id validation, _decode_state handling, and callback redirect/error cases (including popup-aware redirects).
sequenceDiagram
actor User
participant SignInModal as SignInModal (Main Page)
participant Popup as Popup Window (auth/callback)
participant GoogleOAuth as Google OAuth
participant Backend as Backend (/auth)
participant BroadcastCh as BroadcastChannel
User->>SignInModal: Click "Sign in with Google"
SignInModal->>SignInModal: Generate popup_id (crypto.randomUUID)
SignInModal->>Popup: Open popup to /auth/google?popup_id=...
Popup->>Backend: GET /auth/google?popup_id=...
Backend->>Backend: Store code_verifier + popup_id in signed cookie (nonce-only state)
Backend->>GoogleOAuth: Redirect to Google consent screen
GoogleOAuth->>Backend: Redirect to /auth/google/callback?code=...&state=...
Backend->>Backend: Decode cookie, verify state nonce, exchange code
Backend->>Popup: Redirect to /auth/callback?popup_id=... (final client page)
Popup->>Popup: Fetch session / finalize auth
Popup->>BroadcastCh: Post { type: 'sapling_signin', success: true, ... } on popup_id channel
Popup->>Popup: window.close()
SignInModal->>BroadcastCh: Listening on popup_id channel
BroadcastCh->>SignInModal: Deliver signin payload
SignInModal->>SignInModal: Update UI/session and navigate appropriately
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#60: Modifies backend/routes/auth.py and OAuth state/callback handling; closely related to the popup/state changes here.

Poem

🐰 I hopped and toggled, fast or smart,
Poked popups, bound the pieces part,
Cookies signed and channels sung,
A rabbit's toggle—new paths sprung,
Hop in, choose—both do their part.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly and concisely summarizes the two main changes: restoring the Google sign-in popup and adding a tutor model toggle feature.
Description check✅ PassedThe description follows the template structure with detailed explanations of both features, changes made, test plan, and comprehensive technical context.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend3f99981Commit Preview URL

Branch Preview URL
May 04 2026, 08:24 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

160-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route Google callback failures through _fail_redirect.

If the user cancels on Google's consent screen, Google returns error=access_denied instead of code. With code required here, that becomes a 422; similarly, fetch_token() / userinfo() exceptions will 500. In both cases the popup never gets oauth_denied or signin_failed, so the new popup flow strands the user on an error page instead of closing cleanly.

Possible fix
-@router.get("/google/callback")-def google_callback(code: str = Query(...), state: str = Query(None)):+@router.get("/google/callback")+def google_callback(+ code: str | None = Query(None),+ state: str | None = Query(None),+ error: str | None = Query(None),+):
"""Exchange auth code for tokens, validate `@bu.edu`, upsert user."""
state_data = _decode_state(state) if state else {}
code_verifier = state_data.get("cv")
popup_id = state_data.get("popup_id")
@@
+ if error == "access_denied":+ return _fail_redirect("oauth_denied")+ if not code:+ return _fail_redirect("signin_failed")+
flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES)
flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI
- flow.fetch_token(code=code, code_verifier=code_verifier)- creds = flow.credentials-- # Fetch user info from Google- service = build("oauth2", "v2", credentials=creds)- user_info = service.userinfo().get().execute()+ try:+ flow.fetch_token(code=code, code_verifier=code_verifier)+ creds = flow.credentials+ service = build("oauth2", "v2", credentials=creds)+ user_info = service.userinfo().get().execute()+ except Exception:+ return _fail_redirect("signin_failed")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 160 - 185, The google_callback route
should route all auth failures through _fail_redirect: check for an incoming
'error' query param (e.g. access_denied) at the start of google_callback and
call _fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and
the Google userinfo call (service.userinfo().get().execute()) in try/except and
on any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
🧹 Nitpick comments (1)
frontend/src/lib/api.ts (1)

82-82: ⚡ Quick win

ModelPref is declared twice — import from a single source

ModelPref = 'smart' | 'fast' is defined identically here (line 82) and in frontend/src/components/ModelToggle.tsx (line 5). TypeScript structural typing hides this today, but a future addition to one definition (e.g., a third tier) won't automatically update the other, causing silent divergence.

Since ModelPref belongs to the API contract, keep the definition in api.ts and import it in ModelToggle.tsx:

♻️ Proposed fix

In api.ts, keep line 82 as-is.

In frontend/src/components/ModelToggle.tsx:

-export type ModelPref = "smart" | "fast";+export type { ModelPref } from "@/lib/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/lib/api.ts` at line 82, Remove the duplicate ModelPref type
declaration and import the canonical type from the API module: keep the export
type ModelPref = 'smart' | 'fast' in api.ts and in the ModelToggle component
(ModelToggle.tsx) delete its local ModelPref declaration and add an import of
ModelPref from the API module, then update any references in the ModelToggle
component to use the imported ModelPref type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/models/__init__.py`:
- Line 13: Update the misleading comment on model_pref to reflect that the
actual default is the "fast" model (gemini-2.5-flash) not "smart"; modify the
comment near the model_pref variable to state that valid values are "smart"
(gemini-2.5-pro) or "fast" (gemini-2.5-flash) and that None/unknown resolves to
the MODEL_DEFAULT (gemini-2.5-flash) via _resolve_tutor_model. Ensure the
wording matches the PR documentation ("default Fast") and references
MODEL_DEFAULT/_resolve_tutor_model behavior so readers aren’t misled.
In `@backend/routes/auth.py`:
- Around line 147-149: The state construction (state_payload with keys "action",
"cv" (code_verifier), and optional "popup_id") must be changed to include only a
server-bound nonce rather than the PKCE verifier; generate a strong random nonce
when initiating OAuth, store it server-side (e.g., in a session store or
HttpOnly cookie keyed to the browser session), set state_payload to {"n": nonce}
(omit cv and popup_id), and store the code_verifier and popup_id server-side
tied to that nonce; then update the OAuth callback handler (the code that reads
state and exchanges the code) to first validate the returned nonce against the
server-side store/cookie and only after a successful match retrieve the stored
code_verifier and popup_id to perform the token exchange and complete signin,
rejecting any callback with a missing/invalid nonce.
In `@frontend/src/components/ModelToggle.tsx`:
- Around line 41-56: The switch button's tooltip text is inaccessible because
the tooltip div is conditionally rendered and aria-describedby may point to a
non-existent element; update the button (the element using isSmart and onChange)
to include a static aria-label that combines the visible state and the
explanatory text (e.g., "Smart: stronger reasoning" vs "Fast: quicker replies")
so screen readers always receive the description, while leaving the visual
tooltip rendering unchanged for sighted users.
In `@frontend/src/components/SignInModal.tsx`:
- Around line 117-118: The SignInModal currently always appends popup_id to the
Google auth URL (const popupId = crypto.randomUUID(); const url =
`${API_URL}/api/auth/google?popup_id=...`) which breaks the same-tab fallback
when window.open is blocked; change the logic so that you only include popup_id
when the auth flow is actually launched in a popup. Concretely, construct two
URLs (or build the query conditionally): one with popup_id for the popup path
used by window.open, and one without popup_id for the same-tab fallback
navigation; ensure the code paths in the SignInModal component that call
window.open use the popup URL, while the fallback window.location.href (or
equivalent) uses the URL without popup_id, and apply the same conditional change
to the other occurrence noted (the code block around lines 151-157).
- Around line 123-143: The handler on channel.onmessage (inside the SignInModal
component) incorrectly requires data.name for a successful signin; change the
success condition to check only data.success and data.userId (i.e. if
(data.success && data.userId) ), and call setActiveUser(data.userId, data.name
|| "") so missing/empty display names are accepted; keep
cleanupPopupListeners(), setWaiting(false), confirmApproved(), the
router.replace("/dashboard") vs
sessionStorage.setItem("sapling_onboarding_pending", "1") logic, and onClose()
unchanged, and only call setLocalError when the success condition fails.
---
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 160-185: The google_callback route should route all auth failures
through _fail_redirect: check for an incoming 'error' query param (e.g.
access_denied) at the start of google_callback and call
_fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and the
Google userinfo call (service.userinfo().get().execute()) in try/except and on
any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
---
Nitpick comments:
In `@frontend/src/lib/api.ts`:
- Line 82: Remove the duplicate ModelPref type declaration and import the
canonical type from the API module: keep the export type ModelPref = 'smart' |
'fast' in api.ts and in the ModelToggle component (ModelToggle.tsx) delete its
local ModelPref declaration and add an import of ModelPref from the API module,
then update any references in the ModelToggle component to use the imported
ModelPref type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 577d888d-5173-4b24-94a1-8cff9ad25fe1

📥 Commits

Reviewing files that changed from the base of the PR and between e146125 and 90ba796.

📒 Files selected for processing (8)
  • backend/models/__init__.py
  • backend/routes/auth.py
  • backend/routes/learn.py
  • frontend/src/app/auth/callback/page.tsx
  • frontend/src/components/ModelToggle.tsx
  • frontend/src/components/SignInModal.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/models/__init__.py Outdated
Comment threadbackend/routes/auth.py Outdated
Comment threadfrontend/src/components/ModelToggle.tsx
Comment threadfrontend/src/components/SignInModal.tsx Outdated
Comment threadfrontend/src/components/SignInModal.tsx
Address PR #73 review feedback:
- auth: bind state nonce to HttpOnly cookie carrying {nonce, cv, popup_id}
(HMAC-signed via SESSION_SECRET); reject mismatched/missing state with
invalid_state. Closes login-CSRF where attacker-crafted state could log
victim into attacker's account.
- auth: wrap flow.fetch_token in try/except → oauth_exchange_failed so
popup self-closes instead of returning 500 on replayed/expired codes.
- auth: validate popup_id charset/length ([A-Za-z0-9_-]{1,128}); invalid
input degrades to non-popup mode.
- SignInModal: split popupUrl (with popup_id) from sameTabUrl (without)
so popup-blocker fallback redirects locally instead of broadcasting
into a dead channel.
- SignInModal: don't require data.name on success — /auth/callback
legitimately broadcasts empty name when /api/auth/me has no display.
- models: tighten model_pref to Optional[Literal["fast","smart"]] so
garbage input 422s; fix misleading default-comment.
- ModelToggle: static aria-label so screen readers hear the description
even when the visual tooltip is hidden.
- tests: add 23 unit tests for auth state cookie/nonce/popup_id helpers
and 6 for _resolve_tutor_model.
import json
import base64

import pytest
@AndresL230
AndresL230 merged commit a578006 into mainMay 4, 2026
3 of 4 checks passed
@AndresL230
AndresL230 deleted the fix/auth-popup-broadcast-channel-and-tutor-model-toggle branch May 4, 2026 20:25
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
Closes the asymmetry I flagged in the post-merge review: chat tutor
got `model_pref: Literal["fast", "smart"]` per-request via PR #73,
quiz had only env-var-driven model selection. Now both routes accept
the same body field and route to the same model strings.
Wiring
- backend/models/__init__.py: GenerateQuizBody.model_pref accepts
"fast" | "smart" | None. Default None falls through to the agent's
task-default (model_for("quiz") = gemini-2.5-flash-lite per
ADR 0008).
- backend/routes/quiz.py:
* New _resolve_model_pref(pref) builds an optional GoogleModel
override from the body's preference, returning None for
None/empty/unknown so the agent's default wins on degraded input.
* _quiz_via_agent threads `model_pref` through and passes
`model=...` to quiz_agent.run only when an override is present
(no model kwarg = agent default).
* _legacy_generate_quiz now picks MODEL_SMART when pref="smart",
falls back to MODEL_LITE otherwise. So the fast/smart toggle
works on BOTH paths, including when the agent path trips and we
degrade.
- _PREF_MODEL_NAMES table at module scope so the {fast→flash,
smart→pro} mapping is one place. Mirrors the chat tutor's mapping
in services/gemini_service.py (MODEL_DEFAULT/MODEL_SMART).
Tests (TestQuizModelPref, 5 new)
- model_pref="smart" → quiz_agent.run gets model=GoogleModel('gemini-2.5-pro')
- model_pref="fast" → quiz_agent.run gets model=GoogleModel('gemini-2.5-flash')
- model_pref=None → quiz_agent.run gets NO model kwarg (agent default wins)
- model_pref="auto" → _resolve_model_pref returns None (graceful unknown)
- legacy fallback with smart → call_gemini_json gets model=MODEL_SMART
Documentation: ADR 0013 addendum captures the design decision (env var
for ops defaults + body field for per-request overrides; they compose).
Tests
- tests/test_quiz_routes.py: 28/28 (was 23, +5).
- Full backend suite: 531 passed, 3 pre-existing live-Supabase
failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Restore Google sign-in popup + add tutor Fast/Smart model toggle - #73

Merged
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle
May 4, 2026
Merged

Restore Google sign-in popup + add tutor Fast/Smart model toggle#73
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Conversation

@AndresL230

@AndresL230AndresL230 commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Two independent, complementary changes ship together on this branch.


1. Rebuild Google sign-in popup on BroadcastChannel

Restores the popup sign-in flow that was reverted in 5669639 (fix(auth): use top-level redirect for Google OAuth instead of popup). That earlier revert was correct for the bug at the time — COOP severs window.opener the moment the popup hops to a cross-origin URL (Railway, then Google), so postMessage from /auth/callback never reached the opener and the modal hung on "Waiting for Google…".

This rebuild does not depend on window.opener — so COOP severing the opener handle is harmless.

How it works:

  • The opener (SignInModal.tsx) generates a per-attempt popup_id (crypto.randomUUID) and subscribes to BroadcastChannel("sapling_signin:<id>")before opening the window.
  • The id is threaded through the OAuth state blob (backend/routes/auth.py::google_login) so the backend can echo it back.
  • /api/auth/google/callback decodes popup_id from state and includes it on the redirect to /auth/callback.
  • /auth/callback (frontend/src/app/auth/callback/page.tsx) detects popup mode by the popup_id query (not window.opener, which COOP nulls), broadcasts the result on the same channel, then window.close()s.
  • BroadcastChannel is same-origin, so the cross-origin nav through Google is irrelevant.

Failure paths also broadcast.google_not_configured, invalid_domain, and not_approved now route through /auth/callback?error=...&popup_id=... when popup_id is set, so the popup can broadcast the error and self-close instead of stranding the opener on /auth (which doesn't exist post-modal-refactor) or /pending. Same-tab behavior is unchanged.

Fallbacks / safety:

  • If window.open returns null (pop-up blocker) or BroadcastChannel / crypto.randomUUID are unavailable → falls back to today's full same-tab redirect (no regression).
  • A 3-minute watchdog plus a Cancel link reset the modal if the user closes the popup themselves. popup.closed isn't reliably observable under COOP, so we can't poll for it.

2. Tutor chat Fast/Smart model toggle (default Fast)

Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action (commit 6f431d6). Reasoning quality is great; latency is enough to feel blocked. Adds a per-user toggle in the chat header so the student decides when speed matters.

Backend (backend/routes/learn.py, backend/models/__init__.py):

  • StartSessionBody / ChatBody / ActionBody accept an optional model_pref: "fast" | "smart".
  • New _resolve_tutor_model maps "fast"MODEL_DEFAULT (gemini-2.5-flash), "smart"MODEL_SMART (gemini-2.5-pro). Unknown / missing → fast.
  • All three call sites (start_session, chat, action) routed through the resolver.

Frontend (frontend/src/components/ModelToggle.tsx (new), screens/Learn.tsx, lib/api.ts):

  • New ModelToggle component mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern (sapling_model_pref key).
  • useModelPref defaults to "fast" on first load; persists user's choice across reloads.
  • "Smart" is the highlighted opt-in state (matches the existing convention that the highlighted toggle = the optional thing).
  • Mounted in the chat TopBar between the AI disclaimer chip and Class intel toggle.
  • modelPref threaded through startSession, sendChat, and learnAction.

Defaults are airtight on both sides. A fresh user opening their first chat: useState<ModelPref>("fast") is the initial render value, so the first startSession call explicitly sends model_pref: "fast". Even if that field were ever omitted, the backend resolver still returns MODEL_DEFAULT.


Test plan

Auth popup

  • Click "Continue with Google" on the landing page modal → popup opens centered, ~520×640.
  • Complete sign-in → popup self-closes, modal closes, redirected to /dashboard (or onboarding if not completed).
  • With pop-up blocker enabled → falls back to same-tab redirect (no hang).
  • Click Cancel while waiting → modal resets cleanly.
  • Sign in with non-@bu.edu account → popup self-closes, modal shows "Sign-in is limited to approved school accounts" (instead of stranding the popup on /auth).
  • Sign in with an unapproved @bu.edu account → popup self-closes, modal shows "Your account is pending approval" (instead of leaving popup on /pending).
  • Existing same-tab redirect path (no popup_id) still works end-to-end if anyone hits /api/auth/google directly.

Tutor model toggle

  • Open /learn as a fresh user (clear localStorage first) → toggle reads "Fast" (un-highlighted).
  • Start a chat → the first POST /api/learn/start-session body includes model_pref: "fast" (Network tab).
  • Verify backend logs / Logfire trace show gemini-2.5-flash was used.
  • Flip to "Smart" → toggle highlights, value persists across page refresh.
  • Send a message → POST /api/learn/chat body includes model_pref: "smart", backend uses gemini-2.5-pro.
  • Hint / Confused / Skip actions also respect the current toggle.
  • Verified end-to-end: _resolve_tutor_model(None | "" | "fast" | "garbage")MODEL_DEFAULT; _resolve_tutor_model("smart")MODEL_SMART.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tutor model selection: UI toggle to choose "fast" or "smart"; preference is persisted and sent with learning requests.
    • Improved Google sign-in popup flow: popup handoff now uses a broadcast channel with better popup-id handling, fallback, timeout, and cleanup.
  • Tests

    • Added end-to-end and unit tests for auth state handling and tutor model resolution.

AndresL230and others added 2 commits May 4, 2026 04:15
Restores the popup sign-in flow that was reverted in 5669639 (commit
"use top-level redirect for Google OAuth instead of popup"). That earlier
revert was correct for the bug at the time -- COOP severs window.opener
the moment the popup hops to a cross-origin URL (Railway, then Google),
so postMessage from /auth/callback never reached the opener and the modal
hung on "Waiting for Google...".
This rebuild sidesteps the opener handle entirely. The opener generates a
per-attempt popup_id, subscribes to BroadcastChannel("sapling_signin:<id>")
*before* opening the window, and threads the id through the OAuth state
blob. /api/auth/google/callback echoes the id back on the redirect to
/auth/callback, which detects popup mode by the id's presence (not by
window.opener, which COOP nulls), broadcasts the result on the same
channel, then self-closes. BroadcastChannel is same-origin and survives
the cross-origin nav, so COOP is harmless.
Failure paths (google_not_configured, invalid_domain, not_approved) now
also route through /auth/callback when popup_id is set, so the popup can
broadcast the error and self-close instead of stranding the opener on
/auth or /pending.
Falls back to today's same-tab redirect when window.open returns null
(pop-up blocker) or BroadcastChannel/crypto.randomUUID is unavailable.
A 3-minute watchdog plus a Cancel link reset the modal if the user
abandons the popup -- popup.closed isn't observable under COOP, so we
can't poll for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session,
chat, and action -- great for reasoning quality, sluggish enough that
users felt blocked. Adds a per-user UI toggle in the chat TopBar so the
student decides when speed matters.
Backend: StartSessionBody / ChatBody / ActionBody accept optional
model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" ->
MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro),
unknown / missing -> fast. The default is fast on both sides so first-time
chats are snappy by default; users opt in to Smart when they want depth.
Frontend: new ModelToggle component (mirrors SharedContextToggle's
look-and-feel and localStorage-persisted hook pattern, key
sapling_model_pref). Mounted in the chat header next to the Class intel
toggle. modelPref is threaded through startSession, sendChat, and
learnAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 4, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an optional per-request model preference ("fast" | "smart") propagated from frontend toggle through API to backend model resolver, and replaces opener-based OAuth popup messaging with a popup + BroadcastChannel flow plus server-side popup-aware OAuth state cookie handling and routing.

Changes

Model Preference Selection System

Layer / File(s)Summary
Data Shape
backend/models/__init__.py
Adds model_pref: Optional[Literal["fast","smart"]] = None to StartSessionBody, ChatBody, and ActionBody.
API Types / Client
frontend/src/lib/api.ts
Adds ModelPref type and optional modelPref?: ModelPref params to startSession, sendChat, and learnAction; request bodies conditionally include model_pref.
UI State & Component
frontend/src/components/ModelToggle.tsx
New ModelPref type, useModelPref() hook with localStorage persistence, and ModelToggle switch component (tooltip, accessible attributes).
Screen Integration
frontend/src/components/screens/Learn.tsx
Adds useModelPref() usage, surface ModelToggle in TopBar, and forwards modelPref into startSession/sendChat/learnAction calls; updates send deps.
Backend Model Resolution
backend/routes/learn.py
Adds _MODEL_PREF_TO_MODEL and _resolve_tutor_model(...); /start-session, /chat, and /action use resolved model (fallback to MODEL_DEFAULT) instead of fixed MODEL_SMART.
Tests
backend/tests/test_learn_routes.py
Adds tests verifying _resolve_tutor_model mapping/behaviour for various inputs.

Popup-Based OAuth Flow

Layer / File(s)Summary
Parameter Validation & Utilities
backend/routes/auth.py
Adds popup_id validation regex, in-memory fallback nonce store, cookie name/ttl constants, and helper functions (_clean_popup_id, _encode_oauth_cookie, _decode_oauth_cookie, _prune_fallback_store).
OAuth Login Endpoint
backend/routes/auth.py
google_login(popup_id: str = Query(None)) now stores code_verifier and cleaned popup_id in signed/nonce cookie and sets OAuth state to a nonce-only payload.
Callback Handling & Routing
backend/routes/auth.py
google_callback decodes/verifies cookie, validates state nonce, centralizes error routing via _fail_redirect(error_code, ...) which clears cookie and conditionally appends popup_id; success redirect also conditionally includes popup_id.
Frontend Callback Broadcast
frontend/src/app/auth/callback/page.tsx
Replaces window.opener postMessage with BroadcastChannel keyed by popup_id; isPopup requires popup_id; broadcasts signin/failure then attempts window.close().
Popup Sign-in Lifecycle
frontend/src/components/SignInModal.tsx
Generates per-attempt popup_id (crypto.randomUUID), opens centered popup, listens on BroadcastChannel, implements watchdog timeout, centralized cleanupPopupListeners() and a Cancel button to abort; falls back to same-tab redirect when popup/BroadcastChannel unsupported.
Tests
backend/tests/test_auth_state.py
New tests covering cookie encode/decode round-trip, tamper detection, in-memory fallback behavior, _clean_popup_id validation, _decode_state handling, and callback redirect/error cases (including popup-aware redirects).
sequenceDiagram
actor User
participant SignInModal as SignInModal (Main Page)
participant Popup as Popup Window (auth/callback)
participant GoogleOAuth as Google OAuth
participant Backend as Backend (/auth)
participant BroadcastCh as BroadcastChannel
User->>SignInModal: Click "Sign in with Google"
SignInModal->>SignInModal: Generate popup_id (crypto.randomUUID)
SignInModal->>Popup: Open popup to /auth/google?popup_id=...
Popup->>Backend: GET /auth/google?popup_id=...
Backend->>Backend: Store code_verifier + popup_id in signed cookie (nonce-only state)
Backend->>GoogleOAuth: Redirect to Google consent screen
GoogleOAuth->>Backend: Redirect to /auth/google/callback?code=...&state=...
Backend->>Backend: Decode cookie, verify state nonce, exchange code
Backend->>Popup: Redirect to /auth/callback?popup_id=... (final client page)
Popup->>Popup: Fetch session / finalize auth
Popup->>BroadcastCh: Post { type: 'sapling_signin', success: true, ... } on popup_id channel
Popup->>Popup: window.close()
SignInModal->>BroadcastCh: Listening on popup_id channel
BroadcastCh->>SignInModal: Deliver signin payload
SignInModal->>SignInModal: Update UI/session and navigate appropriately
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#60: Modifies backend/routes/auth.py and OAuth state/callback handling; closely related to the popup/state changes here.

Poem

🐰 I hopped and toggled, fast or smart,
Poked popups, bound the pieces part,
Cookies signed and channels sung,
A rabbit's toggle—new paths sprung,
Hop in, choose—both do their part.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly and concisely summarizes the two main changes: restoring the Google sign-in popup and adding a tutor model toggle feature.
Description check✅ PassedThe description follows the template structure with detailed explanations of both features, changes made, test plan, and comprehensive technical context.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend3f99981Commit Preview URL

Branch Preview URL
May 04 2026, 08:24 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

160-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route Google callback failures through _fail_redirect.

If the user cancels on Google's consent screen, Google returns error=access_denied instead of code. With code required here, that becomes a 422; similarly, fetch_token() / userinfo() exceptions will 500. In both cases the popup never gets oauth_denied or signin_failed, so the new popup flow strands the user on an error page instead of closing cleanly.

Possible fix
-@router.get("/google/callback")-def google_callback(code: str = Query(...), state: str = Query(None)):+@router.get("/google/callback")+def google_callback(+ code: str | None = Query(None),+ state: str | None = Query(None),+ error: str | None = Query(None),+):
"""Exchange auth code for tokens, validate `@bu.edu`, upsert user."""
state_data = _decode_state(state) if state else {}
code_verifier = state_data.get("cv")
popup_id = state_data.get("popup_id")
@@
+ if error == "access_denied":+ return _fail_redirect("oauth_denied")+ if not code:+ return _fail_redirect("signin_failed")+
flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES)
flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI
- flow.fetch_token(code=code, code_verifier=code_verifier)- creds = flow.credentials-- # Fetch user info from Google- service = build("oauth2", "v2", credentials=creds)- user_info = service.userinfo().get().execute()+ try:+ flow.fetch_token(code=code, code_verifier=code_verifier)+ creds = flow.credentials+ service = build("oauth2", "v2", credentials=creds)+ user_info = service.userinfo().get().execute()+ except Exception:+ return _fail_redirect("signin_failed")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 160 - 185, The google_callback route
should route all auth failures through _fail_redirect: check for an incoming
'error' query param (e.g. access_denied) at the start of google_callback and
call _fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and
the Google userinfo call (service.userinfo().get().execute()) in try/except and
on any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
🧹 Nitpick comments (1)
frontend/src/lib/api.ts (1)

82-82: ⚡ Quick win

ModelPref is declared twice — import from a single source

ModelPref = 'smart' | 'fast' is defined identically here (line 82) and in frontend/src/components/ModelToggle.tsx (line 5). TypeScript structural typing hides this today, but a future addition to one definition (e.g., a third tier) won't automatically update the other, causing silent divergence.

Since ModelPref belongs to the API contract, keep the definition in api.ts and import it in ModelToggle.tsx:

♻️ Proposed fix

In api.ts, keep line 82 as-is.

In frontend/src/components/ModelToggle.tsx:

-export type ModelPref = "smart" | "fast";+export type { ModelPref } from "@/lib/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/lib/api.ts` at line 82, Remove the duplicate ModelPref type
declaration and import the canonical type from the API module: keep the export
type ModelPref = 'smart' | 'fast' in api.ts and in the ModelToggle component
(ModelToggle.tsx) delete its local ModelPref declaration and add an import of
ModelPref from the API module, then update any references in the ModelToggle
component to use the imported ModelPref type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/models/__init__.py`:
- Line 13: Update the misleading comment on model_pref to reflect that the
actual default is the "fast" model (gemini-2.5-flash) not "smart"; modify the
comment near the model_pref variable to state that valid values are "smart"
(gemini-2.5-pro) or "fast" (gemini-2.5-flash) and that None/unknown resolves to
the MODEL_DEFAULT (gemini-2.5-flash) via _resolve_tutor_model. Ensure the
wording matches the PR documentation ("default Fast") and references
MODEL_DEFAULT/_resolve_tutor_model behavior so readers aren’t misled.
In `@backend/routes/auth.py`:
- Around line 147-149: The state construction (state_payload with keys "action",
"cv" (code_verifier), and optional "popup_id") must be changed to include only a
server-bound nonce rather than the PKCE verifier; generate a strong random nonce
when initiating OAuth, store it server-side (e.g., in a session store or
HttpOnly cookie keyed to the browser session), set state_payload to {"n": nonce}
(omit cv and popup_id), and store the code_verifier and popup_id server-side
tied to that nonce; then update the OAuth callback handler (the code that reads
state and exchanges the code) to first validate the returned nonce against the
server-side store/cookie and only after a successful match retrieve the stored
code_verifier and popup_id to perform the token exchange and complete signin,
rejecting any callback with a missing/invalid nonce.
In `@frontend/src/components/ModelToggle.tsx`:
- Around line 41-56: The switch button's tooltip text is inaccessible because
the tooltip div is conditionally rendered and aria-describedby may point to a
non-existent element; update the button (the element using isSmart and onChange)
to include a static aria-label that combines the visible state and the
explanatory text (e.g., "Smart: stronger reasoning" vs "Fast: quicker replies")
so screen readers always receive the description, while leaving the visual
tooltip rendering unchanged for sighted users.
In `@frontend/src/components/SignInModal.tsx`:
- Around line 117-118: The SignInModal currently always appends popup_id to the
Google auth URL (const popupId = crypto.randomUUID(); const url =
`${API_URL}/api/auth/google?popup_id=...`) which breaks the same-tab fallback
when window.open is blocked; change the logic so that you only include popup_id
when the auth flow is actually launched in a popup. Concretely, construct two
URLs (or build the query conditionally): one with popup_id for the popup path
used by window.open, and one without popup_id for the same-tab fallback
navigation; ensure the code paths in the SignInModal component that call
window.open use the popup URL, while the fallback window.location.href (or
equivalent) uses the URL without popup_id, and apply the same conditional change
to the other occurrence noted (the code block around lines 151-157).
- Around line 123-143: The handler on channel.onmessage (inside the SignInModal
component) incorrectly requires data.name for a successful signin; change the
success condition to check only data.success and data.userId (i.e. if
(data.success && data.userId) ), and call setActiveUser(data.userId, data.name
|| "") so missing/empty display names are accepted; keep
cleanupPopupListeners(), setWaiting(false), confirmApproved(), the
router.replace("/dashboard") vs
sessionStorage.setItem("sapling_onboarding_pending", "1") logic, and onClose()
unchanged, and only call setLocalError when the success condition fails.
---
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 160-185: The google_callback route should route all auth failures
through _fail_redirect: check for an incoming 'error' query param (e.g.
access_denied) at the start of google_callback and call
_fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and the
Google userinfo call (service.userinfo().get().execute()) in try/except and on
any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
---
Nitpick comments:
In `@frontend/src/lib/api.ts`:
- Line 82: Remove the duplicate ModelPref type declaration and import the
canonical type from the API module: keep the export type ModelPref = 'smart' |
'fast' in api.ts and in the ModelToggle component (ModelToggle.tsx) delete its
local ModelPref declaration and add an import of ModelPref from the API module,
then update any references in the ModelToggle component to use the imported
ModelPref type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 577d888d-5173-4b24-94a1-8cff9ad25fe1

📥 Commits

Reviewing files that changed from the base of the PR and between e146125 and 90ba796.

📒 Files selected for processing (8)
  • backend/models/__init__.py
  • backend/routes/auth.py
  • backend/routes/learn.py
  • frontend/src/app/auth/callback/page.tsx
  • frontend/src/components/ModelToggle.tsx
  • frontend/src/components/SignInModal.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/models/__init__.py Outdated
Comment threadbackend/routes/auth.py Outdated
Comment threadfrontend/src/components/ModelToggle.tsx
Comment threadfrontend/src/components/SignInModal.tsx Outdated
Comment threadfrontend/src/components/SignInModal.tsx
Address PR #73 review feedback:
- auth: bind state nonce to HttpOnly cookie carrying {nonce, cv, popup_id}
(HMAC-signed via SESSION_SECRET); reject mismatched/missing state with
invalid_state. Closes login-CSRF where attacker-crafted state could log
victim into attacker's account.
- auth: wrap flow.fetch_token in try/except → oauth_exchange_failed so
popup self-closes instead of returning 500 on replayed/expired codes.
- auth: validate popup_id charset/length ([A-Za-z0-9_-]{1,128}); invalid
input degrades to non-popup mode.
- SignInModal: split popupUrl (with popup_id) from sameTabUrl (without)
so popup-blocker fallback redirects locally instead of broadcasting
into a dead channel.
- SignInModal: don't require data.name on success — /auth/callback
legitimately broadcasts empty name when /api/auth/me has no display.
- models: tighten model_pref to Optional[Literal["fast","smart"]] so
garbage input 422s; fix misleading default-comment.
- ModelToggle: static aria-label so screen readers hear the description
even when the visual tooltip is hidden.
- tests: add 23 unit tests for auth state cookie/nonce/popup_id helpers
and 6 for _resolve_tutor_model.
import json
import base64

import pytest
@AndresL230
AndresL230 merged commit a578006 into mainMay 4, 2026
3 of 4 checks passed
@AndresL230
AndresL230 deleted the fix/auth-popup-broadcast-channel-and-tutor-model-toggle branch May 4, 2026 20:25
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
Closes the asymmetry I flagged in the post-merge review: chat tutor
got `model_pref: Literal["fast", "smart"]` per-request via PR #73,
quiz had only env-var-driven model selection. Now both routes accept
the same body field and route to the same model strings.
Wiring
- backend/models/__init__.py: GenerateQuizBody.model_pref accepts
"fast" | "smart" | None. Default None falls through to the agent's
task-default (model_for("quiz") = gemini-2.5-flash-lite per
ADR 0008).
- backend/routes/quiz.py:
* New _resolve_model_pref(pref) builds an optional GoogleModel
override from the body's preference, returning None for
None/empty/unknown so the agent's default wins on degraded input.
* _quiz_via_agent threads `model_pref` through and passes
`model=...` to quiz_agent.run only when an override is present
(no model kwarg = agent default).
* _legacy_generate_quiz now picks MODEL_SMART when pref="smart",
falls back to MODEL_LITE otherwise. So the fast/smart toggle
works on BOTH paths, including when the agent path trips and we
degrade.
- _PREF_MODEL_NAMES table at module scope so the {fast→flash,
smart→pro} mapping is one place. Mirrors the chat tutor's mapping
in services/gemini_service.py (MODEL_DEFAULT/MODEL_SMART).
Tests (TestQuizModelPref, 5 new)
- model_pref="smart" → quiz_agent.run gets model=GoogleModel('gemini-2.5-pro')
- model_pref="fast" → quiz_agent.run gets model=GoogleModel('gemini-2.5-flash')
- model_pref=None → quiz_agent.run gets NO model kwarg (agent default wins)
- model_pref="auto" → _resolve_model_pref returns None (graceful unknown)
- legacy fallback with smart → call_gemini_json gets model=MODEL_SMART
Documentation: ADR 0013 addendum captures the design decision (env var
for ops defaults + body field for per-request overrides; they compose).
Tests
- tests/test_quiz_routes.py: 28/28 (was 23, +5).
- Full backend suite: 531 passed, 3 pre-existing live-Supabase
failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Restore Google sign-in popup + add tutor Fast/Smart model toggle - #73

Merged
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle
May 4, 2026
Merged

Restore Google sign-in popup + add tutor Fast/Smart model toggle#73
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Conversation

@AndresL230

@AndresL230AndresL230 commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Two independent, complementary changes ship together on this branch.


1. Rebuild Google sign-in popup on BroadcastChannel

Restores the popup sign-in flow that was reverted in 5669639 (fix(auth): use top-level redirect for Google OAuth instead of popup). That earlier revert was correct for the bug at the time — COOP severs window.opener the moment the popup hops to a cross-origin URL (Railway, then Google), so postMessage from /auth/callback never reached the opener and the modal hung on "Waiting for Google…".

This rebuild does not depend on window.opener — so COOP severing the opener handle is harmless.

How it works:

  • The opener (SignInModal.tsx) generates a per-attempt popup_id (crypto.randomUUID) and subscribes to BroadcastChannel("sapling_signin:<id>")before opening the window.
  • The id is threaded through the OAuth state blob (backend/routes/auth.py::google_login) so the backend can echo it back.
  • /api/auth/google/callback decodes popup_id from state and includes it on the redirect to /auth/callback.
  • /auth/callback (frontend/src/app/auth/callback/page.tsx) detects popup mode by the popup_id query (not window.opener, which COOP nulls), broadcasts the result on the same channel, then window.close()s.
  • BroadcastChannel is same-origin, so the cross-origin nav through Google is irrelevant.

Failure paths also broadcast.google_not_configured, invalid_domain, and not_approved now route through /auth/callback?error=...&popup_id=... when popup_id is set, so the popup can broadcast the error and self-close instead of stranding the opener on /auth (which doesn't exist post-modal-refactor) or /pending. Same-tab behavior is unchanged.

Fallbacks / safety:

  • If window.open returns null (pop-up blocker) or BroadcastChannel / crypto.randomUUID are unavailable → falls back to today's full same-tab redirect (no regression).
  • A 3-minute watchdog plus a Cancel link reset the modal if the user closes the popup themselves. popup.closed isn't reliably observable under COOP, so we can't poll for it.

2. Tutor chat Fast/Smart model toggle (default Fast)

Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action (commit 6f431d6). Reasoning quality is great; latency is enough to feel blocked. Adds a per-user toggle in the chat header so the student decides when speed matters.

Backend (backend/routes/learn.py, backend/models/__init__.py):

  • StartSessionBody / ChatBody / ActionBody accept an optional model_pref: "fast" | "smart".
  • New _resolve_tutor_model maps "fast"MODEL_DEFAULT (gemini-2.5-flash), "smart"MODEL_SMART (gemini-2.5-pro). Unknown / missing → fast.
  • All three call sites (start_session, chat, action) routed through the resolver.

Frontend (frontend/src/components/ModelToggle.tsx (new), screens/Learn.tsx, lib/api.ts):

  • New ModelToggle component mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern (sapling_model_pref key).
  • useModelPref defaults to "fast" on first load; persists user's choice across reloads.
  • "Smart" is the highlighted opt-in state (matches the existing convention that the highlighted toggle = the optional thing).
  • Mounted in the chat TopBar between the AI disclaimer chip and Class intel toggle.
  • modelPref threaded through startSession, sendChat, and learnAction.

Defaults are airtight on both sides. A fresh user opening their first chat: useState<ModelPref>("fast") is the initial render value, so the first startSession call explicitly sends model_pref: "fast". Even if that field were ever omitted, the backend resolver still returns MODEL_DEFAULT.


Test plan

Auth popup

  • Click "Continue with Google" on the landing page modal → popup opens centered, ~520×640.
  • Complete sign-in → popup self-closes, modal closes, redirected to /dashboard (or onboarding if not completed).
  • With pop-up blocker enabled → falls back to same-tab redirect (no hang).
  • Click Cancel while waiting → modal resets cleanly.
  • Sign in with non-@bu.edu account → popup self-closes, modal shows "Sign-in is limited to approved school accounts" (instead of stranding the popup on /auth).
  • Sign in with an unapproved @bu.edu account → popup self-closes, modal shows "Your account is pending approval" (instead of leaving popup on /pending).
  • Existing same-tab redirect path (no popup_id) still works end-to-end if anyone hits /api/auth/google directly.

Tutor model toggle

  • Open /learn as a fresh user (clear localStorage first) → toggle reads "Fast" (un-highlighted).
  • Start a chat → the first POST /api/learn/start-session body includes model_pref: "fast" (Network tab).
  • Verify backend logs / Logfire trace show gemini-2.5-flash was used.
  • Flip to "Smart" → toggle highlights, value persists across page refresh.
  • Send a message → POST /api/learn/chat body includes model_pref: "smart", backend uses gemini-2.5-pro.
  • Hint / Confused / Skip actions also respect the current toggle.
  • Verified end-to-end: _resolve_tutor_model(None | "" | "fast" | "garbage")MODEL_DEFAULT; _resolve_tutor_model("smart")MODEL_SMART.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tutor model selection: UI toggle to choose "fast" or "smart"; preference is persisted and sent with learning requests.
    • Improved Google sign-in popup flow: popup handoff now uses a broadcast channel with better popup-id handling, fallback, timeout, and cleanup.
  • Tests

    • Added end-to-end and unit tests for auth state handling and tutor model resolution.

AndresL230and others added 2 commits May 4, 2026 04:15
Restores the popup sign-in flow that was reverted in 5669639 (commit
"use top-level redirect for Google OAuth instead of popup"). That earlier
revert was correct for the bug at the time -- COOP severs window.opener
the moment the popup hops to a cross-origin URL (Railway, then Google),
so postMessage from /auth/callback never reached the opener and the modal
hung on "Waiting for Google...".
This rebuild sidesteps the opener handle entirely. The opener generates a
per-attempt popup_id, subscribes to BroadcastChannel("sapling_signin:<id>")
*before* opening the window, and threads the id through the OAuth state
blob. /api/auth/google/callback echoes the id back on the redirect to
/auth/callback, which detects popup mode by the id's presence (not by
window.opener, which COOP nulls), broadcasts the result on the same
channel, then self-closes. BroadcastChannel is same-origin and survives
the cross-origin nav, so COOP is harmless.
Failure paths (google_not_configured, invalid_domain, not_approved) now
also route through /auth/callback when popup_id is set, so the popup can
broadcast the error and self-close instead of stranding the opener on
/auth or /pending.
Falls back to today's same-tab redirect when window.open returns null
(pop-up blocker) or BroadcastChannel/crypto.randomUUID is unavailable.
A 3-minute watchdog plus a Cancel link reset the modal if the user
abandons the popup -- popup.closed isn't observable under COOP, so we
can't poll for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session,
chat, and action -- great for reasoning quality, sluggish enough that
users felt blocked. Adds a per-user UI toggle in the chat TopBar so the
student decides when speed matters.
Backend: StartSessionBody / ChatBody / ActionBody accept optional
model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" ->
MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro),
unknown / missing -> fast. The default is fast on both sides so first-time
chats are snappy by default; users opt in to Smart when they want depth.
Frontend: new ModelToggle component (mirrors SharedContextToggle's
look-and-feel and localStorage-persisted hook pattern, key
sapling_model_pref). Mounted in the chat header next to the Class intel
toggle. modelPref is threaded through startSession, sendChat, and
learnAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 4, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an optional per-request model preference ("fast" | "smart") propagated from frontend toggle through API to backend model resolver, and replaces opener-based OAuth popup messaging with a popup + BroadcastChannel flow plus server-side popup-aware OAuth state cookie handling and routing.

Changes

Model Preference Selection System

Layer / File(s)Summary
Data Shape
backend/models/__init__.py
Adds model_pref: Optional[Literal["fast","smart"]] = None to StartSessionBody, ChatBody, and ActionBody.
API Types / Client
frontend/src/lib/api.ts
Adds ModelPref type and optional modelPref?: ModelPref params to startSession, sendChat, and learnAction; request bodies conditionally include model_pref.
UI State & Component
frontend/src/components/ModelToggle.tsx
New ModelPref type, useModelPref() hook with localStorage persistence, and ModelToggle switch component (tooltip, accessible attributes).
Screen Integration
frontend/src/components/screens/Learn.tsx
Adds useModelPref() usage, surface ModelToggle in TopBar, and forwards modelPref into startSession/sendChat/learnAction calls; updates send deps.
Backend Model Resolution
backend/routes/learn.py
Adds _MODEL_PREF_TO_MODEL and _resolve_tutor_model(...); /start-session, /chat, and /action use resolved model (fallback to MODEL_DEFAULT) instead of fixed MODEL_SMART.
Tests
backend/tests/test_learn_routes.py
Adds tests verifying _resolve_tutor_model mapping/behaviour for various inputs.

Popup-Based OAuth Flow

Layer / File(s)Summary
Parameter Validation & Utilities
backend/routes/auth.py
Adds popup_id validation regex, in-memory fallback nonce store, cookie name/ttl constants, and helper functions (_clean_popup_id, _encode_oauth_cookie, _decode_oauth_cookie, _prune_fallback_store).
OAuth Login Endpoint
backend/routes/auth.py
google_login(popup_id: str = Query(None)) now stores code_verifier and cleaned popup_id in signed/nonce cookie and sets OAuth state to a nonce-only payload.
Callback Handling & Routing
backend/routes/auth.py
google_callback decodes/verifies cookie, validates state nonce, centralizes error routing via _fail_redirect(error_code, ...) which clears cookie and conditionally appends popup_id; success redirect also conditionally includes popup_id.
Frontend Callback Broadcast
frontend/src/app/auth/callback/page.tsx
Replaces window.opener postMessage with BroadcastChannel keyed by popup_id; isPopup requires popup_id; broadcasts signin/failure then attempts window.close().
Popup Sign-in Lifecycle
frontend/src/components/SignInModal.tsx
Generates per-attempt popup_id (crypto.randomUUID), opens centered popup, listens on BroadcastChannel, implements watchdog timeout, centralized cleanupPopupListeners() and a Cancel button to abort; falls back to same-tab redirect when popup/BroadcastChannel unsupported.
Tests
backend/tests/test_auth_state.py
New tests covering cookie encode/decode round-trip, tamper detection, in-memory fallback behavior, _clean_popup_id validation, _decode_state handling, and callback redirect/error cases (including popup-aware redirects).
sequenceDiagram
actor User
participant SignInModal as SignInModal (Main Page)
participant Popup as Popup Window (auth/callback)
participant GoogleOAuth as Google OAuth
participant Backend as Backend (/auth)
participant BroadcastCh as BroadcastChannel
User->>SignInModal: Click "Sign in with Google"
SignInModal->>SignInModal: Generate popup_id (crypto.randomUUID)
SignInModal->>Popup: Open popup to /auth/google?popup_id=...
Popup->>Backend: GET /auth/google?popup_id=...
Backend->>Backend: Store code_verifier + popup_id in signed cookie (nonce-only state)
Backend->>GoogleOAuth: Redirect to Google consent screen
GoogleOAuth->>Backend: Redirect to /auth/google/callback?code=...&state=...
Backend->>Backend: Decode cookie, verify state nonce, exchange code
Backend->>Popup: Redirect to /auth/callback?popup_id=... (final client page)
Popup->>Popup: Fetch session / finalize auth
Popup->>BroadcastCh: Post { type: 'sapling_signin', success: true, ... } on popup_id channel
Popup->>Popup: window.close()
SignInModal->>BroadcastCh: Listening on popup_id channel
BroadcastCh->>SignInModal: Deliver signin payload
SignInModal->>SignInModal: Update UI/session and navigate appropriately
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#60: Modifies backend/routes/auth.py and OAuth state/callback handling; closely related to the popup/state changes here.

Poem

🐰 I hopped and toggled, fast or smart,
Poked popups, bound the pieces part,
Cookies signed and channels sung,
A rabbit's toggle—new paths sprung,
Hop in, choose—both do their part.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly and concisely summarizes the two main changes: restoring the Google sign-in popup and adding a tutor model toggle feature.
Description check✅ PassedThe description follows the template structure with detailed explanations of both features, changes made, test plan, and comprehensive technical context.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend3f99981Commit Preview URL

Branch Preview URL
May 04 2026, 08:24 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

160-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route Google callback failures through _fail_redirect.

If the user cancels on Google's consent screen, Google returns error=access_denied instead of code. With code required here, that becomes a 422; similarly, fetch_token() / userinfo() exceptions will 500. In both cases the popup never gets oauth_denied or signin_failed, so the new popup flow strands the user on an error page instead of closing cleanly.

Possible fix
-@router.get("/google/callback")-def google_callback(code: str = Query(...), state: str = Query(None)):+@router.get("/google/callback")+def google_callback(+ code: str | None = Query(None),+ state: str | None = Query(None),+ error: str | None = Query(None),+):
"""Exchange auth code for tokens, validate `@bu.edu`, upsert user."""
state_data = _decode_state(state) if state else {}
code_verifier = state_data.get("cv")
popup_id = state_data.get("popup_id")
@@
+ if error == "access_denied":+ return _fail_redirect("oauth_denied")+ if not code:+ return _fail_redirect("signin_failed")+
flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES)
flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI
- flow.fetch_token(code=code, code_verifier=code_verifier)- creds = flow.credentials-- # Fetch user info from Google- service = build("oauth2", "v2", credentials=creds)- user_info = service.userinfo().get().execute()+ try:+ flow.fetch_token(code=code, code_verifier=code_verifier)+ creds = flow.credentials+ service = build("oauth2", "v2", credentials=creds)+ user_info = service.userinfo().get().execute()+ except Exception:+ return _fail_redirect("signin_failed")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 160 - 185, The google_callback route
should route all auth failures through _fail_redirect: check for an incoming
'error' query param (e.g. access_denied) at the start of google_callback and
call _fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and
the Google userinfo call (service.userinfo().get().execute()) in try/except and
on any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
🧹 Nitpick comments (1)
frontend/src/lib/api.ts (1)

82-82: ⚡ Quick win

ModelPref is declared twice — import from a single source

ModelPref = 'smart' | 'fast' is defined identically here (line 82) and in frontend/src/components/ModelToggle.tsx (line 5). TypeScript structural typing hides this today, but a future addition to one definition (e.g., a third tier) won't automatically update the other, causing silent divergence.

Since ModelPref belongs to the API contract, keep the definition in api.ts and import it in ModelToggle.tsx:

♻️ Proposed fix

In api.ts, keep line 82 as-is.

In frontend/src/components/ModelToggle.tsx:

-export type ModelPref = "smart" | "fast";+export type { ModelPref } from "@/lib/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/lib/api.ts` at line 82, Remove the duplicate ModelPref type
declaration and import the canonical type from the API module: keep the export
type ModelPref = 'smart' | 'fast' in api.ts and in the ModelToggle component
(ModelToggle.tsx) delete its local ModelPref declaration and add an import of
ModelPref from the API module, then update any references in the ModelToggle
component to use the imported ModelPref type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/models/__init__.py`:
- Line 13: Update the misleading comment on model_pref to reflect that the
actual default is the "fast" model (gemini-2.5-flash) not "smart"; modify the
comment near the model_pref variable to state that valid values are "smart"
(gemini-2.5-pro) or "fast" (gemini-2.5-flash) and that None/unknown resolves to
the MODEL_DEFAULT (gemini-2.5-flash) via _resolve_tutor_model. Ensure the
wording matches the PR documentation ("default Fast") and references
MODEL_DEFAULT/_resolve_tutor_model behavior so readers aren’t misled.
In `@backend/routes/auth.py`:
- Around line 147-149: The state construction (state_payload with keys "action",
"cv" (code_verifier), and optional "popup_id") must be changed to include only a
server-bound nonce rather than the PKCE verifier; generate a strong random nonce
when initiating OAuth, store it server-side (e.g., in a session store or
HttpOnly cookie keyed to the browser session), set state_payload to {"n": nonce}
(omit cv and popup_id), and store the code_verifier and popup_id server-side
tied to that nonce; then update the OAuth callback handler (the code that reads
state and exchanges the code) to first validate the returned nonce against the
server-side store/cookie and only after a successful match retrieve the stored
code_verifier and popup_id to perform the token exchange and complete signin,
rejecting any callback with a missing/invalid nonce.
In `@frontend/src/components/ModelToggle.tsx`:
- Around line 41-56: The switch button's tooltip text is inaccessible because
the tooltip div is conditionally rendered and aria-describedby may point to a
non-existent element; update the button (the element using isSmart and onChange)
to include a static aria-label that combines the visible state and the
explanatory text (e.g., "Smart: stronger reasoning" vs "Fast: quicker replies")
so screen readers always receive the description, while leaving the visual
tooltip rendering unchanged for sighted users.
In `@frontend/src/components/SignInModal.tsx`:
- Around line 117-118: The SignInModal currently always appends popup_id to the
Google auth URL (const popupId = crypto.randomUUID(); const url =
`${API_URL}/api/auth/google?popup_id=...`) which breaks the same-tab fallback
when window.open is blocked; change the logic so that you only include popup_id
when the auth flow is actually launched in a popup. Concretely, construct two
URLs (or build the query conditionally): one with popup_id for the popup path
used by window.open, and one without popup_id for the same-tab fallback
navigation; ensure the code paths in the SignInModal component that call
window.open use the popup URL, while the fallback window.location.href (or
equivalent) uses the URL without popup_id, and apply the same conditional change
to the other occurrence noted (the code block around lines 151-157).
- Around line 123-143: The handler on channel.onmessage (inside the SignInModal
component) incorrectly requires data.name for a successful signin; change the
success condition to check only data.success and data.userId (i.e. if
(data.success && data.userId) ), and call setActiveUser(data.userId, data.name
|| "") so missing/empty display names are accepted; keep
cleanupPopupListeners(), setWaiting(false), confirmApproved(), the
router.replace("/dashboard") vs
sessionStorage.setItem("sapling_onboarding_pending", "1") logic, and onClose()
unchanged, and only call setLocalError when the success condition fails.
---
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 160-185: The google_callback route should route all auth failures
through _fail_redirect: check for an incoming 'error' query param (e.g.
access_denied) at the start of google_callback and call
_fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and the
Google userinfo call (service.userinfo().get().execute()) in try/except and on
any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
---
Nitpick comments:
In `@frontend/src/lib/api.ts`:
- Line 82: Remove the duplicate ModelPref type declaration and import the
canonical type from the API module: keep the export type ModelPref = 'smart' |
'fast' in api.ts and in the ModelToggle component (ModelToggle.tsx) delete its
local ModelPref declaration and add an import of ModelPref from the API module,
then update any references in the ModelToggle component to use the imported
ModelPref type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 577d888d-5173-4b24-94a1-8cff9ad25fe1

📥 Commits

Reviewing files that changed from the base of the PR and between e146125 and 90ba796.

📒 Files selected for processing (8)
  • backend/models/__init__.py
  • backend/routes/auth.py
  • backend/routes/learn.py
  • frontend/src/app/auth/callback/page.tsx
  • frontend/src/components/ModelToggle.tsx
  • frontend/src/components/SignInModal.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/models/__init__.py Outdated
Comment threadbackend/routes/auth.py Outdated
Comment threadfrontend/src/components/ModelToggle.tsx
Comment threadfrontend/src/components/SignInModal.tsx Outdated
Comment threadfrontend/src/components/SignInModal.tsx
Address PR #73 review feedback:
- auth: bind state nonce to HttpOnly cookie carrying {nonce, cv, popup_id}
(HMAC-signed via SESSION_SECRET); reject mismatched/missing state with
invalid_state. Closes login-CSRF where attacker-crafted state could log
victim into attacker's account.
- auth: wrap flow.fetch_token in try/except → oauth_exchange_failed so
popup self-closes instead of returning 500 on replayed/expired codes.
- auth: validate popup_id charset/length ([A-Za-z0-9_-]{1,128}); invalid
input degrades to non-popup mode.
- SignInModal: split popupUrl (with popup_id) from sameTabUrl (without)
so popup-blocker fallback redirects locally instead of broadcasting
into a dead channel.
- SignInModal: don't require data.name on success — /auth/callback
legitimately broadcasts empty name when /api/auth/me has no display.
- models: tighten model_pref to Optional[Literal["fast","smart"]] so
garbage input 422s; fix misleading default-comment.
- ModelToggle: static aria-label so screen readers hear the description
even when the visual tooltip is hidden.
- tests: add 23 unit tests for auth state cookie/nonce/popup_id helpers
and 6 for _resolve_tutor_model.
import json
import base64

import pytest
@AndresL230
AndresL230 merged commit a578006 into mainMay 4, 2026
3 of 4 checks passed
@AndresL230
AndresL230 deleted the fix/auth-popup-broadcast-channel-and-tutor-model-toggle branch May 4, 2026 20:25
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
Closes the asymmetry I flagged in the post-merge review: chat tutor
got `model_pref: Literal["fast", "smart"]` per-request via PR #73,
quiz had only env-var-driven model selection. Now both routes accept
the same body field and route to the same model strings.
Wiring
- backend/models/__init__.py: GenerateQuizBody.model_pref accepts
"fast" | "smart" | None. Default None falls through to the agent's
task-default (model_for("quiz") = gemini-2.5-flash-lite per
ADR 0008).
- backend/routes/quiz.py:
* New _resolve_model_pref(pref) builds an optional GoogleModel
override from the body's preference, returning None for
None/empty/unknown so the agent's default wins on degraded input.
* _quiz_via_agent threads `model_pref` through and passes
`model=...` to quiz_agent.run only when an override is present
(no model kwarg = agent default).
* _legacy_generate_quiz now picks MODEL_SMART when pref="smart",
falls back to MODEL_LITE otherwise. So the fast/smart toggle
works on BOTH paths, including when the agent path trips and we
degrade.
- _PREF_MODEL_NAMES table at module scope so the {fast→flash,
smart→pro} mapping is one place. Mirrors the chat tutor's mapping
in services/gemini_service.py (MODEL_DEFAULT/MODEL_SMART).
Tests (TestQuizModelPref, 5 new)
- model_pref="smart" → quiz_agent.run gets model=GoogleModel('gemini-2.5-pro')
- model_pref="fast" → quiz_agent.run gets model=GoogleModel('gemini-2.5-flash')
- model_pref=None → quiz_agent.run gets NO model kwarg (agent default wins)
- model_pref="auto" → _resolve_model_pref returns None (graceful unknown)
- legacy fallback with smart → call_gemini_json gets model=MODEL_SMART
Documentation: ADR 0013 addendum captures the design decision (env var
for ops defaults + body field for per-request overrides; they compose).
Tests
- tests/test_quiz_routes.py: 28/28 (was 23, +5).
- Full backend suite: 531 passed, 3 pre-existing live-Supabase
failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Restore Google sign-in popup + add tutor Fast/Smart model toggle - #73

Merged
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle
May 4, 2026
Merged

Restore Google sign-in popup + add tutor Fast/Smart model toggle#73
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Conversation

@AndresL230

@AndresL230AndresL230 commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Two independent, complementary changes ship together on this branch.


1. Rebuild Google sign-in popup on BroadcastChannel

Restores the popup sign-in flow that was reverted in 5669639 (fix(auth): use top-level redirect for Google OAuth instead of popup). That earlier revert was correct for the bug at the time — COOP severs window.opener the moment the popup hops to a cross-origin URL (Railway, then Google), so postMessage from /auth/callback never reached the opener and the modal hung on "Waiting for Google…".

This rebuild does not depend on window.opener — so COOP severing the opener handle is harmless.

How it works:

  • The opener (SignInModal.tsx) generates a per-attempt popup_id (crypto.randomUUID) and subscribes to BroadcastChannel("sapling_signin:<id>")before opening the window.
  • The id is threaded through the OAuth state blob (backend/routes/auth.py::google_login) so the backend can echo it back.
  • /api/auth/google/callback decodes popup_id from state and includes it on the redirect to /auth/callback.
  • /auth/callback (frontend/src/app/auth/callback/page.tsx) detects popup mode by the popup_id query (not window.opener, which COOP nulls), broadcasts the result on the same channel, then window.close()s.
  • BroadcastChannel is same-origin, so the cross-origin nav through Google is irrelevant.

Failure paths also broadcast.google_not_configured, invalid_domain, and not_approved now route through /auth/callback?error=...&popup_id=... when popup_id is set, so the popup can broadcast the error and self-close instead of stranding the opener on /auth (which doesn't exist post-modal-refactor) or /pending. Same-tab behavior is unchanged.

Fallbacks / safety:

  • If window.open returns null (pop-up blocker) or BroadcastChannel / crypto.randomUUID are unavailable → falls back to today's full same-tab redirect (no regression).
  • A 3-minute watchdog plus a Cancel link reset the modal if the user closes the popup themselves. popup.closed isn't reliably observable under COOP, so we can't poll for it.

2. Tutor chat Fast/Smart model toggle (default Fast)

Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action (commit 6f431d6). Reasoning quality is great; latency is enough to feel blocked. Adds a per-user toggle in the chat header so the student decides when speed matters.

Backend (backend/routes/learn.py, backend/models/__init__.py):

  • StartSessionBody / ChatBody / ActionBody accept an optional model_pref: "fast" | "smart".
  • New _resolve_tutor_model maps "fast"MODEL_DEFAULT (gemini-2.5-flash), "smart"MODEL_SMART (gemini-2.5-pro). Unknown / missing → fast.
  • All three call sites (start_session, chat, action) routed through the resolver.

Frontend (frontend/src/components/ModelToggle.tsx (new), screens/Learn.tsx, lib/api.ts):

  • New ModelToggle component mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern (sapling_model_pref key).
  • useModelPref defaults to "fast" on first load; persists user's choice across reloads.
  • "Smart" is the highlighted opt-in state (matches the existing convention that the highlighted toggle = the optional thing).
  • Mounted in the chat TopBar between the AI disclaimer chip and Class intel toggle.
  • modelPref threaded through startSession, sendChat, and learnAction.

Defaults are airtight on both sides. A fresh user opening their first chat: useState<ModelPref>("fast") is the initial render value, so the first startSession call explicitly sends model_pref: "fast". Even if that field were ever omitted, the backend resolver still returns MODEL_DEFAULT.


Test plan

Auth popup

  • Click "Continue with Google" on the landing page modal → popup opens centered, ~520×640.
  • Complete sign-in → popup self-closes, modal closes, redirected to /dashboard (or onboarding if not completed).
  • With pop-up blocker enabled → falls back to same-tab redirect (no hang).
  • Click Cancel while waiting → modal resets cleanly.
  • Sign in with non-@bu.edu account → popup self-closes, modal shows "Sign-in is limited to approved school accounts" (instead of stranding the popup on /auth).
  • Sign in with an unapproved @bu.edu account → popup self-closes, modal shows "Your account is pending approval" (instead of leaving popup on /pending).
  • Existing same-tab redirect path (no popup_id) still works end-to-end if anyone hits /api/auth/google directly.

Tutor model toggle

  • Open /learn as a fresh user (clear localStorage first) → toggle reads "Fast" (un-highlighted).
  • Start a chat → the first POST /api/learn/start-session body includes model_pref: "fast" (Network tab).
  • Verify backend logs / Logfire trace show gemini-2.5-flash was used.
  • Flip to "Smart" → toggle highlights, value persists across page refresh.
  • Send a message → POST /api/learn/chat body includes model_pref: "smart", backend uses gemini-2.5-pro.
  • Hint / Confused / Skip actions also respect the current toggle.
  • Verified end-to-end: _resolve_tutor_model(None | "" | "fast" | "garbage")MODEL_DEFAULT; _resolve_tutor_model("smart")MODEL_SMART.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tutor model selection: UI toggle to choose "fast" or "smart"; preference is persisted and sent with learning requests.
    • Improved Google sign-in popup flow: popup handoff now uses a broadcast channel with better popup-id handling, fallback, timeout, and cleanup.
  • Tests

    • Added end-to-end and unit tests for auth state handling and tutor model resolution.

AndresL230and others added 2 commits May 4, 2026 04:15
Restores the popup sign-in flow that was reverted in 5669639 (commit
"use top-level redirect for Google OAuth instead of popup"). That earlier
revert was correct for the bug at the time -- COOP severs window.opener
the moment the popup hops to a cross-origin URL (Railway, then Google),
so postMessage from /auth/callback never reached the opener and the modal
hung on "Waiting for Google...".
This rebuild sidesteps the opener handle entirely. The opener generates a
per-attempt popup_id, subscribes to BroadcastChannel("sapling_signin:<id>")
*before* opening the window, and threads the id through the OAuth state
blob. /api/auth/google/callback echoes the id back on the redirect to
/auth/callback, which detects popup mode by the id's presence (not by
window.opener, which COOP nulls), broadcasts the result on the same
channel, then self-closes. BroadcastChannel is same-origin and survives
the cross-origin nav, so COOP is harmless.
Failure paths (google_not_configured, invalid_domain, not_approved) now
also route through /auth/callback when popup_id is set, so the popup can
broadcast the error and self-close instead of stranding the opener on
/auth or /pending.
Falls back to today's same-tab redirect when window.open returns null
(pop-up blocker) or BroadcastChannel/crypto.randomUUID is unavailable.
A 3-minute watchdog plus a Cancel link reset the modal if the user
abandons the popup -- popup.closed isn't observable under COOP, so we
can't poll for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session,
chat, and action -- great for reasoning quality, sluggish enough that
users felt blocked. Adds a per-user UI toggle in the chat TopBar so the
student decides when speed matters.
Backend: StartSessionBody / ChatBody / ActionBody accept optional
model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" ->
MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro),
unknown / missing -> fast. The default is fast on both sides so first-time
chats are snappy by default; users opt in to Smart when they want depth.
Frontend: new ModelToggle component (mirrors SharedContextToggle's
look-and-feel and localStorage-persisted hook pattern, key
sapling_model_pref). Mounted in the chat header next to the Class intel
toggle. modelPref is threaded through startSession, sendChat, and
learnAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 4, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an optional per-request model preference ("fast" | "smart") propagated from frontend toggle through API to backend model resolver, and replaces opener-based OAuth popup messaging with a popup + BroadcastChannel flow plus server-side popup-aware OAuth state cookie handling and routing.

Changes

Model Preference Selection System

Layer / File(s)Summary
Data Shape
backend/models/__init__.py
Adds model_pref: Optional[Literal["fast","smart"]] = None to StartSessionBody, ChatBody, and ActionBody.
API Types / Client
frontend/src/lib/api.ts
Adds ModelPref type and optional modelPref?: ModelPref params to startSession, sendChat, and learnAction; request bodies conditionally include model_pref.
UI State & Component
frontend/src/components/ModelToggle.tsx
New ModelPref type, useModelPref() hook with localStorage persistence, and ModelToggle switch component (tooltip, accessible attributes).
Screen Integration
frontend/src/components/screens/Learn.tsx
Adds useModelPref() usage, surface ModelToggle in TopBar, and forwards modelPref into startSession/sendChat/learnAction calls; updates send deps.
Backend Model Resolution
backend/routes/learn.py
Adds _MODEL_PREF_TO_MODEL and _resolve_tutor_model(...); /start-session, /chat, and /action use resolved model (fallback to MODEL_DEFAULT) instead of fixed MODEL_SMART.
Tests
backend/tests/test_learn_routes.py
Adds tests verifying _resolve_tutor_model mapping/behaviour for various inputs.

Popup-Based OAuth Flow

Layer / File(s)Summary
Parameter Validation & Utilities
backend/routes/auth.py
Adds popup_id validation regex, in-memory fallback nonce store, cookie name/ttl constants, and helper functions (_clean_popup_id, _encode_oauth_cookie, _decode_oauth_cookie, _prune_fallback_store).
OAuth Login Endpoint
backend/routes/auth.py
google_login(popup_id: str = Query(None)) now stores code_verifier and cleaned popup_id in signed/nonce cookie and sets OAuth state to a nonce-only payload.
Callback Handling & Routing
backend/routes/auth.py
google_callback decodes/verifies cookie, validates state nonce, centralizes error routing via _fail_redirect(error_code, ...) which clears cookie and conditionally appends popup_id; success redirect also conditionally includes popup_id.
Frontend Callback Broadcast
frontend/src/app/auth/callback/page.tsx
Replaces window.opener postMessage with BroadcastChannel keyed by popup_id; isPopup requires popup_id; broadcasts signin/failure then attempts window.close().
Popup Sign-in Lifecycle
frontend/src/components/SignInModal.tsx
Generates per-attempt popup_id (crypto.randomUUID), opens centered popup, listens on BroadcastChannel, implements watchdog timeout, centralized cleanupPopupListeners() and a Cancel button to abort; falls back to same-tab redirect when popup/BroadcastChannel unsupported.
Tests
backend/tests/test_auth_state.py
New tests covering cookie encode/decode round-trip, tamper detection, in-memory fallback behavior, _clean_popup_id validation, _decode_state handling, and callback redirect/error cases (including popup-aware redirects).
sequenceDiagram
actor User
participant SignInModal as SignInModal (Main Page)
participant Popup as Popup Window (auth/callback)
participant GoogleOAuth as Google OAuth
participant Backend as Backend (/auth)
participant BroadcastCh as BroadcastChannel
User->>SignInModal: Click "Sign in with Google"
SignInModal->>SignInModal: Generate popup_id (crypto.randomUUID)
SignInModal->>Popup: Open popup to /auth/google?popup_id=...
Popup->>Backend: GET /auth/google?popup_id=...
Backend->>Backend: Store code_verifier + popup_id in signed cookie (nonce-only state)
Backend->>GoogleOAuth: Redirect to Google consent screen
GoogleOAuth->>Backend: Redirect to /auth/google/callback?code=...&state=...
Backend->>Backend: Decode cookie, verify state nonce, exchange code
Backend->>Popup: Redirect to /auth/callback?popup_id=... (final client page)
Popup->>Popup: Fetch session / finalize auth
Popup->>BroadcastCh: Post { type: 'sapling_signin', success: true, ... } on popup_id channel
Popup->>Popup: window.close()
SignInModal->>BroadcastCh: Listening on popup_id channel
BroadcastCh->>SignInModal: Deliver signin payload
SignInModal->>SignInModal: Update UI/session and navigate appropriately
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#60: Modifies backend/routes/auth.py and OAuth state/callback handling; closely related to the popup/state changes here.

Poem

🐰 I hopped and toggled, fast or smart,
Poked popups, bound the pieces part,
Cookies signed and channels sung,
A rabbit's toggle—new paths sprung,
Hop in, choose—both do their part.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly and concisely summarizes the two main changes: restoring the Google sign-in popup and adding a tutor model toggle feature.
Description check✅ PassedThe description follows the template structure with detailed explanations of both features, changes made, test plan, and comprehensive technical context.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend3f99981Commit Preview URL

Branch Preview URL
May 04 2026, 08:24 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

160-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route Google callback failures through _fail_redirect.

If the user cancels on Google's consent screen, Google returns error=access_denied instead of code. With code required here, that becomes a 422; similarly, fetch_token() / userinfo() exceptions will 500. In both cases the popup never gets oauth_denied or signin_failed, so the new popup flow strands the user on an error page instead of closing cleanly.

Possible fix
-@router.get("/google/callback")-def google_callback(code: str = Query(...), state: str = Query(None)):+@router.get("/google/callback")+def google_callback(+ code: str | None = Query(None),+ state: str | None = Query(None),+ error: str | None = Query(None),+):
"""Exchange auth code for tokens, validate `@bu.edu`, upsert user."""
state_data = _decode_state(state) if state else {}
code_verifier = state_data.get("cv")
popup_id = state_data.get("popup_id")
@@
+ if error == "access_denied":+ return _fail_redirect("oauth_denied")+ if not code:+ return _fail_redirect("signin_failed")+
flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES)
flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI
- flow.fetch_token(code=code, code_verifier=code_verifier)- creds = flow.credentials-- # Fetch user info from Google- service = build("oauth2", "v2", credentials=creds)- user_info = service.userinfo().get().execute()+ try:+ flow.fetch_token(code=code, code_verifier=code_verifier)+ creds = flow.credentials+ service = build("oauth2", "v2", credentials=creds)+ user_info = service.userinfo().get().execute()+ except Exception:+ return _fail_redirect("signin_failed")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 160 - 185, The google_callback route
should route all auth failures through _fail_redirect: check for an incoming
'error' query param (e.g. access_denied) at the start of google_callback and
call _fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and
the Google userinfo call (service.userinfo().get().execute()) in try/except and
on any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
🧹 Nitpick comments (1)
frontend/src/lib/api.ts (1)

82-82: ⚡ Quick win

ModelPref is declared twice — import from a single source

ModelPref = 'smart' | 'fast' is defined identically here (line 82) and in frontend/src/components/ModelToggle.tsx (line 5). TypeScript structural typing hides this today, but a future addition to one definition (e.g., a third tier) won't automatically update the other, causing silent divergence.

Since ModelPref belongs to the API contract, keep the definition in api.ts and import it in ModelToggle.tsx:

♻️ Proposed fix

In api.ts, keep line 82 as-is.

In frontend/src/components/ModelToggle.tsx:

-export type ModelPref = "smart" | "fast";+export type { ModelPref } from "@/lib/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/lib/api.ts` at line 82, Remove the duplicate ModelPref type
declaration and import the canonical type from the API module: keep the export
type ModelPref = 'smart' | 'fast' in api.ts and in the ModelToggle component
(ModelToggle.tsx) delete its local ModelPref declaration and add an import of
ModelPref from the API module, then update any references in the ModelToggle
component to use the imported ModelPref type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/models/__init__.py`:
- Line 13: Update the misleading comment on model_pref to reflect that the
actual default is the "fast" model (gemini-2.5-flash) not "smart"; modify the
comment near the model_pref variable to state that valid values are "smart"
(gemini-2.5-pro) or "fast" (gemini-2.5-flash) and that None/unknown resolves to
the MODEL_DEFAULT (gemini-2.5-flash) via _resolve_tutor_model. Ensure the
wording matches the PR documentation ("default Fast") and references
MODEL_DEFAULT/_resolve_tutor_model behavior so readers aren’t misled.
In `@backend/routes/auth.py`:
- Around line 147-149: The state construction (state_payload with keys "action",
"cv" (code_verifier), and optional "popup_id") must be changed to include only a
server-bound nonce rather than the PKCE verifier; generate a strong random nonce
when initiating OAuth, store it server-side (e.g., in a session store or
HttpOnly cookie keyed to the browser session), set state_payload to {"n": nonce}
(omit cv and popup_id), and store the code_verifier and popup_id server-side
tied to that nonce; then update the OAuth callback handler (the code that reads
state and exchanges the code) to first validate the returned nonce against the
server-side store/cookie and only after a successful match retrieve the stored
code_verifier and popup_id to perform the token exchange and complete signin,
rejecting any callback with a missing/invalid nonce.
In `@frontend/src/components/ModelToggle.tsx`:
- Around line 41-56: The switch button's tooltip text is inaccessible because
the tooltip div is conditionally rendered and aria-describedby may point to a
non-existent element; update the button (the element using isSmart and onChange)
to include a static aria-label that combines the visible state and the
explanatory text (e.g., "Smart: stronger reasoning" vs "Fast: quicker replies")
so screen readers always receive the description, while leaving the visual
tooltip rendering unchanged for sighted users.
In `@frontend/src/components/SignInModal.tsx`:
- Around line 117-118: The SignInModal currently always appends popup_id to the
Google auth URL (const popupId = crypto.randomUUID(); const url =
`${API_URL}/api/auth/google?popup_id=...`) which breaks the same-tab fallback
when window.open is blocked; change the logic so that you only include popup_id
when the auth flow is actually launched in a popup. Concretely, construct two
URLs (or build the query conditionally): one with popup_id for the popup path
used by window.open, and one without popup_id for the same-tab fallback
navigation; ensure the code paths in the SignInModal component that call
window.open use the popup URL, while the fallback window.location.href (or
equivalent) uses the URL without popup_id, and apply the same conditional change
to the other occurrence noted (the code block around lines 151-157).
- Around line 123-143: The handler on channel.onmessage (inside the SignInModal
component) incorrectly requires data.name for a successful signin; change the
success condition to check only data.success and data.userId (i.e. if
(data.success && data.userId) ), and call setActiveUser(data.userId, data.name
|| "") so missing/empty display names are accepted; keep
cleanupPopupListeners(), setWaiting(false), confirmApproved(), the
router.replace("/dashboard") vs
sessionStorage.setItem("sapling_onboarding_pending", "1") logic, and onClose()
unchanged, and only call setLocalError when the success condition fails.
---
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 160-185: The google_callback route should route all auth failures
through _fail_redirect: check for an incoming 'error' query param (e.g.
access_denied) at the start of google_callback and call
_fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and the
Google userinfo call (service.userinfo().get().execute()) in try/except and on
any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
---
Nitpick comments:
In `@frontend/src/lib/api.ts`:
- Line 82: Remove the duplicate ModelPref type declaration and import the
canonical type from the API module: keep the export type ModelPref = 'smart' |
'fast' in api.ts and in the ModelToggle component (ModelToggle.tsx) delete its
local ModelPref declaration and add an import of ModelPref from the API module,
then update any references in the ModelToggle component to use the imported
ModelPref type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 577d888d-5173-4b24-94a1-8cff9ad25fe1

📥 Commits

Reviewing files that changed from the base of the PR and between e146125 and 90ba796.

📒 Files selected for processing (8)
  • backend/models/__init__.py
  • backend/routes/auth.py
  • backend/routes/learn.py
  • frontend/src/app/auth/callback/page.tsx
  • frontend/src/components/ModelToggle.tsx
  • frontend/src/components/SignInModal.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/models/__init__.py Outdated
Comment threadbackend/routes/auth.py Outdated
Comment threadfrontend/src/components/ModelToggle.tsx
Comment threadfrontend/src/components/SignInModal.tsx Outdated
Comment threadfrontend/src/components/SignInModal.tsx
Address PR #73 review feedback:
- auth: bind state nonce to HttpOnly cookie carrying {nonce, cv, popup_id}
(HMAC-signed via SESSION_SECRET); reject mismatched/missing state with
invalid_state. Closes login-CSRF where attacker-crafted state could log
victim into attacker's account.
- auth: wrap flow.fetch_token in try/except → oauth_exchange_failed so
popup self-closes instead of returning 500 on replayed/expired codes.
- auth: validate popup_id charset/length ([A-Za-z0-9_-]{1,128}); invalid
input degrades to non-popup mode.
- SignInModal: split popupUrl (with popup_id) from sameTabUrl (without)
so popup-blocker fallback redirects locally instead of broadcasting
into a dead channel.
- SignInModal: don't require data.name on success — /auth/callback
legitimately broadcasts empty name when /api/auth/me has no display.
- models: tighten model_pref to Optional[Literal["fast","smart"]] so
garbage input 422s; fix misleading default-comment.
- ModelToggle: static aria-label so screen readers hear the description
even when the visual tooltip is hidden.
- tests: add 23 unit tests for auth state cookie/nonce/popup_id helpers
and 6 for _resolve_tutor_model.
import json
import base64

import pytest
@AndresL230
AndresL230 merged commit a578006 into mainMay 4, 2026
3 of 4 checks passed
@AndresL230
AndresL230 deleted the fix/auth-popup-broadcast-channel-and-tutor-model-toggle branch May 4, 2026 20:25
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
Closes the asymmetry I flagged in the post-merge review: chat tutor
got `model_pref: Literal["fast", "smart"]` per-request via PR #73,
quiz had only env-var-driven model selection. Now both routes accept
the same body field and route to the same model strings.
Wiring
- backend/models/__init__.py: GenerateQuizBody.model_pref accepts
"fast" | "smart" | None. Default None falls through to the agent's
task-default (model_for("quiz") = gemini-2.5-flash-lite per
ADR 0008).
- backend/routes/quiz.py:
* New _resolve_model_pref(pref) builds an optional GoogleModel
override from the body's preference, returning None for
None/empty/unknown so the agent's default wins on degraded input.
* _quiz_via_agent threads `model_pref` through and passes
`model=...` to quiz_agent.run only when an override is present
(no model kwarg = agent default).
* _legacy_generate_quiz now picks MODEL_SMART when pref="smart",
falls back to MODEL_LITE otherwise. So the fast/smart toggle
works on BOTH paths, including when the agent path trips and we
degrade.
- _PREF_MODEL_NAMES table at module scope so the {fast→flash,
smart→pro} mapping is one place. Mirrors the chat tutor's mapping
in services/gemini_service.py (MODEL_DEFAULT/MODEL_SMART).
Tests (TestQuizModelPref, 5 new)
- model_pref="smart" → quiz_agent.run gets model=GoogleModel('gemini-2.5-pro')
- model_pref="fast" → quiz_agent.run gets model=GoogleModel('gemini-2.5-flash')
- model_pref=None → quiz_agent.run gets NO model kwarg (agent default wins)
- model_pref="auto" → _resolve_model_pref returns None (graceful unknown)
- legacy fallback with smart → call_gemini_json gets model=MODEL_SMART
Documentation: ADR 0013 addendum captures the design decision (env var
for ops defaults + body field for per-request overrides; they compose).
Tests
- tests/test_quiz_routes.py: 28/28 (was 23, +5).
- Full backend suite: 531 passed, 3 pre-existing live-Supabase
failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Restore Google sign-in popup + add tutor Fast/Smart model toggle - #73

Merged
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle
May 4, 2026
Merged

Restore Google sign-in popup + add tutor Fast/Smart model toggle#73
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Conversation

@AndresL230

@AndresL230AndresL230 commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Two independent, complementary changes ship together on this branch.


1. Rebuild Google sign-in popup on BroadcastChannel

Restores the popup sign-in flow that was reverted in 5669639 (fix(auth): use top-level redirect for Google OAuth instead of popup). That earlier revert was correct for the bug at the time — COOP severs window.opener the moment the popup hops to a cross-origin URL (Railway, then Google), so postMessage from /auth/callback never reached the opener and the modal hung on "Waiting for Google…".

This rebuild does not depend on window.opener — so COOP severing the opener handle is harmless.

How it works:

  • The opener (SignInModal.tsx) generates a per-attempt popup_id (crypto.randomUUID) and subscribes to BroadcastChannel("sapling_signin:<id>")before opening the window.
  • The id is threaded through the OAuth state blob (backend/routes/auth.py::google_login) so the backend can echo it back.
  • /api/auth/google/callback decodes popup_id from state and includes it on the redirect to /auth/callback.
  • /auth/callback (frontend/src/app/auth/callback/page.tsx) detects popup mode by the popup_id query (not window.opener, which COOP nulls), broadcasts the result on the same channel, then window.close()s.
  • BroadcastChannel is same-origin, so the cross-origin nav through Google is irrelevant.

Failure paths also broadcast.google_not_configured, invalid_domain, and not_approved now route through /auth/callback?error=...&popup_id=... when popup_id is set, so the popup can broadcast the error and self-close instead of stranding the opener on /auth (which doesn't exist post-modal-refactor) or /pending. Same-tab behavior is unchanged.

Fallbacks / safety:

  • If window.open returns null (pop-up blocker) or BroadcastChannel / crypto.randomUUID are unavailable → falls back to today's full same-tab redirect (no regression).
  • A 3-minute watchdog plus a Cancel link reset the modal if the user closes the popup themselves. popup.closed isn't reliably observable under COOP, so we can't poll for it.

2. Tutor chat Fast/Smart model toggle (default Fast)

Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action (commit 6f431d6). Reasoning quality is great; latency is enough to feel blocked. Adds a per-user toggle in the chat header so the student decides when speed matters.

Backend (backend/routes/learn.py, backend/models/__init__.py):

  • StartSessionBody / ChatBody / ActionBody accept an optional model_pref: "fast" | "smart".
  • New _resolve_tutor_model maps "fast"MODEL_DEFAULT (gemini-2.5-flash), "smart"MODEL_SMART (gemini-2.5-pro). Unknown / missing → fast.
  • All three call sites (start_session, chat, action) routed through the resolver.

Frontend (frontend/src/components/ModelToggle.tsx (new), screens/Learn.tsx, lib/api.ts):

  • New ModelToggle component mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern (sapling_model_pref key).
  • useModelPref defaults to "fast" on first load; persists user's choice across reloads.
  • "Smart" is the highlighted opt-in state (matches the existing convention that the highlighted toggle = the optional thing).
  • Mounted in the chat TopBar between the AI disclaimer chip and Class intel toggle.
  • modelPref threaded through startSession, sendChat, and learnAction.

Defaults are airtight on both sides. A fresh user opening their first chat: useState<ModelPref>("fast") is the initial render value, so the first startSession call explicitly sends model_pref: "fast". Even if that field were ever omitted, the backend resolver still returns MODEL_DEFAULT.


Test plan

Auth popup

  • Click "Continue with Google" on the landing page modal → popup opens centered, ~520×640.
  • Complete sign-in → popup self-closes, modal closes, redirected to /dashboard (or onboarding if not completed).
  • With pop-up blocker enabled → falls back to same-tab redirect (no hang).
  • Click Cancel while waiting → modal resets cleanly.
  • Sign in with non-@bu.edu account → popup self-closes, modal shows "Sign-in is limited to approved school accounts" (instead of stranding the popup on /auth).
  • Sign in with an unapproved @bu.edu account → popup self-closes, modal shows "Your account is pending approval" (instead of leaving popup on /pending).
  • Existing same-tab redirect path (no popup_id) still works end-to-end if anyone hits /api/auth/google directly.

Tutor model toggle

  • Open /learn as a fresh user (clear localStorage first) → toggle reads "Fast" (un-highlighted).
  • Start a chat → the first POST /api/learn/start-session body includes model_pref: "fast" (Network tab).
  • Verify backend logs / Logfire trace show gemini-2.5-flash was used.
  • Flip to "Smart" → toggle highlights, value persists across page refresh.
  • Send a message → POST /api/learn/chat body includes model_pref: "smart", backend uses gemini-2.5-pro.
  • Hint / Confused / Skip actions also respect the current toggle.
  • Verified end-to-end: _resolve_tutor_model(None | "" | "fast" | "garbage")MODEL_DEFAULT; _resolve_tutor_model("smart")MODEL_SMART.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tutor model selection: UI toggle to choose "fast" or "smart"; preference is persisted and sent with learning requests.
    • Improved Google sign-in popup flow: popup handoff now uses a broadcast channel with better popup-id handling, fallback, timeout, and cleanup.
  • Tests

    • Added end-to-end and unit tests for auth state handling and tutor model resolution.

AndresL230and others added 2 commits May 4, 2026 04:15
Restores the popup sign-in flow that was reverted in 5669639 (commit
"use top-level redirect for Google OAuth instead of popup"). That earlier
revert was correct for the bug at the time -- COOP severs window.opener
the moment the popup hops to a cross-origin URL (Railway, then Google),
so postMessage from /auth/callback never reached the opener and the modal
hung on "Waiting for Google...".
This rebuild sidesteps the opener handle entirely. The opener generates a
per-attempt popup_id, subscribes to BroadcastChannel("sapling_signin:<id>")
*before* opening the window, and threads the id through the OAuth state
blob. /api/auth/google/callback echoes the id back on the redirect to
/auth/callback, which detects popup mode by the id's presence (not by
window.opener, which COOP nulls), broadcasts the result on the same
channel, then self-closes. BroadcastChannel is same-origin and survives
the cross-origin nav, so COOP is harmless.
Failure paths (google_not_configured, invalid_domain, not_approved) now
also route through /auth/callback when popup_id is set, so the popup can
broadcast the error and self-close instead of stranding the opener on
/auth or /pending.
Falls back to today's same-tab redirect when window.open returns null
(pop-up blocker) or BroadcastChannel/crypto.randomUUID is unavailable.
A 3-minute watchdog plus a Cancel link reset the modal if the user
abandons the popup -- popup.closed isn't observable under COOP, so we
can't poll for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session,
chat, and action -- great for reasoning quality, sluggish enough that
users felt blocked. Adds a per-user UI toggle in the chat TopBar so the
student decides when speed matters.
Backend: StartSessionBody / ChatBody / ActionBody accept optional
model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" ->
MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro),
unknown / missing -> fast. The default is fast on both sides so first-time
chats are snappy by default; users opt in to Smart when they want depth.
Frontend: new ModelToggle component (mirrors SharedContextToggle's
look-and-feel and localStorage-persisted hook pattern, key
sapling_model_pref). Mounted in the chat header next to the Class intel
toggle. modelPref is threaded through startSession, sendChat, and
learnAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 4, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an optional per-request model preference ("fast" | "smart") propagated from frontend toggle through API to backend model resolver, and replaces opener-based OAuth popup messaging with a popup + BroadcastChannel flow plus server-side popup-aware OAuth state cookie handling and routing.

Changes

Model Preference Selection System

Layer / File(s)Summary
Data Shape
backend/models/__init__.py
Adds model_pref: Optional[Literal["fast","smart"]] = None to StartSessionBody, ChatBody, and ActionBody.
API Types / Client
frontend/src/lib/api.ts
Adds ModelPref type and optional modelPref?: ModelPref params to startSession, sendChat, and learnAction; request bodies conditionally include model_pref.
UI State & Component
frontend/src/components/ModelToggle.tsx
New ModelPref type, useModelPref() hook with localStorage persistence, and ModelToggle switch component (tooltip, accessible attributes).
Screen Integration
frontend/src/components/screens/Learn.tsx
Adds useModelPref() usage, surface ModelToggle in TopBar, and forwards modelPref into startSession/sendChat/learnAction calls; updates send deps.
Backend Model Resolution
backend/routes/learn.py
Adds _MODEL_PREF_TO_MODEL and _resolve_tutor_model(...); /start-session, /chat, and /action use resolved model (fallback to MODEL_DEFAULT) instead of fixed MODEL_SMART.
Tests
backend/tests/test_learn_routes.py
Adds tests verifying _resolve_tutor_model mapping/behaviour for various inputs.

Popup-Based OAuth Flow

Layer / File(s)Summary
Parameter Validation & Utilities
backend/routes/auth.py
Adds popup_id validation regex, in-memory fallback nonce store, cookie name/ttl constants, and helper functions (_clean_popup_id, _encode_oauth_cookie, _decode_oauth_cookie, _prune_fallback_store).
OAuth Login Endpoint
backend/routes/auth.py
google_login(popup_id: str = Query(None)) now stores code_verifier and cleaned popup_id in signed/nonce cookie and sets OAuth state to a nonce-only payload.
Callback Handling & Routing
backend/routes/auth.py
google_callback decodes/verifies cookie, validates state nonce, centralizes error routing via _fail_redirect(error_code, ...) which clears cookie and conditionally appends popup_id; success redirect also conditionally includes popup_id.
Frontend Callback Broadcast
frontend/src/app/auth/callback/page.tsx
Replaces window.opener postMessage with BroadcastChannel keyed by popup_id; isPopup requires popup_id; broadcasts signin/failure then attempts window.close().
Popup Sign-in Lifecycle
frontend/src/components/SignInModal.tsx
Generates per-attempt popup_id (crypto.randomUUID), opens centered popup, listens on BroadcastChannel, implements watchdog timeout, centralized cleanupPopupListeners() and a Cancel button to abort; falls back to same-tab redirect when popup/BroadcastChannel unsupported.
Tests
backend/tests/test_auth_state.py
New tests covering cookie encode/decode round-trip, tamper detection, in-memory fallback behavior, _clean_popup_id validation, _decode_state handling, and callback redirect/error cases (including popup-aware redirects).
sequenceDiagram
actor User
participant SignInModal as SignInModal (Main Page)
participant Popup as Popup Window (auth/callback)
participant GoogleOAuth as Google OAuth
participant Backend as Backend (/auth)
participant BroadcastCh as BroadcastChannel
User->>SignInModal: Click "Sign in with Google"
SignInModal->>SignInModal: Generate popup_id (crypto.randomUUID)
SignInModal->>Popup: Open popup to /auth/google?popup_id=...
Popup->>Backend: GET /auth/google?popup_id=...
Backend->>Backend: Store code_verifier + popup_id in signed cookie (nonce-only state)
Backend->>GoogleOAuth: Redirect to Google consent screen
GoogleOAuth->>Backend: Redirect to /auth/google/callback?code=...&state=...
Backend->>Backend: Decode cookie, verify state nonce, exchange code
Backend->>Popup: Redirect to /auth/callback?popup_id=... (final client page)
Popup->>Popup: Fetch session / finalize auth
Popup->>BroadcastCh: Post { type: 'sapling_signin', success: true, ... } on popup_id channel
Popup->>Popup: window.close()
SignInModal->>BroadcastCh: Listening on popup_id channel
BroadcastCh->>SignInModal: Deliver signin payload
SignInModal->>SignInModal: Update UI/session and navigate appropriately
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#60: Modifies backend/routes/auth.py and OAuth state/callback handling; closely related to the popup/state changes here.

Poem

🐰 I hopped and toggled, fast or smart,
Poked popups, bound the pieces part,
Cookies signed and channels sung,
A rabbit's toggle—new paths sprung,
Hop in, choose—both do their part.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly and concisely summarizes the two main changes: restoring the Google sign-in popup and adding a tutor model toggle feature.
Description check✅ PassedThe description follows the template structure with detailed explanations of both features, changes made, test plan, and comprehensive technical context.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend3f99981Commit Preview URL

Branch Preview URL
May 04 2026, 08:24 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

160-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route Google callback failures through _fail_redirect.

If the user cancels on Google's consent screen, Google returns error=access_denied instead of code. With code required here, that becomes a 422; similarly, fetch_token() / userinfo() exceptions will 500. In both cases the popup never gets oauth_denied or signin_failed, so the new popup flow strands the user on an error page instead of closing cleanly.

Possible fix
-@router.get("/google/callback")-def google_callback(code: str = Query(...), state: str = Query(None)):+@router.get("/google/callback")+def google_callback(+ code: str | None = Query(None),+ state: str | None = Query(None),+ error: str | None = Query(None),+):
"""Exchange auth code for tokens, validate `@bu.edu`, upsert user."""
state_data = _decode_state(state) if state else {}
code_verifier = state_data.get("cv")
popup_id = state_data.get("popup_id")
@@
+ if error == "access_denied":+ return _fail_redirect("oauth_denied")+ if not code:+ return _fail_redirect("signin_failed")+
flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES)
flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI
- flow.fetch_token(code=code, code_verifier=code_verifier)- creds = flow.credentials-- # Fetch user info from Google- service = build("oauth2", "v2", credentials=creds)- user_info = service.userinfo().get().execute()+ try:+ flow.fetch_token(code=code, code_verifier=code_verifier)+ creds = flow.credentials+ service = build("oauth2", "v2", credentials=creds)+ user_info = service.userinfo().get().execute()+ except Exception:+ return _fail_redirect("signin_failed")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 160 - 185, The google_callback route
should route all auth failures through _fail_redirect: check for an incoming
'error' query param (e.g. access_denied) at the start of google_callback and
call _fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and
the Google userinfo call (service.userinfo().get().execute()) in try/except and
on any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
🧹 Nitpick comments (1)
frontend/src/lib/api.ts (1)

82-82: ⚡ Quick win

ModelPref is declared twice — import from a single source

ModelPref = 'smart' | 'fast' is defined identically here (line 82) and in frontend/src/components/ModelToggle.tsx (line 5). TypeScript structural typing hides this today, but a future addition to one definition (e.g., a third tier) won't automatically update the other, causing silent divergence.

Since ModelPref belongs to the API contract, keep the definition in api.ts and import it in ModelToggle.tsx:

♻️ Proposed fix

In api.ts, keep line 82 as-is.

In frontend/src/components/ModelToggle.tsx:

-export type ModelPref = "smart" | "fast";+export type { ModelPref } from "@/lib/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/lib/api.ts` at line 82, Remove the duplicate ModelPref type
declaration and import the canonical type from the API module: keep the export
type ModelPref = 'smart' | 'fast' in api.ts and in the ModelToggle component
(ModelToggle.tsx) delete its local ModelPref declaration and add an import of
ModelPref from the API module, then update any references in the ModelToggle
component to use the imported ModelPref type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/models/__init__.py`:
- Line 13: Update the misleading comment on model_pref to reflect that the
actual default is the "fast" model (gemini-2.5-flash) not "smart"; modify the
comment near the model_pref variable to state that valid values are "smart"
(gemini-2.5-pro) or "fast" (gemini-2.5-flash) and that None/unknown resolves to
the MODEL_DEFAULT (gemini-2.5-flash) via _resolve_tutor_model. Ensure the
wording matches the PR documentation ("default Fast") and references
MODEL_DEFAULT/_resolve_tutor_model behavior so readers aren’t misled.
In `@backend/routes/auth.py`:
- Around line 147-149: The state construction (state_payload with keys "action",
"cv" (code_verifier), and optional "popup_id") must be changed to include only a
server-bound nonce rather than the PKCE verifier; generate a strong random nonce
when initiating OAuth, store it server-side (e.g., in a session store or
HttpOnly cookie keyed to the browser session), set state_payload to {"n": nonce}
(omit cv and popup_id), and store the code_verifier and popup_id server-side
tied to that nonce; then update the OAuth callback handler (the code that reads
state and exchanges the code) to first validate the returned nonce against the
server-side store/cookie and only after a successful match retrieve the stored
code_verifier and popup_id to perform the token exchange and complete signin,
rejecting any callback with a missing/invalid nonce.
In `@frontend/src/components/ModelToggle.tsx`:
- Around line 41-56: The switch button's tooltip text is inaccessible because
the tooltip div is conditionally rendered and aria-describedby may point to a
non-existent element; update the button (the element using isSmart and onChange)
to include a static aria-label that combines the visible state and the
explanatory text (e.g., "Smart: stronger reasoning" vs "Fast: quicker replies")
so screen readers always receive the description, while leaving the visual
tooltip rendering unchanged for sighted users.
In `@frontend/src/components/SignInModal.tsx`:
- Around line 117-118: The SignInModal currently always appends popup_id to the
Google auth URL (const popupId = crypto.randomUUID(); const url =
`${API_URL}/api/auth/google?popup_id=...`) which breaks the same-tab fallback
when window.open is blocked; change the logic so that you only include popup_id
when the auth flow is actually launched in a popup. Concretely, construct two
URLs (or build the query conditionally): one with popup_id for the popup path
used by window.open, and one without popup_id for the same-tab fallback
navigation; ensure the code paths in the SignInModal component that call
window.open use the popup URL, while the fallback window.location.href (or
equivalent) uses the URL without popup_id, and apply the same conditional change
to the other occurrence noted (the code block around lines 151-157).
- Around line 123-143: The handler on channel.onmessage (inside the SignInModal
component) incorrectly requires data.name for a successful signin; change the
success condition to check only data.success and data.userId (i.e. if
(data.success && data.userId) ), and call setActiveUser(data.userId, data.name
|| "") so missing/empty display names are accepted; keep
cleanupPopupListeners(), setWaiting(false), confirmApproved(), the
router.replace("/dashboard") vs
sessionStorage.setItem("sapling_onboarding_pending", "1") logic, and onClose()
unchanged, and only call setLocalError when the success condition fails.
---
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 160-185: The google_callback route should route all auth failures
through _fail_redirect: check for an incoming 'error' query param (e.g.
access_denied) at the start of google_callback and call
_fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and the
Google userinfo call (service.userinfo().get().execute()) in try/except and on
any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
---
Nitpick comments:
In `@frontend/src/lib/api.ts`:
- Line 82: Remove the duplicate ModelPref type declaration and import the
canonical type from the API module: keep the export type ModelPref = 'smart' |
'fast' in api.ts and in the ModelToggle component (ModelToggle.tsx) delete its
local ModelPref declaration and add an import of ModelPref from the API module,
then update any references in the ModelToggle component to use the imported
ModelPref type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 577d888d-5173-4b24-94a1-8cff9ad25fe1

📥 Commits

Reviewing files that changed from the base of the PR and between e146125 and 90ba796.

📒 Files selected for processing (8)
  • backend/models/__init__.py
  • backend/routes/auth.py
  • backend/routes/learn.py
  • frontend/src/app/auth/callback/page.tsx
  • frontend/src/components/ModelToggle.tsx
  • frontend/src/components/SignInModal.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/models/__init__.py Outdated
Comment threadbackend/routes/auth.py Outdated
Comment threadfrontend/src/components/ModelToggle.tsx
Comment threadfrontend/src/components/SignInModal.tsx Outdated
Comment threadfrontend/src/components/SignInModal.tsx
Address PR #73 review feedback:
- auth: bind state nonce to HttpOnly cookie carrying {nonce, cv, popup_id}
(HMAC-signed via SESSION_SECRET); reject mismatched/missing state with
invalid_state. Closes login-CSRF where attacker-crafted state could log
victim into attacker's account.
- auth: wrap flow.fetch_token in try/except → oauth_exchange_failed so
popup self-closes instead of returning 500 on replayed/expired codes.
- auth: validate popup_id charset/length ([A-Za-z0-9_-]{1,128}); invalid
input degrades to non-popup mode.
- SignInModal: split popupUrl (with popup_id) from sameTabUrl (without)
so popup-blocker fallback redirects locally instead of broadcasting
into a dead channel.
- SignInModal: don't require data.name on success — /auth/callback
legitimately broadcasts empty name when /api/auth/me has no display.
- models: tighten model_pref to Optional[Literal["fast","smart"]] so
garbage input 422s; fix misleading default-comment.
- ModelToggle: static aria-label so screen readers hear the description
even when the visual tooltip is hidden.
- tests: add 23 unit tests for auth state cookie/nonce/popup_id helpers
and 6 for _resolve_tutor_model.
import json
import base64

import pytest
@AndresL230
AndresL230 merged commit a578006 into mainMay 4, 2026
3 of 4 checks passed
@AndresL230
AndresL230 deleted the fix/auth-popup-broadcast-channel-and-tutor-model-toggle branch May 4, 2026 20:25
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
Closes the asymmetry I flagged in the post-merge review: chat tutor
got `model_pref: Literal["fast", "smart"]` per-request via PR #73,
quiz had only env-var-driven model selection. Now both routes accept
the same body field and route to the same model strings.
Wiring
- backend/models/__init__.py: GenerateQuizBody.model_pref accepts
"fast" | "smart" | None. Default None falls through to the agent's
task-default (model_for("quiz") = gemini-2.5-flash-lite per
ADR 0008).
- backend/routes/quiz.py:
* New _resolve_model_pref(pref) builds an optional GoogleModel
override from the body's preference, returning None for
None/empty/unknown so the agent's default wins on degraded input.
* _quiz_via_agent threads `model_pref` through and passes
`model=...` to quiz_agent.run only when an override is present
(no model kwarg = agent default).
* _legacy_generate_quiz now picks MODEL_SMART when pref="smart",
falls back to MODEL_LITE otherwise. So the fast/smart toggle
works on BOTH paths, including when the agent path trips and we
degrade.
- _PREF_MODEL_NAMES table at module scope so the {fast→flash,
smart→pro} mapping is one place. Mirrors the chat tutor's mapping
in services/gemini_service.py (MODEL_DEFAULT/MODEL_SMART).
Tests (TestQuizModelPref, 5 new)
- model_pref="smart" → quiz_agent.run gets model=GoogleModel('gemini-2.5-pro')
- model_pref="fast" → quiz_agent.run gets model=GoogleModel('gemini-2.5-flash')
- model_pref=None → quiz_agent.run gets NO model kwarg (agent default wins)
- model_pref="auto" → _resolve_model_pref returns None (graceful unknown)
- legacy fallback with smart → call_gemini_json gets model=MODEL_SMART
Documentation: ADR 0013 addendum captures the design decision (env var
for ops defaults + body field for per-request overrides; they compose).
Tests
- tests/test_quiz_routes.py: 28/28 (was 23, +5).
- Full backend suite: 531 passed, 3 pre-existing live-Supabase
failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Restore Google sign-in popup + add tutor Fast/Smart model toggle - #73

Merged
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle
May 4, 2026
Merged

Restore Google sign-in popup + add tutor Fast/Smart model toggle#73
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Conversation

@AndresL230

@AndresL230AndresL230 commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Two independent, complementary changes ship together on this branch.


1. Rebuild Google sign-in popup on BroadcastChannel

Restores the popup sign-in flow that was reverted in 5669639 (fix(auth): use top-level redirect for Google OAuth instead of popup). That earlier revert was correct for the bug at the time — COOP severs window.opener the moment the popup hops to a cross-origin URL (Railway, then Google), so postMessage from /auth/callback never reached the opener and the modal hung on "Waiting for Google…".

This rebuild does not depend on window.opener — so COOP severing the opener handle is harmless.

How it works:

  • The opener (SignInModal.tsx) generates a per-attempt popup_id (crypto.randomUUID) and subscribes to BroadcastChannel("sapling_signin:<id>")before opening the window.
  • The id is threaded through the OAuth state blob (backend/routes/auth.py::google_login) so the backend can echo it back.
  • /api/auth/google/callback decodes popup_id from state and includes it on the redirect to /auth/callback.
  • /auth/callback (frontend/src/app/auth/callback/page.tsx) detects popup mode by the popup_id query (not window.opener, which COOP nulls), broadcasts the result on the same channel, then window.close()s.
  • BroadcastChannel is same-origin, so the cross-origin nav through Google is irrelevant.

Failure paths also broadcast.google_not_configured, invalid_domain, and not_approved now route through /auth/callback?error=...&popup_id=... when popup_id is set, so the popup can broadcast the error and self-close instead of stranding the opener on /auth (which doesn't exist post-modal-refactor) or /pending. Same-tab behavior is unchanged.

Fallbacks / safety:

  • If window.open returns null (pop-up blocker) or BroadcastChannel / crypto.randomUUID are unavailable → falls back to today's full same-tab redirect (no regression).
  • A 3-minute watchdog plus a Cancel link reset the modal if the user closes the popup themselves. popup.closed isn't reliably observable under COOP, so we can't poll for it.

2. Tutor chat Fast/Smart model toggle (default Fast)

Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action (commit 6f431d6). Reasoning quality is great; latency is enough to feel blocked. Adds a per-user toggle in the chat header so the student decides when speed matters.

Backend (backend/routes/learn.py, backend/models/__init__.py):

  • StartSessionBody / ChatBody / ActionBody accept an optional model_pref: "fast" | "smart".
  • New _resolve_tutor_model maps "fast"MODEL_DEFAULT (gemini-2.5-flash), "smart"MODEL_SMART (gemini-2.5-pro). Unknown / missing → fast.
  • All three call sites (start_session, chat, action) routed through the resolver.

Frontend (frontend/src/components/ModelToggle.tsx (new), screens/Learn.tsx, lib/api.ts):

  • New ModelToggle component mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern (sapling_model_pref key).
  • useModelPref defaults to "fast" on first load; persists user's choice across reloads.
  • "Smart" is the highlighted opt-in state (matches the existing convention that the highlighted toggle = the optional thing).
  • Mounted in the chat TopBar between the AI disclaimer chip and Class intel toggle.
  • modelPref threaded through startSession, sendChat, and learnAction.

Defaults are airtight on both sides. A fresh user opening their first chat: useState<ModelPref>("fast") is the initial render value, so the first startSession call explicitly sends model_pref: "fast". Even if that field were ever omitted, the backend resolver still returns MODEL_DEFAULT.


Test plan

Auth popup

  • Click "Continue with Google" on the landing page modal → popup opens centered, ~520×640.
  • Complete sign-in → popup self-closes, modal closes, redirected to /dashboard (or onboarding if not completed).
  • With pop-up blocker enabled → falls back to same-tab redirect (no hang).
  • Click Cancel while waiting → modal resets cleanly.
  • Sign in with non-@bu.edu account → popup self-closes, modal shows "Sign-in is limited to approved school accounts" (instead of stranding the popup on /auth).
  • Sign in with an unapproved @bu.edu account → popup self-closes, modal shows "Your account is pending approval" (instead of leaving popup on /pending).
  • Existing same-tab redirect path (no popup_id) still works end-to-end if anyone hits /api/auth/google directly.

Tutor model toggle

  • Open /learn as a fresh user (clear localStorage first) → toggle reads "Fast" (un-highlighted).
  • Start a chat → the first POST /api/learn/start-session body includes model_pref: "fast" (Network tab).
  • Verify backend logs / Logfire trace show gemini-2.5-flash was used.
  • Flip to "Smart" → toggle highlights, value persists across page refresh.
  • Send a message → POST /api/learn/chat body includes model_pref: "smart", backend uses gemini-2.5-pro.
  • Hint / Confused / Skip actions also respect the current toggle.
  • Verified end-to-end: _resolve_tutor_model(None | "" | "fast" | "garbage")MODEL_DEFAULT; _resolve_tutor_model("smart")MODEL_SMART.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tutor model selection: UI toggle to choose "fast" or "smart"; preference is persisted and sent with learning requests.
    • Improved Google sign-in popup flow: popup handoff now uses a broadcast channel with better popup-id handling, fallback, timeout, and cleanup.
  • Tests

    • Added end-to-end and unit tests for auth state handling and tutor model resolution.

AndresL230and others added 2 commits May 4, 2026 04:15
Restores the popup sign-in flow that was reverted in 5669639 (commit
"use top-level redirect for Google OAuth instead of popup"). That earlier
revert was correct for the bug at the time -- COOP severs window.opener
the moment the popup hops to a cross-origin URL (Railway, then Google),
so postMessage from /auth/callback never reached the opener and the modal
hung on "Waiting for Google...".
This rebuild sidesteps the opener handle entirely. The opener generates a
per-attempt popup_id, subscribes to BroadcastChannel("sapling_signin:<id>")
*before* opening the window, and threads the id through the OAuth state
blob. /api/auth/google/callback echoes the id back on the redirect to
/auth/callback, which detects popup mode by the id's presence (not by
window.opener, which COOP nulls), broadcasts the result on the same
channel, then self-closes. BroadcastChannel is same-origin and survives
the cross-origin nav, so COOP is harmless.
Failure paths (google_not_configured, invalid_domain, not_approved) now
also route through /auth/callback when popup_id is set, so the popup can
broadcast the error and self-close instead of stranding the opener on
/auth or /pending.
Falls back to today's same-tab redirect when window.open returns null
(pop-up blocker) or BroadcastChannel/crypto.randomUUID is unavailable.
A 3-minute watchdog plus a Cancel link reset the modal if the user
abandons the popup -- popup.closed isn't observable under COOP, so we
can't poll for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session,
chat, and action -- great for reasoning quality, sluggish enough that
users felt blocked. Adds a per-user UI toggle in the chat TopBar so the
student decides when speed matters.
Backend: StartSessionBody / ChatBody / ActionBody accept optional
model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" ->
MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro),
unknown / missing -> fast. The default is fast on both sides so first-time
chats are snappy by default; users opt in to Smart when they want depth.
Frontend: new ModelToggle component (mirrors SharedContextToggle's
look-and-feel and localStorage-persisted hook pattern, key
sapling_model_pref). Mounted in the chat header next to the Class intel
toggle. modelPref is threaded through startSession, sendChat, and
learnAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 4, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an optional per-request model preference ("fast" | "smart") propagated from frontend toggle through API to backend model resolver, and replaces opener-based OAuth popup messaging with a popup + BroadcastChannel flow plus server-side popup-aware OAuth state cookie handling and routing.

Changes

Model Preference Selection System

Layer / File(s)Summary
Data Shape
backend/models/__init__.py
Adds model_pref: Optional[Literal["fast","smart"]] = None to StartSessionBody, ChatBody, and ActionBody.
API Types / Client
frontend/src/lib/api.ts
Adds ModelPref type and optional modelPref?: ModelPref params to startSession, sendChat, and learnAction; request bodies conditionally include model_pref.
UI State & Component
frontend/src/components/ModelToggle.tsx
New ModelPref type, useModelPref() hook with localStorage persistence, and ModelToggle switch component (tooltip, accessible attributes).
Screen Integration
frontend/src/components/screens/Learn.tsx
Adds useModelPref() usage, surface ModelToggle in TopBar, and forwards modelPref into startSession/sendChat/learnAction calls; updates send deps.
Backend Model Resolution
backend/routes/learn.py
Adds _MODEL_PREF_TO_MODEL and _resolve_tutor_model(...); /start-session, /chat, and /action use resolved model (fallback to MODEL_DEFAULT) instead of fixed MODEL_SMART.
Tests
backend/tests/test_learn_routes.py
Adds tests verifying _resolve_tutor_model mapping/behaviour for various inputs.

Popup-Based OAuth Flow

Layer / File(s)Summary
Parameter Validation & Utilities
backend/routes/auth.py
Adds popup_id validation regex, in-memory fallback nonce store, cookie name/ttl constants, and helper functions (_clean_popup_id, _encode_oauth_cookie, _decode_oauth_cookie, _prune_fallback_store).
OAuth Login Endpoint
backend/routes/auth.py
google_login(popup_id: str = Query(None)) now stores code_verifier and cleaned popup_id in signed/nonce cookie and sets OAuth state to a nonce-only payload.
Callback Handling & Routing
backend/routes/auth.py
google_callback decodes/verifies cookie, validates state nonce, centralizes error routing via _fail_redirect(error_code, ...) which clears cookie and conditionally appends popup_id; success redirect also conditionally includes popup_id.
Frontend Callback Broadcast
frontend/src/app/auth/callback/page.tsx
Replaces window.opener postMessage with BroadcastChannel keyed by popup_id; isPopup requires popup_id; broadcasts signin/failure then attempts window.close().
Popup Sign-in Lifecycle
frontend/src/components/SignInModal.tsx
Generates per-attempt popup_id (crypto.randomUUID), opens centered popup, listens on BroadcastChannel, implements watchdog timeout, centralized cleanupPopupListeners() and a Cancel button to abort; falls back to same-tab redirect when popup/BroadcastChannel unsupported.
Tests
backend/tests/test_auth_state.py
New tests covering cookie encode/decode round-trip, tamper detection, in-memory fallback behavior, _clean_popup_id validation, _decode_state handling, and callback redirect/error cases (including popup-aware redirects).
sequenceDiagram
actor User
participant SignInModal as SignInModal (Main Page)
participant Popup as Popup Window (auth/callback)
participant GoogleOAuth as Google OAuth
participant Backend as Backend (/auth)
participant BroadcastCh as BroadcastChannel
User->>SignInModal: Click "Sign in with Google"
SignInModal->>SignInModal: Generate popup_id (crypto.randomUUID)
SignInModal->>Popup: Open popup to /auth/google?popup_id=...
Popup->>Backend: GET /auth/google?popup_id=...
Backend->>Backend: Store code_verifier + popup_id in signed cookie (nonce-only state)
Backend->>GoogleOAuth: Redirect to Google consent screen
GoogleOAuth->>Backend: Redirect to /auth/google/callback?code=...&state=...
Backend->>Backend: Decode cookie, verify state nonce, exchange code
Backend->>Popup: Redirect to /auth/callback?popup_id=... (final client page)
Popup->>Popup: Fetch session / finalize auth
Popup->>BroadcastCh: Post { type: 'sapling_signin', success: true, ... } on popup_id channel
Popup->>Popup: window.close()
SignInModal->>BroadcastCh: Listening on popup_id channel
BroadcastCh->>SignInModal: Deliver signin payload
SignInModal->>SignInModal: Update UI/session and navigate appropriately
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#60: Modifies backend/routes/auth.py and OAuth state/callback handling; closely related to the popup/state changes here.

Poem

🐰 I hopped and toggled, fast or smart,
Poked popups, bound the pieces part,
Cookies signed and channels sung,
A rabbit's toggle—new paths sprung,
Hop in, choose—both do their part.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly and concisely summarizes the two main changes: restoring the Google sign-in popup and adding a tutor model toggle feature.
Description check✅ PassedThe description follows the template structure with detailed explanations of both features, changes made, test plan, and comprehensive technical context.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend3f99981Commit Preview URL

Branch Preview URL
May 04 2026, 08:24 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

160-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route Google callback failures through _fail_redirect.

If the user cancels on Google's consent screen, Google returns error=access_denied instead of code. With code required here, that becomes a 422; similarly, fetch_token() / userinfo() exceptions will 500. In both cases the popup never gets oauth_denied or signin_failed, so the new popup flow strands the user on an error page instead of closing cleanly.

Possible fix
-@router.get("/google/callback")-def google_callback(code: str = Query(...), state: str = Query(None)):+@router.get("/google/callback")+def google_callback(+ code: str | None = Query(None),+ state: str | None = Query(None),+ error: str | None = Query(None),+):
"""Exchange auth code for tokens, validate `@bu.edu`, upsert user."""
state_data = _decode_state(state) if state else {}
code_verifier = state_data.get("cv")
popup_id = state_data.get("popup_id")
@@
+ if error == "access_denied":+ return _fail_redirect("oauth_denied")+ if not code:+ return _fail_redirect("signin_failed")+
flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES)
flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI
- flow.fetch_token(code=code, code_verifier=code_verifier)- creds = flow.credentials-- # Fetch user info from Google- service = build("oauth2", "v2", credentials=creds)- user_info = service.userinfo().get().execute()+ try:+ flow.fetch_token(code=code, code_verifier=code_verifier)+ creds = flow.credentials+ service = build("oauth2", "v2", credentials=creds)+ user_info = service.userinfo().get().execute()+ except Exception:+ return _fail_redirect("signin_failed")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 160 - 185, The google_callback route
should route all auth failures through _fail_redirect: check for an incoming
'error' query param (e.g. access_denied) at the start of google_callback and
call _fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and
the Google userinfo call (service.userinfo().get().execute()) in try/except and
on any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
🧹 Nitpick comments (1)
frontend/src/lib/api.ts (1)

82-82: ⚡ Quick win

ModelPref is declared twice — import from a single source

ModelPref = 'smart' | 'fast' is defined identically here (line 82) and in frontend/src/components/ModelToggle.tsx (line 5). TypeScript structural typing hides this today, but a future addition to one definition (e.g., a third tier) won't automatically update the other, causing silent divergence.

Since ModelPref belongs to the API contract, keep the definition in api.ts and import it in ModelToggle.tsx:

♻️ Proposed fix

In api.ts, keep line 82 as-is.

In frontend/src/components/ModelToggle.tsx:

-export type ModelPref = "smart" | "fast";+export type { ModelPref } from "@/lib/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/lib/api.ts` at line 82, Remove the duplicate ModelPref type
declaration and import the canonical type from the API module: keep the export
type ModelPref = 'smart' | 'fast' in api.ts and in the ModelToggle component
(ModelToggle.tsx) delete its local ModelPref declaration and add an import of
ModelPref from the API module, then update any references in the ModelToggle
component to use the imported ModelPref type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/models/__init__.py`:
- Line 13: Update the misleading comment on model_pref to reflect that the
actual default is the "fast" model (gemini-2.5-flash) not "smart"; modify the
comment near the model_pref variable to state that valid values are "smart"
(gemini-2.5-pro) or "fast" (gemini-2.5-flash) and that None/unknown resolves to
the MODEL_DEFAULT (gemini-2.5-flash) via _resolve_tutor_model. Ensure the
wording matches the PR documentation ("default Fast") and references
MODEL_DEFAULT/_resolve_tutor_model behavior so readers aren’t misled.
In `@backend/routes/auth.py`:
- Around line 147-149: The state construction (state_payload with keys "action",
"cv" (code_verifier), and optional "popup_id") must be changed to include only a
server-bound nonce rather than the PKCE verifier; generate a strong random nonce
when initiating OAuth, store it server-side (e.g., in a session store or
HttpOnly cookie keyed to the browser session), set state_payload to {"n": nonce}
(omit cv and popup_id), and store the code_verifier and popup_id server-side
tied to that nonce; then update the OAuth callback handler (the code that reads
state and exchanges the code) to first validate the returned nonce against the
server-side store/cookie and only after a successful match retrieve the stored
code_verifier and popup_id to perform the token exchange and complete signin,
rejecting any callback with a missing/invalid nonce.
In `@frontend/src/components/ModelToggle.tsx`:
- Around line 41-56: The switch button's tooltip text is inaccessible because
the tooltip div is conditionally rendered and aria-describedby may point to a
non-existent element; update the button (the element using isSmart and onChange)
to include a static aria-label that combines the visible state and the
explanatory text (e.g., "Smart: stronger reasoning" vs "Fast: quicker replies")
so screen readers always receive the description, while leaving the visual
tooltip rendering unchanged for sighted users.
In `@frontend/src/components/SignInModal.tsx`:
- Around line 117-118: The SignInModal currently always appends popup_id to the
Google auth URL (const popupId = crypto.randomUUID(); const url =
`${API_URL}/api/auth/google?popup_id=...`) which breaks the same-tab fallback
when window.open is blocked; change the logic so that you only include popup_id
when the auth flow is actually launched in a popup. Concretely, construct two
URLs (or build the query conditionally): one with popup_id for the popup path
used by window.open, and one without popup_id for the same-tab fallback
navigation; ensure the code paths in the SignInModal component that call
window.open use the popup URL, while the fallback window.location.href (or
equivalent) uses the URL without popup_id, and apply the same conditional change
to the other occurrence noted (the code block around lines 151-157).
- Around line 123-143: The handler on channel.onmessage (inside the SignInModal
component) incorrectly requires data.name for a successful signin; change the
success condition to check only data.success and data.userId (i.e. if
(data.success && data.userId) ), and call setActiveUser(data.userId, data.name
|| "") so missing/empty display names are accepted; keep
cleanupPopupListeners(), setWaiting(false), confirmApproved(), the
router.replace("/dashboard") vs
sessionStorage.setItem("sapling_onboarding_pending", "1") logic, and onClose()
unchanged, and only call setLocalError when the success condition fails.
---
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 160-185: The google_callback route should route all auth failures
through _fail_redirect: check for an incoming 'error' query param (e.g.
access_denied) at the start of google_callback and call
_fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and the
Google userinfo call (service.userinfo().get().execute()) in try/except and on
any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
---
Nitpick comments:
In `@frontend/src/lib/api.ts`:
- Line 82: Remove the duplicate ModelPref type declaration and import the
canonical type from the API module: keep the export type ModelPref = 'smart' |
'fast' in api.ts and in the ModelToggle component (ModelToggle.tsx) delete its
local ModelPref declaration and add an import of ModelPref from the API module,
then update any references in the ModelToggle component to use the imported
ModelPref type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 577d888d-5173-4b24-94a1-8cff9ad25fe1

📥 Commits

Reviewing files that changed from the base of the PR and between e146125 and 90ba796.

📒 Files selected for processing (8)
  • backend/models/__init__.py
  • backend/routes/auth.py
  • backend/routes/learn.py
  • frontend/src/app/auth/callback/page.tsx
  • frontend/src/components/ModelToggle.tsx
  • frontend/src/components/SignInModal.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/models/__init__.py Outdated
Comment threadbackend/routes/auth.py Outdated
Comment threadfrontend/src/components/ModelToggle.tsx
Comment threadfrontend/src/components/SignInModal.tsx Outdated
Comment threadfrontend/src/components/SignInModal.tsx
Address PR #73 review feedback:
- auth: bind state nonce to HttpOnly cookie carrying {nonce, cv, popup_id}
(HMAC-signed via SESSION_SECRET); reject mismatched/missing state with
invalid_state. Closes login-CSRF where attacker-crafted state could log
victim into attacker's account.
- auth: wrap flow.fetch_token in try/except → oauth_exchange_failed so
popup self-closes instead of returning 500 on replayed/expired codes.
- auth: validate popup_id charset/length ([A-Za-z0-9_-]{1,128}); invalid
input degrades to non-popup mode.
- SignInModal: split popupUrl (with popup_id) from sameTabUrl (without)
so popup-blocker fallback redirects locally instead of broadcasting
into a dead channel.
- SignInModal: don't require data.name on success — /auth/callback
legitimately broadcasts empty name when /api/auth/me has no display.
- models: tighten model_pref to Optional[Literal["fast","smart"]] so
garbage input 422s; fix misleading default-comment.
- ModelToggle: static aria-label so screen readers hear the description
even when the visual tooltip is hidden.
- tests: add 23 unit tests for auth state cookie/nonce/popup_id helpers
and 6 for _resolve_tutor_model.
import json
import base64

import pytest
@AndresL230
AndresL230 merged commit a578006 into mainMay 4, 2026
3 of 4 checks passed
@AndresL230
AndresL230 deleted the fix/auth-popup-broadcast-channel-and-tutor-model-toggle branch May 4, 2026 20:25
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
Closes the asymmetry I flagged in the post-merge review: chat tutor
got `model_pref: Literal["fast", "smart"]` per-request via PR #73,
quiz had only env-var-driven model selection. Now both routes accept
the same body field and route to the same model strings.
Wiring
- backend/models/__init__.py: GenerateQuizBody.model_pref accepts
"fast" | "smart" | None. Default None falls through to the agent's
task-default (model_for("quiz") = gemini-2.5-flash-lite per
ADR 0008).
- backend/routes/quiz.py:
* New _resolve_model_pref(pref) builds an optional GoogleModel
override from the body's preference, returning None for
None/empty/unknown so the agent's default wins on degraded input.
* _quiz_via_agent threads `model_pref` through and passes
`model=...` to quiz_agent.run only when an override is present
(no model kwarg = agent default).
* _legacy_generate_quiz now picks MODEL_SMART when pref="smart",
falls back to MODEL_LITE otherwise. So the fast/smart toggle
works on BOTH paths, including when the agent path trips and we
degrade.
- _PREF_MODEL_NAMES table at module scope so the {fast→flash,
smart→pro} mapping is one place. Mirrors the chat tutor's mapping
in services/gemini_service.py (MODEL_DEFAULT/MODEL_SMART).
Tests (TestQuizModelPref, 5 new)
- model_pref="smart" → quiz_agent.run gets model=GoogleModel('gemini-2.5-pro')
- model_pref="fast" → quiz_agent.run gets model=GoogleModel('gemini-2.5-flash')
- model_pref=None → quiz_agent.run gets NO model kwarg (agent default wins)
- model_pref="auto" → _resolve_model_pref returns None (graceful unknown)
- legacy fallback with smart → call_gemini_json gets model=MODEL_SMART
Documentation: ADR 0013 addendum captures the design decision (env var
for ops defaults + body field for per-request overrides; they compose).
Tests
- tests/test_quiz_routes.py: 28/28 (was 23, +5).
- Full backend suite: 531 passed, 3 pre-existing live-Supabase
failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Restore Google sign-in popup + add tutor Fast/Smart model toggle - #73

Merged
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle
May 4, 2026
Merged

Restore Google sign-in popup + add tutor Fast/Smart model toggle#73
AndresL230 merged 3 commits into
mainfrom
fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Conversation

@AndresL230

@AndresL230AndresL230 commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Two independent, complementary changes ship together on this branch.


1. Rebuild Google sign-in popup on BroadcastChannel

Restores the popup sign-in flow that was reverted in 5669639 (fix(auth): use top-level redirect for Google OAuth instead of popup). That earlier revert was correct for the bug at the time — COOP severs window.opener the moment the popup hops to a cross-origin URL (Railway, then Google), so postMessage from /auth/callback never reached the opener and the modal hung on "Waiting for Google…".

This rebuild does not depend on window.opener — so COOP severing the opener handle is harmless.

How it works:

  • The opener (SignInModal.tsx) generates a per-attempt popup_id (crypto.randomUUID) and subscribes to BroadcastChannel("sapling_signin:<id>")before opening the window.
  • The id is threaded through the OAuth state blob (backend/routes/auth.py::google_login) so the backend can echo it back.
  • /api/auth/google/callback decodes popup_id from state and includes it on the redirect to /auth/callback.
  • /auth/callback (frontend/src/app/auth/callback/page.tsx) detects popup mode by the popup_id query (not window.opener, which COOP nulls), broadcasts the result on the same channel, then window.close()s.
  • BroadcastChannel is same-origin, so the cross-origin nav through Google is irrelevant.

Failure paths also broadcast.google_not_configured, invalid_domain, and not_approved now route through /auth/callback?error=...&popup_id=... when popup_id is set, so the popup can broadcast the error and self-close instead of stranding the opener on /auth (which doesn't exist post-modal-refactor) or /pending. Same-tab behavior is unchanged.

Fallbacks / safety:

  • If window.open returns null (pop-up blocker) or BroadcastChannel / crypto.randomUUID are unavailable → falls back to today's full same-tab redirect (no regression).
  • A 3-minute watchdog plus a Cancel link reset the modal if the user closes the popup themselves. popup.closed isn't reliably observable under COOP, so we can't poll for it.

2. Tutor chat Fast/Smart model toggle (default Fast)

Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session, chat, and action (commit 6f431d6). Reasoning quality is great; latency is enough to feel blocked. Adds a per-user toggle in the chat header so the student decides when speed matters.

Backend (backend/routes/learn.py, backend/models/__init__.py):

  • StartSessionBody / ChatBody / ActionBody accept an optional model_pref: "fast" | "smart".
  • New _resolve_tutor_model maps "fast"MODEL_DEFAULT (gemini-2.5-flash), "smart"MODEL_SMART (gemini-2.5-pro). Unknown / missing → fast.
  • All three call sites (start_session, chat, action) routed through the resolver.

Frontend (frontend/src/components/ModelToggle.tsx (new), screens/Learn.tsx, lib/api.ts):

  • New ModelToggle component mirrors SharedContextToggle's look-and-feel and localStorage-persisted hook pattern (sapling_model_pref key).
  • useModelPref defaults to "fast" on first load; persists user's choice across reloads.
  • "Smart" is the highlighted opt-in state (matches the existing convention that the highlighted toggle = the optional thing).
  • Mounted in the chat TopBar between the AI disclaimer chip and Class intel toggle.
  • modelPref threaded through startSession, sendChat, and learnAction.

Defaults are airtight on both sides. A fresh user opening their first chat: useState<ModelPref>("fast") is the initial render value, so the first startSession call explicitly sends model_pref: "fast". Even if that field were ever omitted, the backend resolver still returns MODEL_DEFAULT.


Test plan

Auth popup

  • Click "Continue with Google" on the landing page modal → popup opens centered, ~520×640.
  • Complete sign-in → popup self-closes, modal closes, redirected to /dashboard (or onboarding if not completed).
  • With pop-up blocker enabled → falls back to same-tab redirect (no hang).
  • Click Cancel while waiting → modal resets cleanly.
  • Sign in with non-@bu.edu account → popup self-closes, modal shows "Sign-in is limited to approved school accounts" (instead of stranding the popup on /auth).
  • Sign in with an unapproved @bu.edu account → popup self-closes, modal shows "Your account is pending approval" (instead of leaving popup on /pending).
  • Existing same-tab redirect path (no popup_id) still works end-to-end if anyone hits /api/auth/google directly.

Tutor model toggle

  • Open /learn as a fresh user (clear localStorage first) → toggle reads "Fast" (un-highlighted).
  • Start a chat → the first POST /api/learn/start-session body includes model_pref: "fast" (Network tab).
  • Verify backend logs / Logfire trace show gemini-2.5-flash was used.
  • Flip to "Smart" → toggle highlights, value persists across page refresh.
  • Send a message → POST /api/learn/chat body includes model_pref: "smart", backend uses gemini-2.5-pro.
  • Hint / Confused / Skip actions also respect the current toggle.
  • Verified end-to-end: _resolve_tutor_model(None | "" | "fast" | "garbage")MODEL_DEFAULT; _resolve_tutor_model("smart")MODEL_SMART.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Tutor model selection: UI toggle to choose "fast" or "smart"; preference is persisted and sent with learning requests.
    • Improved Google sign-in popup flow: popup handoff now uses a broadcast channel with better popup-id handling, fallback, timeout, and cleanup.
  • Tests

    • Added end-to-end and unit tests for auth state handling and tutor model resolution.

AndresL230and others added 2 commits May 4, 2026 04:15
Restores the popup sign-in flow that was reverted in 5669639 (commit
"use top-level redirect for Google OAuth instead of popup"). That earlier
revert was correct for the bug at the time -- COOP severs window.opener
the moment the popup hops to a cross-origin URL (Railway, then Google),
so postMessage from /auth/callback never reached the opener and the modal
hung on "Waiting for Google...".
This rebuild sidesteps the opener handle entirely. The opener generates a
per-attempt popup_id, subscribes to BroadcastChannel("sapling_signin:<id>")
*before* opening the window, and threads the id through the OAuth state
blob. /api/auth/google/callback echoes the id back on the redirect to
/auth/callback, which detects popup mode by the id's presence (not by
window.opener, which COOP nulls), broadcasts the result on the same
channel, then self-closes. BroadcastChannel is same-origin and survives
the cross-origin nav, so COOP is harmless.
Failure paths (google_not_configured, invalid_domain, not_approved) now
also route through /auth/callback when popup_id is set, so the popup can
broadcast the error and self-close instead of stranding the opener on
/auth or /pending.
Falls back to today's same-tab redirect when window.open returns null
(pop-up blocker) or BroadcastChannel/crypto.randomUUID is unavailable.
A 3-minute watchdog plus a Cancel link reset the modal if the user
abandons the popup -- popup.closed isn't observable under COOP, so we
can't poll for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tutor chat was hardcoded to gemini-2.5-pro (MODEL_SMART) in start_session,
chat, and action -- great for reasoning quality, sluggish enough that
users felt blocked. Adds a per-user UI toggle in the chat TopBar so the
student decides when speed matters.
Backend: StartSessionBody / ChatBody / ActionBody accept optional
model_pref ("fast" | "smart"). New _resolve_tutor_model maps "fast" ->
MODEL_DEFAULT (gemini-2.5-flash), "smart" -> MODEL_SMART (gemini-2.5-pro),
unknown / missing -> fast. The default is fast on both sides so first-time
chats are snappy by default; users opt in to Smart when they want depth.
Frontend: new ModelToggle component (mirrors SharedContextToggle's
look-and-feel and localStorage-persisted hook pattern, key
sapling_model_pref). Mounted in the chat header next to the Class intel
toggle. modelPref is threaded through startSession, sendChat, and
learnAction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented May 4, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an optional per-request model preference ("fast" | "smart") propagated from frontend toggle through API to backend model resolver, and replaces opener-based OAuth popup messaging with a popup + BroadcastChannel flow plus server-side popup-aware OAuth state cookie handling and routing.

Changes

Model Preference Selection System

Layer / File(s)Summary
Data Shape
backend/models/__init__.py
Adds model_pref: Optional[Literal["fast","smart"]] = None to StartSessionBody, ChatBody, and ActionBody.
API Types / Client
frontend/src/lib/api.ts
Adds ModelPref type and optional modelPref?: ModelPref params to startSession, sendChat, and learnAction; request bodies conditionally include model_pref.
UI State & Component
frontend/src/components/ModelToggle.tsx
New ModelPref type, useModelPref() hook with localStorage persistence, and ModelToggle switch component (tooltip, accessible attributes).
Screen Integration
frontend/src/components/screens/Learn.tsx
Adds useModelPref() usage, surface ModelToggle in TopBar, and forwards modelPref into startSession/sendChat/learnAction calls; updates send deps.
Backend Model Resolution
backend/routes/learn.py
Adds _MODEL_PREF_TO_MODEL and _resolve_tutor_model(...); /start-session, /chat, and /action use resolved model (fallback to MODEL_DEFAULT) instead of fixed MODEL_SMART.
Tests
backend/tests/test_learn_routes.py
Adds tests verifying _resolve_tutor_model mapping/behaviour for various inputs.

Popup-Based OAuth Flow

Layer / File(s)Summary
Parameter Validation & Utilities
backend/routes/auth.py
Adds popup_id validation regex, in-memory fallback nonce store, cookie name/ttl constants, and helper functions (_clean_popup_id, _encode_oauth_cookie, _decode_oauth_cookie, _prune_fallback_store).
OAuth Login Endpoint
backend/routes/auth.py
google_login(popup_id: str = Query(None)) now stores code_verifier and cleaned popup_id in signed/nonce cookie and sets OAuth state to a nonce-only payload.
Callback Handling & Routing
backend/routes/auth.py
google_callback decodes/verifies cookie, validates state nonce, centralizes error routing via _fail_redirect(error_code, ...) which clears cookie and conditionally appends popup_id; success redirect also conditionally includes popup_id.
Frontend Callback Broadcast
frontend/src/app/auth/callback/page.tsx
Replaces window.opener postMessage with BroadcastChannel keyed by popup_id; isPopup requires popup_id; broadcasts signin/failure then attempts window.close().
Popup Sign-in Lifecycle
frontend/src/components/SignInModal.tsx
Generates per-attempt popup_id (crypto.randomUUID), opens centered popup, listens on BroadcastChannel, implements watchdog timeout, centralized cleanupPopupListeners() and a Cancel button to abort; falls back to same-tab redirect when popup/BroadcastChannel unsupported.
Tests
backend/tests/test_auth_state.py
New tests covering cookie encode/decode round-trip, tamper detection, in-memory fallback behavior, _clean_popup_id validation, _decode_state handling, and callback redirect/error cases (including popup-aware redirects).
sequenceDiagram
actor User
participant SignInModal as SignInModal (Main Page)
participant Popup as Popup Window (auth/callback)
participant GoogleOAuth as Google OAuth
participant Backend as Backend (/auth)
participant BroadcastCh as BroadcastChannel
User->>SignInModal: Click "Sign in with Google"
SignInModal->>SignInModal: Generate popup_id (crypto.randomUUID)
SignInModal->>Popup: Open popup to /auth/google?popup_id=...
Popup->>Backend: GET /auth/google?popup_id=...
Backend->>Backend: Store code_verifier + popup_id in signed cookie (nonce-only state)
Backend->>GoogleOAuth: Redirect to Google consent screen
GoogleOAuth->>Backend: Redirect to /auth/google/callback?code=...&state=...
Backend->>Backend: Decode cookie, verify state nonce, exchange code
Backend->>Popup: Redirect to /auth/callback?popup_id=... (final client page)
Popup->>Popup: Fetch session / finalize auth
Popup->>BroadcastCh: Post { type: 'sapling_signin', success: true, ... } on popup_id channel
Popup->>Popup: window.close()
SignInModal->>BroadcastCh: Listening on popup_id channel
BroadcastCh->>SignInModal: Deliver signin payload
SignInModal->>SignInModal: Update UI/session and navigate appropriately
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • SaplingLearn/Sapling#60: Modifies backend/routes/auth.py and OAuth state/callback handling; closely related to the popup/state changes here.

Poem

🐰 I hopped and toggled, fast or smart,
Poked popups, bound the pieces part,
Cookies signed and channels sung,
A rabbit's toggle—new paths sprung,
Hop in, choose—both do their part.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 6.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly and concisely summarizes the two main changes: restoring the Google sign-in popup and adding a tutor model toggle feature.
Description check✅ PassedThe description follows the template structure with detailed explanations of both features, changes made, test plan, and comprehensive technical context.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/auth-popup-broadcast-channel-and-tutor-model-toggle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 4, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend3f99981Commit Preview URL

Branch Preview URL
May 04 2026, 08:24 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

160-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route Google callback failures through _fail_redirect.

If the user cancels on Google's consent screen, Google returns error=access_denied instead of code. With code required here, that becomes a 422; similarly, fetch_token() / userinfo() exceptions will 500. In both cases the popup never gets oauth_denied or signin_failed, so the new popup flow strands the user on an error page instead of closing cleanly.

Possible fix
-@router.get("/google/callback")-def google_callback(code: str = Query(...), state: str = Query(None)):+@router.get("/google/callback")+def google_callback(+ code: str | None = Query(None),+ state: str | None = Query(None),+ error: str | None = Query(None),+):
"""Exchange auth code for tokens, validate `@bu.edu`, upsert user."""
state_data = _decode_state(state) if state else {}
code_verifier = state_data.get("cv")
popup_id = state_data.get("popup_id")
@@
+ if error == "access_denied":+ return _fail_redirect("oauth_denied")+ if not code:+ return _fail_redirect("signin_failed")+
flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES)
flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI
- flow.fetch_token(code=code, code_verifier=code_verifier)- creds = flow.credentials-- # Fetch user info from Google- service = build("oauth2", "v2", credentials=creds)- user_info = service.userinfo().get().execute()+ try:+ flow.fetch_token(code=code, code_verifier=code_verifier)+ creds = flow.credentials+ service = build("oauth2", "v2", credentials=creds)+ user_info = service.userinfo().get().execute()+ except Exception:+ return _fail_redirect("signin_failed")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routes/auth.py` around lines 160 - 185, The google_callback route
should route all auth failures through _fail_redirect: check for an incoming
'error' query param (e.g. access_denied) at the start of google_callback and
call _fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and
the Google userinfo call (service.userinfo().get().execute()) in try/except and
on any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
🧹 Nitpick comments (1)
frontend/src/lib/api.ts (1)

82-82: ⚡ Quick win

ModelPref is declared twice — import from a single source

ModelPref = 'smart' | 'fast' is defined identically here (line 82) and in frontend/src/components/ModelToggle.tsx (line 5). TypeScript structural typing hides this today, but a future addition to one definition (e.g., a third tier) won't automatically update the other, causing silent divergence.

Since ModelPref belongs to the API contract, keep the definition in api.ts and import it in ModelToggle.tsx:

♻️ Proposed fix

In api.ts, keep line 82 as-is.

In frontend/src/components/ModelToggle.tsx:

-export type ModelPref = "smart" | "fast";+export type { ModelPref } from "@/lib/api";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/lib/api.ts` at line 82, Remove the duplicate ModelPref type
declaration and import the canonical type from the API module: keep the export
type ModelPref = 'smart' | 'fast' in api.ts and in the ModelToggle component
(ModelToggle.tsx) delete its local ModelPref declaration and add an import of
ModelPref from the API module, then update any references in the ModelToggle
component to use the imported ModelPref type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/models/__init__.py`:
- Line 13: Update the misleading comment on model_pref to reflect that the
actual default is the "fast" model (gemini-2.5-flash) not "smart"; modify the
comment near the model_pref variable to state that valid values are "smart"
(gemini-2.5-pro) or "fast" (gemini-2.5-flash) and that None/unknown resolves to
the MODEL_DEFAULT (gemini-2.5-flash) via _resolve_tutor_model. Ensure the
wording matches the PR documentation ("default Fast") and references
MODEL_DEFAULT/_resolve_tutor_model behavior so readers aren’t misled.
In `@backend/routes/auth.py`:
- Around line 147-149: The state construction (state_payload with keys "action",
"cv" (code_verifier), and optional "popup_id") must be changed to include only a
server-bound nonce rather than the PKCE verifier; generate a strong random nonce
when initiating OAuth, store it server-side (e.g., in a session store or
HttpOnly cookie keyed to the browser session), set state_payload to {"n": nonce}
(omit cv and popup_id), and store the code_verifier and popup_id server-side
tied to that nonce; then update the OAuth callback handler (the code that reads
state and exchanges the code) to first validate the returned nonce against the
server-side store/cookie and only after a successful match retrieve the stored
code_verifier and popup_id to perform the token exchange and complete signin,
rejecting any callback with a missing/invalid nonce.
In `@frontend/src/components/ModelToggle.tsx`:
- Around line 41-56: The switch button's tooltip text is inaccessible because
the tooltip div is conditionally rendered and aria-describedby may point to a
non-existent element; update the button (the element using isSmart and onChange)
to include a static aria-label that combines the visible state and the
explanatory text (e.g., "Smart: stronger reasoning" vs "Fast: quicker replies")
so screen readers always receive the description, while leaving the visual
tooltip rendering unchanged for sighted users.
In `@frontend/src/components/SignInModal.tsx`:
- Around line 117-118: The SignInModal currently always appends popup_id to the
Google auth URL (const popupId = crypto.randomUUID(); const url =
`${API_URL}/api/auth/google?popup_id=...`) which breaks the same-tab fallback
when window.open is blocked; change the logic so that you only include popup_id
when the auth flow is actually launched in a popup. Concretely, construct two
URLs (or build the query conditionally): one with popup_id for the popup path
used by window.open, and one without popup_id for the same-tab fallback
navigation; ensure the code paths in the SignInModal component that call
window.open use the popup URL, while the fallback window.location.href (or
equivalent) uses the URL without popup_id, and apply the same conditional change
to the other occurrence noted (the code block around lines 151-157).
- Around line 123-143: The handler on channel.onmessage (inside the SignInModal
component) incorrectly requires data.name for a successful signin; change the
success condition to check only data.success and data.userId (i.e. if
(data.success && data.userId) ), and call setActiveUser(data.userId, data.name
|| "") so missing/empty display names are accepted; keep
cleanupPopupListeners(), setWaiting(false), confirmApproved(), the
router.replace("/dashboard") vs
sessionStorage.setItem("sapling_onboarding_pending", "1") logic, and onClose()
unchanged, and only call setLocalError when the success condition fails.
---
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 160-185: The google_callback route should route all auth failures
through _fail_redirect: check for an incoming 'error' query param (e.g.
access_denied) at the start of google_callback and call
_fail_redirect("oauth_denied") when present; wrap flow.fetch_token(...) and the
Google userinfo call (service.userinfo().get().execute()) in try/except and on
any exception call _fail_redirect("signin_failed") (or a more specific error
code) instead of letting exceptions propagate; ensure any error paths used by
Flow.from_client_config / flow.fetch_token and userinfo use _fail_redirect so
popup flows receive the proper redirect (references: google_callback,
_fail_redirect, Flow.from_client_config, flow.fetch_token,
service.userinfo().get().execute()).
---
Nitpick comments:
In `@frontend/src/lib/api.ts`:
- Line 82: Remove the duplicate ModelPref type declaration and import the
canonical type from the API module: keep the export type ModelPref = 'smart' |
'fast' in api.ts and in the ModelToggle component (ModelToggle.tsx) delete its
local ModelPref declaration and add an import of ModelPref from the API module,
then update any references in the ModelToggle component to use the imported
ModelPref type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 577d888d-5173-4b24-94a1-8cff9ad25fe1

📥 Commits

Reviewing files that changed from the base of the PR and between e146125 and 90ba796.

📒 Files selected for processing (8)
  • backend/models/__init__.py
  • backend/routes/auth.py
  • backend/routes/learn.py
  • frontend/src/app/auth/callback/page.tsx
  • frontend/src/components/ModelToggle.tsx
  • frontend/src/components/SignInModal.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/lib/api.ts

Comment threadbackend/models/__init__.py Outdated
Comment threadbackend/routes/auth.py Outdated
Comment threadfrontend/src/components/ModelToggle.tsx
Comment threadfrontend/src/components/SignInModal.tsx Outdated
Comment threadfrontend/src/components/SignInModal.tsx
Address PR #73 review feedback:
- auth: bind state nonce to HttpOnly cookie carrying {nonce, cv, popup_id}
(HMAC-signed via SESSION_SECRET); reject mismatched/missing state with
invalid_state. Closes login-CSRF where attacker-crafted state could log
victim into attacker's account.
- auth: wrap flow.fetch_token in try/except → oauth_exchange_failed so
popup self-closes instead of returning 500 on replayed/expired codes.
- auth: validate popup_id charset/length ([A-Za-z0-9_-]{1,128}); invalid
input degrades to non-popup mode.
- SignInModal: split popupUrl (with popup_id) from sameTabUrl (without)
so popup-blocker fallback redirects locally instead of broadcasting
into a dead channel.
- SignInModal: don't require data.name on success — /auth/callback
legitimately broadcasts empty name when /api/auth/me has no display.
- models: tighten model_pref to Optional[Literal["fast","smart"]] so
garbage input 422s; fix misleading default-comment.
- ModelToggle: static aria-label so screen readers hear the description
even when the visual tooltip is hidden.
- tests: add 23 unit tests for auth state cookie/nonce/popup_id helpers
and 6 for _resolve_tutor_model.
import json
import base64

import pytest
@AndresL230
AndresL230 merged commit a578006 into mainMay 4, 2026
3 of 4 checks passed
@AndresL230
AndresL230 deleted the fix/auth-popup-broadcast-channel-and-tutor-model-toggle branch May 4, 2026 20:25
Jose-Gael-Cruz-Lopez added a commit that referenced this pull request May 5, 2026
Closes the asymmetry I flagged in the post-merge review: chat tutor
got `model_pref: Literal["fast", "smart"]` per-request via PR #73,
quiz had only env-var-driven model selection. Now both routes accept
the same body field and route to the same model strings.
Wiring
- backend/models/__init__.py: GenerateQuizBody.model_pref accepts
"fast" | "smart" | None. Default None falls through to the agent's
task-default (model_for("quiz") = gemini-2.5-flash-lite per
ADR 0008).
- backend/routes/quiz.py:
* New _resolve_model_pref(pref) builds an optional GoogleModel
override from the body's preference, returning None for
None/empty/unknown so the agent's default wins on degraded input.
* _quiz_via_agent threads `model_pref` through and passes
`model=...` to quiz_agent.run only when an override is present
(no model kwarg = agent default).
* _legacy_generate_quiz now picks MODEL_SMART when pref="smart",
falls back to MODEL_LITE otherwise. So the fast/smart toggle
works on BOTH paths, including when the agent path trips and we
degrade.
- _PREF_MODEL_NAMES table at module scope so the {fast→flash,
smart→pro} mapping is one place. Mirrors the chat tutor's mapping
in services/gemini_service.py (MODEL_DEFAULT/MODEL_SMART).
Tests (TestQuizModelPref, 5 new)
- model_pref="smart" → quiz_agent.run gets model=GoogleModel('gemini-2.5-pro')
- model_pref="fast" → quiz_agent.run gets model=GoogleModel('gemini-2.5-flash')
- model_pref=None → quiz_agent.run gets NO model kwarg (agent default wins)
- model_pref="auto" → _resolve_model_pref returns None (graceful unknown)
- legacy fallback with smart → call_gemini_json gets model=MODEL_SMART
Documentation: ADR 0013 addendum captures the design decision (env var
for ops defaults + body field for per-request overrides; they compose).
Tests
- tests/test_quiz_routes.py: 28/28 (was 23, +5).
- Full backend suite: 531 passed, 3 pre-existing live-Supabase
failures unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230