diff --git a/frontend/src/app/ChatPage.tsx b/frontend/src/app/ChatPage.tsx index b650092..f88732e 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..e5fa6d2 --- /dev/null +++ b/frontend/src/app/reset-password/page.tsx @@ -0,0 +1,126 @@ +"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); + // The Supabase SDK parses the recovery token from the URL hash asynchronously + // after createClient runs, then fires an auth-state-change event. Without a + // grace period the page would briefly render "invalid link" before the + // session arrives. Wait 600ms before treating "no user" as a failure. + const [hashGracePassed, setHashGracePassed] = useState(false); + + const ready = !loading && !!user; + const linkInvalid = !loading && !user && hashGracePassed; + + useEffect(() => { + const t = setTimeout(() => setHashGracePassed(true), 600); + return () => clearTimeout(t); + }, []); + + 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 || (!user && !hashGracePassed)) && ( +
Verifying reset link…
+ )} + + {linkInvalid && ( + <> +
+ This reset link is invalid or has expired. +
+ + + )} + + {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} );