From ac7a5473999e47edf98f8adb86225ea0d56fe5fe Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Mon, 4 May 2026 04:15:30 -0400 Subject: [PATCH 1/3] fix(auth): rebuild Google sign-in popup on BroadcastChannel 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:") *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) --- backend/routes/auth.py | 29 ++++-- frontend/src/app/auth/callback/page.tsx | 24 ++--- frontend/src/components/SignInModal.tsx | 123 +++++++++++++++++++++++- 3 files changed, 152 insertions(+), 24 deletions(-) diff --git a/backend/routes/auth.py b/backend/routes/auth.py index aa95a406..c3e36584 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -136,7 +136,7 @@ def get_me(request: Request): @router.get("/google") -def google_login(): +def google_login(popup_id: str = Query(None)): """Redirect to Google consent screen with identity + calendar scopes.""" if not GOOGLE_AVAILABLE or not GOOGLE_CLIENT_ID: raise HTTPException(status_code=400, detail="Google OAuth not configured") @@ -144,10 +144,13 @@ def google_login(): code_verifier, code_challenge = _generate_pkce_pair() flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES) flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI + state_payload = {"action": "signin", "cv": code_verifier} + if popup_id: + state_payload["popup_id"] = popup_id auth_url, _ = flow.authorization_url( prompt="consent", access_type="offline", - state=_encode_state({"action": "signin", "cv": code_verifier}), + state=_encode_state(state_payload), code_challenge=code_challenge, code_challenge_method="S256", ) @@ -157,11 +160,20 @@ def google_login(): @router.get("/google/callback") def google_callback(code: str = Query(...), state: str = Query(None)): """Exchange auth code for tokens, validate @bu.edu, upsert user.""" - if not GOOGLE_AVAILABLE: - return RedirectResponse(f"{FRONTEND_URL}/auth?error=google_not_configured") - state_data = _decode_state(state) if state else {} code_verifier = state_data.get("cv") + popup_id = state_data.get("popup_id") + + def _fail_redirect(error_code: str, fallback_path: str = "/auth") -> RedirectResponse: + # In popup mode, route failures through /auth/callback so the popup + # can broadcast the error and self-close instead of stranding the opener. + if popup_id: + params = urlencode({"error": error_code, "popup_id": popup_id}) + return RedirectResponse(f"{FRONTEND_URL}/auth/callback?{params}") + return RedirectResponse(f"{FRONTEND_URL}{fallback_path}?error={error_code}") + + if not GOOGLE_AVAILABLE: + return _fail_redirect("google_not_configured") flow = Flow.from_client_config(_google_client_config(), scopes=AUTH_SCOPES) flow.redirect_uri = GOOGLE_AUTH_REDIRECT_URI @@ -184,9 +196,7 @@ def google_callback(code: str = Query(...), state: str = Query(None)): # Restrict to @bu.edu accounts if not email.endswith("@bu.edu"): - return RedirectResponse( - f"{FRONTEND_URL}/auth?error=invalid_domain" - ) + return _fail_redirect("invalid_domain") # Determine user_id: check if this Google ID already exists existing = table("users").select("id,is_approved", filters={"google_id": f"eq.{google_id}"}) @@ -233,6 +243,8 @@ def google_callback(code: str = Query(...), state: str = Query(None)): ) if not is_approved: + if popup_id: + return _fail_redirect("not_approved") return RedirectResponse(f"{FRONTEND_URL}/pending") # Build a short-lived HMAC token so the frontend can verify this redirect @@ -250,5 +262,6 @@ def google_callback(code: str = Query(...), state: str = Query(None)): "avatar": avatar_url, "is_approved": "true", **({"auth_token": auth_token} if auth_token else {}), + **({"popup_id": popup_id} if popup_id else {}), }) return RedirectResponse(f"{FRONTEND_URL}/auth/callback?{params}") diff --git a/frontend/src/app/auth/callback/page.tsx b/frontend/src/app/auth/callback/page.tsx index a1e30eb4..9b2d5c18 100644 --- a/frontend/src/app/auth/callback/page.tsx +++ b/frontend/src/app/auth/callback/page.tsx @@ -16,26 +16,26 @@ function CallbackInner() { const approvedParam = searchParams.get('is_approved'); const authToken = searchParams.get('auth_token'); const error = searchParams.get('error'); + const popupId = searchParams.get('popup_id'); - const isPopup = - typeof window !== 'undefined' && - !!window.opener && - window.opener !== window; + // Popup mode is signalled by the backend echoing back popup_id we sent + // when opening the window. We can't trust window.opener here: COOP nulls + // it the moment the popup hops to a cross-origin URL (Railway/Google). + const isPopup = !!popupId && typeof window !== 'undefined'; - const postToOpener = (payload: Record): boolean => { - if (!isPopup) return false; + const broadcast = (payload: Record): boolean => { + if (!isPopup || typeof BroadcastChannel === 'undefined') return false; try { - window.opener.postMessage( - { type: 'sapling_signin', ...payload }, - window.location.origin, - ); + const ch = new BroadcastChannel(`sapling_signin:${popupId}`); + ch.postMessage({ type: 'sapling_signin', ...payload }); + ch.close(); } catch {} try { window.close(); } catch {} return true; }; const fail = (errCode: string) => { - if (postToOpener({ success: false, error: errCode })) return; + if (broadcast({ success: false, error: errCode })) return; router.replace(`/?error=${encodeURIComponent(errCode)}`); }; @@ -83,7 +83,7 @@ function CallbackInner() { confirmApproved(); } - if (postToOpener({ + if (broadcast({ success: true, userId, name, diff --git a/frontend/src/components/SignInModal.tsx b/frontend/src/components/SignInModal.tsx index 536641ef..7c22493f 100644 --- a/frontend/src/components/SignInModal.tsx +++ b/frontend/src/components/SignInModal.tsx @@ -1,10 +1,11 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useUser } from "@/context/UserContext"; import { IS_LOCAL_MODE } from "@/lib/api"; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; +const POPUP_TIMEOUT_MS = 3 * 60 * 1000; const ERROR_COPY: Record = { not_approved: "Your account is pending approval. We'll email you once an admin lets you in.", @@ -29,24 +30,47 @@ export default function SignInModal({ open, onClose, errorCode }: SignInModalPro const [closing, setClosing] = useState(false); const [localError, setLocalError] = useState(null); const [waiting, setWaiting] = useState(false); + const popupRef = useRef(null); + const channelRef = useRef(null); + const watchdogRef = useRef(null); + + const cleanupPopupListeners = useCallback(() => { + if (channelRef.current) { + try { channelRef.current.close(); } catch {} + channelRef.current = null; + } + if (watchdogRef.current !== null) { + window.clearTimeout(watchdogRef.current); + watchdogRef.current = null; + } + // Best effort — COOP may have severed the handle, but harmless to try. + if (popupRef.current) { + try { popupRef.current.close(); } catch {} + popupRef.current = null; + } + }, []); const close = useCallback(() => { + cleanupPopupListeners(); setClosing(true); setTimeout(() => { setClosing(false); onClose(); }, 200); - }, [onClose]); + }, [onClose, cleanupPopupListeners]); useEffect(() => { if (!open) { setWaiting(false); + cleanupPopupListeners(); return; } setLocalError(null); document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = ""; }; - }, [open]); + }, [open, cleanupPopupListeners]); + + useEffect(() => () => cleanupPopupListeners(), [cleanupPopupListeners]); useEffect(() => { if (!open) return; @@ -74,8 +98,79 @@ export default function SignInModal({ open, onClose, errorCode }: SignInModalPro setLocalError("google_not_configured"); return; } + + // BroadcastChannel-based popup flow. We don't depend on window.opener: + // COOP severs the opener handle the moment the popup hops to a + // cross-origin URL (Railway, then Google). Instead the callback page + // broadcasts the result on a per-attempt channel keyed by popup_id. + const supportsPopup = + typeof BroadcastChannel !== "undefined" && + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function"; + + if (!supportsPopup) { + setWaiting(true); + window.location.href = `${API_URL}/api/auth/google`; + return; + } + + const popupId = crypto.randomUUID(); + const url = `${API_URL}/api/auth/google?popup_id=${encodeURIComponent(popupId)}`; + + cleanupPopupListeners(); + const channel = new BroadcastChannel(`sapling_signin:${popupId}`); + channelRef.current = channel; + channel.onmessage = (event: MessageEvent) => { + const data = event.data as { + type?: string; success?: boolean; error?: string; + userId?: string; name?: string; avatar?: string; + onboardingCompleted?: boolean; + } | null; + if (!data || data.type !== "sapling_signin") return; + cleanupPopupListeners(); + setWaiting(false); + if (data.success && data.userId && data.name) { + setActiveUser(data.userId, data.name, data.avatar || ""); + confirmApproved(); + if (data.onboardingCompleted) { + router.replace("/dashboard"); + } else { + sessionStorage.setItem("sapling_onboarding_pending", "1"); + } + onClose(); + } else { + setLocalError(data.error || "signin_failed"); + } + }; + + const w = 520; + const h = 640; + const left = Math.max(0, window.screenX + (window.outerWidth - w) / 2); + const top = Math.max(0, window.screenY + (window.outerHeight - h) / 2); + const features = `width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`; + const popup = window.open(url, "sapling_signin", features); + if (!popup || popup.closed) { + // Pop-up blocker / user setting. Fall back to same-tab redirect. + cleanupPopupListeners(); + setWaiting(true); + window.location.href = url; + return; + } + popupRef.current = popup; + try { popup.focus(); } catch {} setWaiting(true); - window.location.href = `${API_URL}/api/auth/google`; + + // We can't reliably observe popup.closed under COOP, so set a watchdog + // that resets the modal if the user abandons the flow. + watchdogRef.current = window.setTimeout(() => { + cleanupPopupListeners(); + setWaiting(false); + }, POPUP_TIMEOUT_MS); + }; + + const cancelSignIn = () => { + cleanupPopupListeners(); + setWaiting(false); }; return ( @@ -181,6 +276,26 @@ export default function SignInModal({ open, onClose, errorCode }: SignInModalPro {waiting ? "Waiting for Google…" : "Continue with Google"} + {waiting && ( + + )} +

By signing in, you agree to the{" "} terms From 90ba796cae95e30d6f03e9680a8a247f51549aea Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Mon, 4 May 2026 04:15:43 -0400 Subject: [PATCH 2/3] feat(learn): add Fast/Smart model switcher for tutor chat 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) --- backend/models/__init__.py | 3 + backend/routes/learn.py | 33 ++++++- frontend/src/components/ModelToggle.tsx | 111 ++++++++++++++++++++++ frontend/src/components/screens/Learn.tsx | 11 ++- frontend/src/lib/api.ts | 40 +++++++- 5 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/ModelToggle.tsx diff --git a/backend/models/__init__.py b/backend/models/__init__.py index cd89bdd7..8bb0182c 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -10,6 +10,7 @@ class StartSessionBody(BaseModel): mode: str = "socratic" use_shared_context: bool = True course_id: Optional[str] = None # Direct course_id lookup instead of resolving from topic + model_pref: Optional[str] = None # "smart" (default, gemini-2.5-pro) or "fast" (gemini-2.5-flash) class ChatBody(BaseModel): @@ -18,6 +19,7 @@ class ChatBody(BaseModel): message: str mode: str = "socratic" use_shared_context: bool = True + model_pref: Optional[str] = None class EndSessionBody(BaseModel): @@ -31,6 +33,7 @@ class ActionBody(BaseModel): action_type: str = "hint" mode: str = "socratic" use_shared_context: bool = True + model_pref: Optional[str] = None # ── Quiz ────────────────────────────────────────────────────────────────────── diff --git a/backend/routes/learn.py b/backend/routes/learn.py index 37df0b3a..c289969c 100644 --- a/backend/routes/learn.py +++ b/backend/routes/learn.py @@ -11,7 +11,12 @@ from models import StartSessionBody, ChatBody, EndSessionBody, ActionBody, ModeSwitchBody from services.auth_guard import require_self, get_session_user_id from services.encryption import encrypt_if_present, encrypt_json, decrypt_if_present, decrypt_json -from services.gemini_service import MODEL_SMART, call_gemini_multiturn, extract_graph_update +from services.gemini_service import ( + MODEL_DEFAULT, + MODEL_SMART, + call_gemini_multiturn, + extract_graph_update, +) from services.graph_service import get_graph, apply_graph_update router = APIRouter() @@ -28,6 +33,18 @@ "teachback": "Teach-back (you explain to me)", } +# User-facing speed/quality knob for the tutor chat. +# "fast" = flash (default, faster), "smart" = pro (opt-in, slower but stronger reasoning). +# Anything unrecognized falls back to fast so the default is the snappy one. +_MODEL_PREF_TO_MODEL = { + "fast": MODEL_DEFAULT, + "smart": MODEL_SMART, +} + + +def _resolve_tutor_model(model_pref: str | None) -> str: + return _MODEL_PREF_TO_MODEL.get(model_pref or "", MODEL_DEFAULT) + def _load_prompt(name: str) -> str: with open(os.path.join(PROMPTS_DIR, name)) as f: @@ -293,13 +310,15 @@ def start_session(body: StartSessionBody, request: Request): ) try: - raw = call_gemini_multiturn(system_prompt, [], user_message, model=MODEL_SMART) + raw = call_gemini_multiturn( + system_prompt, [], user_message, model=_resolve_tutor_model(body.model_pref) + ) except Exception as e: raise HTTPException(status_code=502, detail=f"Gemini error: {e}") reply, graph_update = extract_graph_update(raw) apply_graph_update(body.user_id, graph_update, course_id=course_id) - + PENDING_SESSIONS[session_id] = { "user_id": body.user_id, "mode": body.mode, @@ -339,7 +358,9 @@ def chat(body: ChatBody, request: Request): ) try: - raw = call_gemini_multiturn(system_prompt, history, body.message, model=MODEL_SMART) + raw = call_gemini_multiturn( + system_prompt, history, body.message, model=_resolve_tutor_model(body.model_pref) + ) except Exception as e: raise HTTPException(status_code=502, detail=f"Gemini error: {e}") @@ -558,7 +579,9 @@ def action(body: ActionBody, request: Request): action_message = f"[ACTION: {action_prompts.get(body.action_type, '')}]" try: - raw = call_gemini_multiturn(system_prompt, history, action_message, model=MODEL_SMART) + raw = call_gemini_multiturn( + system_prompt, history, action_message, model=_resolve_tutor_model(body.model_pref) + ) except Exception as e: raise HTTPException(status_code=502, detail=f"Gemini error: {e}") diff --git a/frontend/src/components/ModelToggle.tsx b/frontend/src/components/ModelToggle.tsx new file mode 100644 index 00000000..5b40d724 --- /dev/null +++ b/frontend/src/components/ModelToggle.tsx @@ -0,0 +1,111 @@ +"use client"; + +import React, { useEffect, useState } from "react"; + +export type ModelPref = "smart" | "fast"; + +const STORAGE_KEY = "sapling_model_pref"; + +export function useModelPref(): [ModelPref, (v: ModelPref) => void] { + const [pref, setPref] = useState("fast"); + useEffect(() => { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === "fast" || raw === "smart") setPref(raw); + }, []); + const update = (v: ModelPref) => { + setPref(v); + localStorage.setItem(STORAGE_KEY, v); + }; + return [pref, update]; +} + +export function ModelToggle({ + pref, + onChange, +}: { + pref: ModelPref; + onChange: (v: ModelPref) => void; +}) { + const [tooltip, setTooltip] = useState(false); + // Fast is the default; Smart is the opt-in upgrade, so it gets the highlight. + const isSmart = pref === "smart"; + + return ( +

setTooltip(true)} + onMouseLeave={() => setTooltip(false)} + onFocus={() => setTooltip(true)} + onBlur={() => setTooltip(false)} + > + + {tooltip && ( +
+ + Tutor model + + Fast is the default — quicker replies. Flip on Smart for stronger reasoning when you + want depth and don't mind waiting. +
+ )} +
+ ); +} diff --git a/frontend/src/components/screens/Learn.tsx b/frontend/src/components/screens/Learn.tsx index 8917363e..da8e496b 100644 --- a/frontend/src/components/screens/Learn.tsx +++ b/frontend/src/components/screens/Learn.tsx @@ -9,6 +9,7 @@ import { CustomSelect } from "../CustomSelect"; import { ChatPanel, type ChatMsg } from "../ChatPanel"; import { SessionSummary } from "../SessionSummary"; import { SharedContextToggle, useSharedContext } from "../SharedContextToggle"; +import { ModelToggle, useModelPref } from "../ModelToggle"; import { DisclaimerModal } from "../DisclaimerModal"; import { AIDisclaimerChip } from "../AIDisclaimerChip"; import { QuizPanel } from "../QuizPanel"; @@ -89,6 +90,7 @@ function LearnInner() { const isMobile = useIsMobile(); const [sharedCtx, setSharedCtx] = useSharedContext(); + const [modelPref, setModelPref] = useModelPref(); const initialTopic = searchParams.get("topic") ?? ""; const initialMode = normalizeMode(searchParams.get("mode")); @@ -172,7 +174,7 @@ function LearnInner() { setMessages([{ id: msgId(), role: "assistant", content: "", loading: true }]); setStarting(true); try { - const res = await startSession(userId, t, mode, selectedCourseId || undefined, sharedCtx); + const res = await startSession(userId, t, mode, selectedCourseId || undefined, sharedCtx, modelPref); setSessionId(res.session_id); setMessages([{ id: msgId(), role: "assistant", content: res.initial_message || "Let's begin." }]); } catch (err) { @@ -223,7 +225,7 @@ function LearnInner() { ]); setSending(true); try { - const res = await sendChat(sessionId, userId, userText, chatMode, sharedCtx); + const res = await sendChat(sessionId, userId, userText, chatMode, sharedCtx, modelPref); setMessages(m => { const next = [...m]; next[next.length - 1] = { id: next[next.length - 1].id, role: "assistant", content: res.reply || "" }; @@ -238,7 +240,7 @@ function LearnInner() { } finally { setSending(false); } - }, [sessionId, userId, mode, sharedCtx]); + }, [sessionId, userId, mode, sharedCtx, modelPref]); const handleAction = async (action: "hint" | "confused" | "skip") => { if (!sessionId || !userId) return; @@ -251,7 +253,7 @@ function LearnInner() { ]); setSending(true); try { - const res = await learnAction(sessionId, userId, action, chatMode, sharedCtx); + const res = await learnAction(sessionId, userId, action, chatMode, sharedCtx, modelPref); setMessages(m => { const next = [...m]; next[next.length - 1] = { id: next[next.length - 1].id, role: "assistant", content: res.reply || "" }; @@ -579,6 +581,7 @@ function LearnInner() { actions={ <> +