From f4e86224400060bcb258c636ac6225041f64b0ed Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Wed, 6 May 2026 12:59:14 +0100 Subject: [PATCH 1/2] Add Google OAuth, magic link, and password reset (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-side completes the auth surface: - AuthContext gains signInWithGoogle, signInWithMagicLink, resetPassword, and updatePassword. All are no-ops with a clear error when Supabase isn't configured (matches existing pattern). - ChatPage's auth modal is restructured: "Continue with Google" button at top, divider, then email/password (signin/signup) or single email (magic-link/reset). "Forgot password?" sits beneath the password field on signin and switches the modal to reset mode. After magic link / reset / signup confirmation, a "Check your email" notice replaces the form until the user dismisses it. - New page at /reset-password/ — Supabase deep-link target. The SDK picks up the recovery token from the URL hash and creates a session; the page shows a "set new password" form, calls updateUser, and bounces back to /. Invalid / expired links show a helpful error. The existing email+password flow is untouched: existing users continue to work without migration. DASHBOARD CONFIG REQUIRED (out-of-band, not in this PR): - Enable the Google provider in Supabase project settings; paste OAuth client id + secret from a Google Cloud Console OAuth client. - Add prod + PR-beta + localhost origins under "Redirect URLs": https://policyengine-uk-chat.vercel.app https://policyengine-uk-chat.vercel.app/reset-password plus the equivalent preview-domain entries plus http://localhost:3006(/reset-password) for dev - Confirm the email templates point at the right domains (Supabase defaults to the project URL; we want app domain). Closes #45 Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/app/ChatPage.tsx | 143 +++++++++++++++++++---- frontend/src/app/reset-password/page.tsx | 107 +++++++++++++++++ frontend/src/utils/AuthContext.tsx | 74 +++++++++++- 3 files changed, 294 insertions(+), 30 deletions(-) create mode 100644 frontend/src/app/reset-password/page.tsx diff --git a/frontend/src/app/ChatPage.tsx b/frontend/src/app/ChatPage.tsx index b650092..bced763 100644 --- a/frontend/src/app/ChatPage.tsx +++ b/frontend/src/app/ChatPage.tsx @@ -141,7 +141,7 @@ async function apiRequest(method: string, endpoint: string, params?: Record([]); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); @@ -154,10 +154,11 @@ export default function ChatPage() { const [activeConversationId, setActiveConversationId] = useState(null); const conversationCache = useRef>(new Map()); const [showAuth, setShowAuth] = useState(false); - const [authMode, setAuthMode] = useState<"signin" | "signup">("signin"); + const [authMode, setAuthMode] = useState<"signin" | "signup" | "magic-link" | "reset">("signin"); const [authEmail, setAuthEmail] = useState(""); const [authPassword, setAuthPassword] = useState(""); const [authError, setAuthError] = useState(null); + const [authNotice, setAuthNotice] = useState(null); const [authSubmitting, setAuthSubmitting] = useState(false); const [reportOpen, setReportOpen] = useState(false); const [reportNote, setReportNote] = useState(""); @@ -1016,32 +1017,126 @@ export default function ChatPage() { {/* Auth modal */} {showAuth && ( -
setShowAuth(false)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.3)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}> +
{ if (!authSubmitting) { setShowAuth(false); setAuthError(null); setAuthNotice(null); } }} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.3)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}>
e.stopPropagation()} style={{ background: "#fff", padding: "32px", width: "360px", maxWidth: "90vw" }}>

- {authMode === "signin" ? "Sign in" : "Create account"} + {authMode === "signin" ? "Sign in" + : authMode === "signup" ? "Create account" + : authMode === "magic-link" ? "Sign in with magic link" + : "Reset your password"}

