Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 119 additions & 24 deletions frontend/src/app/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ async function apiRequest<T>(method: string, endpoint: string, params?: Record<s
}

export default function ChatPage() {
const { user, loading: authLoading, signIn, signUp, signOut } = useAuth();
const { user, loading: authLoading, signIn, signUp, signOut, signInWithGoogle, signInWithMagicLink, resetPassword } = useAuth();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
Expand All @@ -154,10 +154,11 @@ export default function ChatPage() {
const [activeConversationId, setActiveConversationId] = useState<number | null>(null);
const conversationCache = useRef<Map<number, ConversationDetail>>(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<string | null>(null);
const [authNotice, setAuthNotice] = useState<string | null>(null);
const [authSubmitting, setAuthSubmitting] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportNote, setReportNote] = useState("");
Expand Down Expand Up @@ -1016,32 +1017,126 @@ export default function ChatPage() {

{/* Auth modal */}
{showAuth && (
<div onClick={() => setShowAuth(false)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.3)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}>
<div onClick={() => { 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 }}>
<div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", padding: "32px", width: "360px", maxWidth: "90vw" }}>
<h2 style={{ margin: "0 0 20px", fontSize: "18px", fontWeight: 600, color: "#1c1a17" }}>
{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"}
</h2>
{authError && <div style={{ padding: "8px 12px", background: "#fef2f2", color: "#b91c1c", fontSize: "13px", marginBottom: "16px" }}>{authError}</div>}
<form onSubmit={async (e) => {
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(""); }
}}>
<input type="email" placeholder="Email" value={authEmail} onChange={(e) => setAuthEmail(e.target.value)} required style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "10px", fontFamily: "inherit", boxSizing: "border-box" }} />
<input type="password" placeholder="Password" value={authPassword} onChange={(e) => 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" }} />
<button type="submit" disabled={authSubmitting} style={{ width: "100%", padding: "10px", fontSize: "14px", background: THEME.primaryGradient, color: "#fff", border: "none", cursor: authSubmitting ? "not-allowed" : "pointer", fontFamily: "inherit", opacity: authSubmitting ? 0.7 : 1 }}>
{authSubmitting ? "..." : authMode === "signin" ? "Sign in" : "Create account"}
</button>
</form>
<div style={{ marginTop: "16px", textAlign: "center", fontSize: "13px", color: "#6b7280" }}>
{authMode === "signin" ? (
<>No account? <button onClick={() => { setAuthMode("signup"); setAuthError(null); }} style={{ color: THEME.primary, background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: "13px" }}>Create one</button></>
) : (

{authError && <div style={{ padding: "8px 12px", background: "#fef2f2", color: "#b91c1c", fontSize: "13px", marginBottom: "12px" }}>{authError}</div>}
{authNotice && <div style={{ padding: "8px 12px", background: "#ecfdf5", color: "#065f46", fontSize: "13px", marginBottom: "12px", lineHeight: 1.5 }}>{authNotice}</div>}

{/* Google OAuth — available everywhere except after a notice has been shown */}
{!authNotice && (authMode === "signin" || authMode === "signup" || authMode === "magic-link") && (
<>
<button
type="button"
disabled={authSubmitting}
onClick={async () => {
setAuthError(null);
setAuthSubmitting(true);
const { error } = await signInWithGoogle();
if (error) { setAuthError(error); setAuthSubmitting(false); }
// On success, signInWithOAuth navigates the page; no further action.
}}
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 }}
>
<svg width="16" height="16" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"/>
<path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"/>
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238C29.211 35.091 26.715 36 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"/>
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571.001-.001.002-.001.003-.002l6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"/>
</svg>
Continue with Google
</button>
<div style={{ display: "flex", alignItems: "center", gap: "10px", margin: "0 0 16px", color: "#9ca3af", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
<div style={{ flex: 1, height: "1px", background: "#e5e7eb" }} />
<span>or</span>
<div style={{ flex: 1, height: "1px", background: "#e5e7eb" }} />
</div>
</>
)}

{/* Email + password (signin/signup) */}
{!authNotice && (authMode === "signin" || authMode === "signup") && (
<form onSubmit={async (e) => {
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(""); }
}}>
<input type="email" placeholder="Email" value={authEmail} onChange={(e) => setAuthEmail(e.target.value)} required style={{ width: "100%", padding: "10px 12px", fontSize: "14px", border: "1px solid #e5e7eb", marginBottom: "10px", fontFamily: "inherit", boxSizing: "border-box" }} />
<input type="password" placeholder="Password" value={authPassword} onChange={(e) => 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" && (
<div style={{ textAlign: "right", marginBottom: "12px" }}>
<button type="button" onClick={() => { setAuthMode("reset"); setAuthError(null); setAuthPassword(""); }} style={{ color: THEME.primary, background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: "12px", padding: 0 }}>
Forgot password?
</button>
</div>
)}
<button type="submit" disabled={authSubmitting} style={{ width: "100%", padding: "10px", fontSize: "14px", background: THEME.primaryGradient, color: "#fff", border: "none", cursor: authSubmitting ? "not-allowed" : "pointer", fontFamily: "inherit", opacity: authSubmitting ? 0.7 : 1 }}>
{authSubmitting ? "..." : authMode === "signin" ? "Sign in" : "Create account"}
</button>
</form>
)}

{/* Magic-link form */}
{!authNotice && authMode === "magic-link" && (
<form onSubmit={async (e) => {
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.`);
}}>
<input type="email" placeholder="Email" value={authEmail} onChange={(e) => 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" }} />
<button type="submit" disabled={authSubmitting} style={{ width: "100%", padding: "10px", fontSize: "14px", background: THEME.primaryGradient, color: "#fff", border: "none", cursor: authSubmitting ? "not-allowed" : "pointer", fontFamily: "inherit", opacity: authSubmitting ? 0.7 : 1 }}>
{authSubmitting ? "..." : "Email me a sign-in link"}
</button>
</form>
)}

{/* Password reset form */}
{!authNotice && authMode === "reset" && (
<form onSubmit={async (e) => {
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.`);
}}>
<input type="email" placeholder="Email" value={authEmail} onChange={(e) => 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" }} />
<button type="submit" disabled={authSubmitting} style={{ width: "100%", padding: "10px", fontSize: "14px", background: THEME.primaryGradient, color: "#fff", border: "none", cursor: authSubmitting ? "not-allowed" : "pointer", fontFamily: "inherit", opacity: authSubmitting ? 0.7 : 1 }}>
{authSubmitting ? "..." : "Send reset link"}
</button>
</form>
)}

{/* Bottom switcher */}
<div style={{ marginTop: "16px", textAlign: "center", fontSize: "13px", color: "#6b7280", lineHeight: 1.7 }}>
{authNotice ? (
<button onClick={() => { setShowAuth(false); setAuthNotice(null); setAuthError(null); setAuthEmail(""); setAuthPassword(""); setAuthMode("signin"); }} style={{ color: THEME.primary, background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: "13px" }}>Done</button>
) : authMode === "signin" ? (
<>
<div>No account? <button onClick={() => { setAuthMode("signup"); setAuthError(null); }} style={{ color: THEME.primary, background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: "13px" }}>Create one</button></div>
<div>Or <button onClick={() => { setAuthMode("magic-link"); setAuthError(null); setAuthPassword(""); }} style={{ color: THEME.primary, background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: "13px" }}>sign in with a magic link</button></div>
</>
) : authMode === "signup" ? (
<>Have an account? <button onClick={() => { setAuthMode("signin"); setAuthError(null); }} style={{ color: THEME.primary, background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: "13px" }}>Sign in</button></>
) : (
/* magic-link or reset */
<button onClick={() => { setAuthMode("signin"); setAuthError(null); }} style={{ color: THEME.primary, background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: "13px" }}>Back to sign in</button>
)}
</div>
</div>
Expand Down
126 changes: 126 additions & 0 deletions frontend/src/app/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div style={{ minHeight: "100vh", background: "#fafaf9", display: "flex", alignItems: "center", justifyContent: "center", padding: "20px" }}>
<div style={{ background: "#fff", padding: "32px", width: "360px", maxWidth: "92vw", border: "1px solid #e5e7eb" }}>
<h2 style={{ margin: "0 0 8px", fontSize: "18px", fontWeight: 600, color: "#1c1a17" }}>Set a new password</h2>
<p style={{ margin: "0 0 20px", fontSize: "13px", color: "#6b7280", lineHeight: 1.5 }}>
You'll be signed in once the new password is saved.
</p>

{(loading || (!user && !hashGracePassed)) && (
<div style={{ fontSize: "13px", color: "#6b7280" }}>Verifying reset link…</div>
)}

{linkInvalid && (
<>
<div style={{ padding: "10px 12px", background: "#fef2f2", color: "#b91c1c", fontSize: "13px", marginBottom: "12px" }}>
This reset link is invalid or has expired.
</div>
<button
type="button"
onClick={() => router.push("/")}
style={{ width: "100%", padding: "10px", fontSize: "14px", background: THEME.primaryGradient, color: "#fff", border: "none", cursor: "pointer", fontFamily: "inherit" }}
>
Back to sign in
</button>
</>
)}

{ready && !done && (
<>
{error && <div style={{ padding: "8px 12px", background: "#fef2f2", color: "#b91c1c", fontSize: "13px", marginBottom: "16px" }}>{error}</div>}
<form onSubmit={onSubmit}>
<input
type="password"
placeholder="New password"
value={password}
onChange={(e) => 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" }}
/>
<input
type="password"
placeholder="Confirm new password"
value={confirm}
onChange={(e) => 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" }}
/>
<button
type="submit"
disabled={submitting}
style={{ width: "100%", padding: "10px", fontSize: "14px", background: THEME.primaryGradient, color: "#fff", border: "none", cursor: submitting ? "not-allowed" : "pointer", fontFamily: "inherit", opacity: submitting ? 0.7 : 1 }}
>
{submitting ? "Saving…" : "Save new password"}
</button>
</form>
</>
)}

{done && (
<div style={{ padding: "10px 12px", background: "#ecfdf5", color: "#065f46", fontSize: "13px" }}>
Password updated. Redirecting…
</div>
)}
</div>
</div>
);
}
Loading