From 8f52b1d80c2a1d56ef37e23483ad49de1ca9cc11 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 29 May 2026 12:28:08 +0800 Subject: [PATCH] Add password reset flow --- frontend/app/auth/reset-password/page.tsx | 25 ++ frontend/components/auth/LoginClient.tsx | 2 +- .../components/auth/ResetPasswordClient.tsx | 227 ++++++++++++++++++ .../auth/__tests__/passwordReset.test.ts | 58 +++++ 4 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 frontend/app/auth/reset-password/page.tsx create mode 100644 frontend/components/auth/ResetPasswordClient.tsx create mode 100644 frontend/components/auth/__tests__/passwordReset.test.ts diff --git a/frontend/app/auth/reset-password/page.tsx b/frontend/app/auth/reset-password/page.tsx new file mode 100644 index 00000000..55a60340 --- /dev/null +++ b/frontend/app/auth/reset-password/page.tsx @@ -0,0 +1,25 @@ +import { ResetPasswordClient } from "@/components/auth/ResetPasswordClient"; +import { I18nProvider } from "@/hooks/useI18n"; + +type PageProps = { + searchParams?: Promise<{ next?: string }>; +}; + +function normalizeNextPath(input: string | undefined) { + const fallback = "/account"; + const raw = String(input || "").trim(); + if (!raw) return fallback; + if (!raw.startsWith("/")) return fallback; + if (raw.startsWith("//")) return fallback; + return raw; +} + +export default async function ResetPasswordPage({ searchParams }: PageProps) { + const params = (await searchParams) || {}; + const nextPath = normalizeNextPath(params.next); + return ( + + + + ); +} diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx index 055def5c..4928ffe1 100644 --- a/frontend/components/auth/LoginClient.tsx +++ b/frontend/components/auth/LoginClient.tsx @@ -126,7 +126,7 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) { const supabase = getSupabaseBrowserClient(); const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent( - "/account", + `/auth/reset-password?next=${encodeURIComponent(nextPath || "/account")}`, )}`, }); if (error) { diff --git a/frontend/components/auth/ResetPasswordClient.tsx b/frontend/components/auth/ResetPasswordClient.tsx new file mode 100644 index 00000000..b9802a2d --- /dev/null +++ b/frontend/components/auth/ResetPasswordClient.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Lock } from "lucide-react"; +import { + getSupabaseBrowserClient, + hasSupabasePublicEnv, +} from "@/lib/supabase/client"; +import { useI18n } from "@/hooks/useI18n"; + +type ResetPasswordClientProps = { + nextPath: string; +}; + +export function ResetPasswordClient({ nextPath }: ResetPasswordClientProps) { + const router = useRouter(); + const { locale } = useI18n(); + const isEn = locale === "en-US"; + const supabaseReady = hasSupabasePublicEnv(); + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [checkingSession, setCheckingSession] = useState(true); + const [hasSession, setHasSession] = useState(false); + const [loading, setLoading] = useState(false); + const [errorText, setErrorText] = useState(""); + const [infoText, setInfoText] = useState(""); + + const copy = { + title: isEn ? "Set a new password" : "设置新密码", + subtitle: isEn + ? "Create a password for your PolyWeather account." + : "为您的 PolyWeather 账户创建一个新密码。", + password: isEn ? "New password" : "新密码", + confirmPassword: isEn ? "Confirm password" : "确认密码", + passwordPlaceholder: isEn ? "At least 6 characters" : "至少 6 位字符", + confirmPlaceholder: isEn ? "Enter it again" : "再次输入新密码", + submit: isEn ? "Update password" : "更新密码", + checking: isEn ? "Checking reset link..." : "正在检查重置链接...", + expired: isEn + ? "This reset link is expired or invalid. Please request a new password reset email." + : "重置链接已过期或无效,请重新发送密码重置邮件。", + supabaseMissing: isEn + ? "Supabase is not configured. Password reset is unavailable." + : "Supabase 未配置,无法重置密码。", + passwordTooShort: isEn + ? "Password must be at least 6 characters." + : "密码至少需要 6 位字符。", + passwordMismatch: isEn + ? "The two passwords do not match." + : "两次输入的密码不一致。", + success: isEn + ? "Password updated. Redirecting..." + : "密码已更新,正在跳转...", + backLogin: isEn ? "Back to sign in" : "返回登录", + } as const; + + useEffect(() => { + if (!supabaseReady) { + setErrorText(copy.supabaseMissing); + setCheckingSession(false); + return; + } + + const run = async () => { + const supabase = getSupabaseBrowserClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + const hasRecoverySession = Boolean(session?.user); + setHasSession(hasRecoverySession); + if (!hasRecoverySession) { + setErrorText(copy.expired); + } + setCheckingSession(false); + }; + + void run(); + }, [copy.expired, copy.supabaseMissing, supabaseReady]); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setErrorText(""); + setInfoText(""); + + if (!hasSession) { + setErrorText(copy.expired); + return; + } + if (password.length < 6) { + setErrorText(copy.passwordTooShort); + return; + } + if (password !== confirmPassword) { + setErrorText(copy.passwordMismatch); + return; + } + + setLoading(true); + try { + const supabase = getSupabaseBrowserClient(); + const { error } = await supabase.auth.updateUser({ password }); + if (error) { + setErrorText(error.message); + return; + } + setInfoText(copy.success); + window.setTimeout(() => router.replace(nextPath), 700); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + PolyWeather + + +
+

{copy.title}

+

{copy.subtitle}

+
+ + {checkingSession ? ( +
+ {copy.checking} +
+ ) : ( +
void onSubmit(event)} className="space-y-5"> +
+ +
+ + setPassword(event.target.value)} + placeholder={copy.passwordPlaceholder} + className="w-full rounded-xl border border-slate-200 bg-slate-50/70 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60" + /> + +
+
+ +
+ +
+ + setConfirmPassword(event.target.value)} + placeholder={copy.confirmPlaceholder} + className="w-full rounded-xl border border-slate-200 bg-slate-50/70 py-3 pl-11 pr-11 text-sm text-slate-900 placeholder:text-slate-400 transition-all focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 disabled:cursor-not-allowed disabled:opacity-60" + /> + +
+
+ + +
+ )} + + {errorText ? ( +
+ + {errorText} +
+ ) : null} + {infoText ? ( +
+ + {infoText} +
+ ) : null} + + + {copy.backLogin} + +
+
+ ); +} diff --git a/frontend/components/auth/__tests__/passwordReset.test.ts b/frontend/components/auth/__tests__/passwordReset.test.ts new file mode 100644 index 00000000..ff7d0c73 --- /dev/null +++ b/frontend/components/auth/__tests__/passwordReset.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const loginClientPath = path.join( + projectRoot, + "components", + "auth", + "LoginClient.tsx", + ); + const resetPagePath = path.join( + projectRoot, + "app", + "auth", + "reset-password", + "page.tsx", + ); + const resetClientPath = path.join( + projectRoot, + "components", + "auth", + "ResetPasswordClient.tsx", + ); + + const loginClientSource = fs.readFileSync(loginClientPath, "utf8"); + + assert( + loginClientSource.includes("/auth/reset-password") && + loginClientSource.indexOf("resetPasswordForEmail") < + loginClientSource.indexOf("/auth/reset-password"), + "forgot-password emails must land on a password reset page after auth callback", + ); + assert( + fs.existsSync(resetPagePath), + "password reset page must exist at app/auth/reset-password/page.tsx", + ); + assert( + fs.existsSync(resetClientPath), + "password reset page must use a dedicated client component", + ); + + const resetClientSource = fs.readFileSync(resetClientPath, "utf8"); + assert( + resetClientSource.includes("supabase.auth.updateUser") && + resetClientSource.includes("password"), + "password reset client must update the authenticated user's password", + ); + assert( + resetClientSource.includes("getSession") && + resetClientSource.includes("expired"), + "password reset client must detect an expired or invalid recovery session", + ); +}