- {authError &&
{authError}
} -
{ - e.preventDefault(); - setAuthSubmitting(true); - setAuthError(null); - const { error } = authMode === "signin" ? await signIn(authEmail, authPassword) : await signUp(authEmail, authPassword); - setAuthSubmitting(false); - if (error) setAuthError(error); - else { setShowAuth(false); setAuthEmail(""); setAuthPassword(""); } - }}> - setAuthEmail(e.target.value)} required style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "10px", fontFamily: "inherit", boxSizing: "border-box" }} /> - setAuthPassword(e.target.value)} required minLength={6} style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "16px", fontFamily: "inherit", boxSizing: "border-box" }} /> - -
-
- {authMode === "signin" ? ( - <>No account? - ) : ( + + {authError &&
{authError}
} + {authNotice &&
{authNotice}
} + + {/* Google OAuth — available everywhere except after a notice has been shown */} + {!authNotice && (authMode === "signin" || authMode === "signup" || authMode === "magic-link") && ( + <> + +
+
+ or +
+
+ + )} + + {/* Email + password (signin/signup) */} + {!authNotice && (authMode === "signin" || authMode === "signup") && ( +
{ + e.preventDefault(); + setAuthSubmitting(true); + setAuthError(null); + const { error } = authMode === "signin" ? await signIn(authEmail, authPassword) : await signUp(authEmail, authPassword); + setAuthSubmitting(false); + if (error) setAuthError(error); + else if (authMode === "signup") setAuthNotice(`Check your email at ${authEmail} to confirm your account.`); + else { setShowAuth(false); setAuthEmail(""); setAuthPassword(""); } + }}> + setAuthEmail(e.target.value)} required style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "10px", fontFamily: "inherit", boxSizing: "border-box" }} /> + setAuthPassword(e.target.value)} required minLength={6} style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "8px", fontFamily: "inherit", boxSizing: "border-box" }} /> + {authMode === "signin" && ( +
+ +
+ )} + +
+ )} + + {/* Magic-link form */} + {!authNotice && authMode === "magic-link" && ( +
{ + e.preventDefault(); + setAuthSubmitting(true); + setAuthError(null); + const { error } = await signInWithMagicLink(authEmail); + setAuthSubmitting(false); + if (error) setAuthError(error); + else setAuthNotice(`Check your email at ${authEmail} for a sign-in link.`); + }}> + setAuthEmail(e.target.value)} required autoFocus style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "16px", fontFamily: "inherit", boxSizing: "border-box" }} /> + +
+ )} + + {/* Password reset form */} + {!authNotice && authMode === "reset" && ( +
{ + e.preventDefault(); + setAuthSubmitting(true); + setAuthError(null); + const { error } = await resetPassword(authEmail); + setAuthSubmitting(false); + if (error) setAuthError(error); + else setAuthNotice(`Check your email at ${authEmail} for a password reset link.`); + }}> + setAuthEmail(e.target.value)} required autoFocus style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "16px", fontFamily: "inherit", boxSizing: "border-box" }} /> + +
+ )} + + {/* Bottom switcher */} +
+ {authNotice ? ( + + ) : authMode === "signin" ? ( + <> +
No account?
+
Or
+ + ) : authMode === "signup" ? ( <>Have an account? + ) : ( + /* magic-link or reset */ + )}
diff --git a/frontend/src/app/reset-password/page.tsx b/frontend/src/app/reset-password/page.tsx new file mode 100644 index 0000000..bc4df57 --- /dev/null +++ b/frontend/src/app/reset-password/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/utils/AuthContext"; +import { THEME } from "@/components/theme"; + +/** + * Landing page for the Supabase password-reset email link. + * + * Supabase puts the recovery token in the URL hash and the SDK auto-creates + * a temporary session. We render a "set new password" form, and on submit + * call `updateUser({ password })` which finalises the change. The user is + * already signed in at that point — we just bounce them back to the chat. + */ +export default function ResetPasswordPage() { + const router = useRouter(); + const { user, loading, updatePassword } = useAuth(); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + // Once the SDK processes the hash and the user is recognised, we can show + // the form. If after auth has loaded there's still no user, the link was + // invalid or expired. + const ready = !loading && !!user; + + useEffect(() => { + if (done) { + const t = setTimeout(() => router.push("/"), 1500); + return () => clearTimeout(t); + } + }, [done, router]); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password.length < 6) { setError("Password must be at least 6 characters."); return; } + if (password !== confirm) { setError("Passwords don't match."); return; } + setSubmitting(true); + setError(null); + const { error: err } = await updatePassword(password); + setSubmitting(false); + if (err) setError(err); + else setDone(true); + }; + + return ( +
+
+

Set a new password

+

+ You'll be signed in once the new password is saved. +

+ + {loading &&
Verifying reset link…
} + + {!loading && !user && ( +
+ This reset link is invalid or has expired. Request a new one from the sign-in screen. +
+ )} + + {ready && !done && ( + <> + {error &&
{error}
} +
+ setPassword(e.target.value)} + required + minLength={6} + autoFocus + style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "10px", fontFamily: "inherit", boxSizing: "border-box" }} + /> + setConfirm(e.target.value)} + required + minLength={6} + style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "16px", fontFamily: "inherit", boxSizing: "border-box" }} + /> + +
+ + )} + + {done && ( +
+ Password updated. Redirecting… +
+ )} +
+
+ ); +} diff --git a/frontend/src/utils/AuthContext.tsx b/frontend/src/utils/AuthContext.tsx index b1d64a0..6cded37 100644 --- a/frontend/src/utils/AuthContext.tsx +++ b/frontend/src/utils/AuthContext.tsx @@ -11,17 +11,32 @@ interface AuthState { signUp: (email: string, password: string) => Promise<{ error: string | null }>; signIn: (email: string, password: string) => Promise<{ error: string | null }>; signOut: () => Promise; + signInWithGoogle: () => Promise<{ error: string | null }>; + signInWithMagicLink: (email: string) => Promise<{ error: string | null }>; + resetPassword: (email: string) => Promise<{ error: string | null }>; + updatePassword: (password: string) => Promise<{ error: string | null }>; } +const NOT_CONFIGURED = { error: "Auth not configured" } as const; + const AuthContext = createContext({ user: null, session: null, loading: false, - signUp: async () => ({ error: "Auth not configured" }), - signIn: async () => ({ error: "Auth not configured" }), + signUp: async () => NOT_CONFIGURED, + signIn: async () => NOT_CONFIGURED, signOut: async () => {}, + signInWithGoogle: async () => NOT_CONFIGURED, + signInWithMagicLink: async () => NOT_CONFIGURED, + resetPassword: async () => NOT_CONFIGURED, + updatePassword: async () => NOT_CONFIGURED, }); +const originIfBrowser = (): string | undefined => { + if (typeof window === "undefined") return undefined; + return window.location.origin; +}; + export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [session, setSession] = useState(null); @@ -50,14 +65,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signUp = async (email: string, password: string) => { const supabase = getSupabase(); - if (!supabase) return { error: "Auth not configured" }; - const { error } = await supabase.auth.signUp({ email, password }); + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.signUp({ + email, + password, + options: { emailRedirectTo: originIfBrowser() }, + }); return { error: error?.message ?? null }; }; const signIn = async (email: string, password: string) => { const supabase = getSupabase(); - if (!supabase) return { error: "Auth not configured" }; + if (!supabase) return NOT_CONFIGURED; const { error } = await supabase.auth.signInWithPassword({ email, password }); return { error: error?.message ?? null }; }; @@ -66,8 +85,51 @@ export function AuthProvider({ children }: { children: ReactNode }) { await getSupabase()?.auth.signOut(); }; + const signInWithGoogle = async () => { + const supabase = getSupabase(); + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { redirectTo: originIfBrowser() }, + }); + // Note: signInWithOAuth navigates the page on success — code after this + // line generally doesn't run unless `error` is set. + return { error: error?.message ?? null }; + }; + + const signInWithMagicLink = async (email: string) => { + const supabase = getSupabase(); + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { emailRedirectTo: originIfBrowser() }, + }); + return { error: error?.message ?? null }; + }; + + const resetPassword = async (email: string) => { + const supabase = getSupabase(); + if (!supabase) return NOT_CONFIGURED; + const origin = originIfBrowser(); + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: origin ? `${origin}/reset-password` : undefined, + }); + return { error: error?.message ?? null }; + }; + + const updatePassword = async (password: string) => { + const supabase = getSupabase(); + if (!supabase) return NOT_CONFIGURED; + const { error } = await supabase.auth.updateUser({ password }); + return { error: error?.message ?? null }; + }; + return ( - + {children} ); From 0d53ffbf5755592f1d924f87cd660b5e4a994d8f Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Tue, 26 May 2026 10:32:27 +0200 Subject: [PATCH 2/2] Address review: hash-grace window, invalid-link CTA, aria-hidden value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold the reset-password page in a "Verifying reset link…" state for 600ms after mount so the Supabase SDK has time to parse the recovery hash and emit the PASSWORD_RECOVERY event before we decide the link is invalid. Add a "Back to sign in" button on the invalid-link state so users have a way out. Fix the Google OAuth SVG's aria-hidden to use an explicit "true" attribute value. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/app/ChatPage.tsx | 2 +- frontend/src/app/reset-password/page.tsx | 35 ++++++++++++++++++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/ChatPage.tsx b/frontend/src/app/ChatPage.tsx index bced763..f88732e 100644 --- a/frontend/src/app/ChatPage.tsx +++ b/frontend/src/app/ChatPage.tsx @@ -1044,7 +1044,7 @@ export default function ChatPage() { }} style={{ width: "100%", padding: "10px", fontSize: "14px", background: "#fff", color: "#1c1a17", border: "1px solid #d1d5db", cursor: authSubmitting ? "not-allowed" : "pointer", fontFamily: "inherit", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px", marginBottom: "16px", opacity: authSubmitting ? 0.7 : 1 }} > - +
Verifying reset link…
} + {(loading || (!user && !hashGracePassed)) && ( +
Verifying reset link…
+ )} - {!loading && !user && ( -
- This reset link is invalid or has expired. Request a new one from the sign-in screen. -
+ {linkInvalid && ( + <> +
+ This reset link is invalid or has expired. +
+ + )} {ready && !done && (