11/**
2- * @ai -context: 页面组件:ResetPassword。
2+ * @ai -context: 页面组件:ResetPassword。重置密码采用邮箱验证码(OTP)流程:
3+ * 请求重置 → 邮件收 6 位验证码 → 应用内输入验证码+新密码。
4+ * Why: 桌面端邮件链接在系统浏览器打开、恢复会话无法回到应用(file:// 协议
5+ * 也无法作为 redirectTo),链接回跳模式结构性不可用,故全程留在应用内。
6+ * 依赖 Supabase Recovery 邮件模板包含 {{ .Token }} 验证码。
37 */
48import { useState , useEffect , useRef , type FormEvent } from 'react' ;
5- import { Link , useSearchParams , useNavigate } from 'react-router-dom' ;
6- import { Mail , Lock , AlertCircle , CheckCircle2 , ArrowLeft } from 'lucide-react' ;
9+ import { Link , useNavigate } from 'react-router-dom' ;
10+ import { Mail , Lock , KeyRound , AlertCircle , CheckCircle2 , ArrowLeft } from 'lucide-react' ;
711import { Button , Input , Card } from '@/components/ui' ;
812import { cn } from '@/lib/utils' ;
913import { supabase } from '@/lib/auth/supabaseClient' ;
1014
1115type ViewMode = 'request' | 'reset' ;
1216
1317export default function ResetPassword ( ) {
14- const [ searchParams ] = useSearchParams ( ) ;
1518 const navigate = useNavigate ( ) ;
1619
17- const tokenHash = searchParams . get ( 'token_hash' ) ;
18- const type = searchParams . get ( 'type' ) ;
19- const isResetMode = tokenHash && type === 'recovery' ;
20-
21- const [ viewMode , setViewMode ] = useState < ViewMode > ( isResetMode ? 'reset' : 'request' ) ;
20+ const [ viewMode , setViewMode ] = useState < ViewMode > ( 'request' ) ;
2221 const [ email , setEmail ] = useState ( '' ) ;
22+ const [ otpCode , setOtpCode ] = useState ( '' ) ;
2323 const [ password , setPassword ] = useState ( '' ) ;
2424 const [ confirmPassword , setConfirmPassword ] = useState ( '' ) ;
2525 const [ error , setError ] = useState < string | null > ( null ) ;
2626 const [ success , setSuccess ] = useState ( false ) ;
2727 const [ loading , setLoading ] = useState ( false ) ;
2828 const navigateTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
2929
30- useEffect ( ( ) => {
31- if ( isResetMode ) setViewMode ( 'reset' ) ;
32- } , [ isResetMode ] ) ;
33-
3430 useEffect ( ( ) => {
3531 return ( ) => {
3632 if ( navigateTimerRef . current ) clearTimeout ( navigateTimerRef . current ) ;
@@ -48,12 +44,10 @@ export default function ResetPassword() {
4844
4945 setLoading ( true ) ;
5046 try {
51- const redirectTo = `${ window . location . origin } ${ window . location . pathname } #/reset-password` ;
52- const { error : resetError } = await supabase . auth . resetPasswordForEmail ( email . trim ( ) , {
53- redirectTo,
54- } ) ;
47+ // 不传 redirectTo:验证走应用内 OTP 验证码,不依赖邮件链接跳转
48+ const { error : resetError } = await supabase . auth . resetPasswordForEmail ( email . trim ( ) ) ;
5549 if ( resetError ) throw resetError ;
56- setSuccess ( true ) ;
50+ setViewMode ( 'reset' ) ;
5751 } catch ( err : unknown ) {
5852 const msg = err instanceof Error ? err . message : '发送失败,请稍后重试' ;
5953 setError ( msg ) ;
@@ -66,6 +60,10 @@ export default function ResetPassword() {
6660 e . preventDefault ( ) ;
6761 setError ( null ) ;
6862
63+ if ( ! otpCode . trim ( ) || otpCode . trim ( ) . length < 6 ) {
64+ setError ( '请输入邮件中的 6 位验证码' ) ;
65+ return ;
66+ }
6967 if ( ! password || password . length < 8 ) {
7068 setError ( '密码长度至少 8 位' ) ;
7169 return ;
@@ -77,6 +75,13 @@ export default function ResetPassword() {
7775
7876 setLoading ( true ) ;
7977 try {
78+ // 先用验证码换取恢复会话,再更新密码
79+ const { error : otpError } = await supabase . auth . verifyOtp ( {
80+ email : email . trim ( ) ,
81+ token : otpCode . trim ( ) ,
82+ type : 'recovery' ,
83+ } ) ;
84+ if ( otpError ) throw otpError ;
8085 const { error : updateError } = await supabase . auth . updateUser ( { password } ) ;
8186 if ( updateError ) throw updateError ;
8287 setSuccess ( true ) ;
@@ -107,7 +112,9 @@ export default function ResetPassword() {
107112 { viewMode === 'request' ? '重置密码' : '设置新密码' }
108113 </ h1 >
109114 < p className = "text-b2 text-text-tertiary mt-1" >
110- { viewMode === 'request' ? '输入邮箱以接收重置链接' : '输入你的新密码' }
115+ { viewMode === 'request'
116+ ? '输入邮箱以接收验证码'
117+ : `验证码已发送至 ${ email } ` }
111118 </ p >
112119 </ div >
113120 </ div >
@@ -117,41 +124,25 @@ export default function ResetPassword() {
117124 { success ? (
118125 < div className = "flex flex-col items-center gap-4 py-2" >
119126 < CheckCircle2 className = "w-12 h-12 text-emerald-500" strokeWidth = { 1.5 } />
120- < p className = "text-b1 font-medium text-text-primary text-center" >
121- { viewMode === 'request'
122- ? '重置邮件已发送'
123- : '密码重置成功' }
124- </ p >
125- < p className = "text-b2 text-text-tertiary text-center" >
126- { viewMode === 'request'
127- ? `如果 ${ email } 已注册,你将收到一封重置密码的邮件`
128- : '即将跳转到登录页...' }
129- </ p >
130- { viewMode === 'request' && (
131- < Link
132- to = "/login"
133- className = "text-b2 text-brand-600 font-medium hover:text-brand-700 transition-colors mt-2"
134- >
135- 返回登录
136- </ Link >
137- ) }
127+ < p className = "text-b1 font-medium text-text-primary text-center" > 密码重置成功</ p >
128+ < p className = "text-b2 text-text-tertiary text-center" > 即将跳转到登录页...</ p >
138129 </ div >
139130 ) : (
140131 < >
141- { viewMode === 'request' ? (
142- < form onSubmit = { handleRequestReset } className = "flex flex-col gap-kb-md" >
143- { error && (
144- < div
145- className = { cn (
146- 'flex items-start gap-2 px-3 py-2.5 rounded-kb-md' ,
147- 'bg-[#F43F5E]/10 border border-[#F43F5E]/30' ,
148- ) }
149- >
150- < AlertCircle className = "w-icon-sm h-icon-sm text-[#F43F5E] flex-shrink-0 mt-0.5" strokeWidth = { 1.5 } />
151- < p className = "text-b3 text-[#F43F5E]" > { error } </ p >
152- </ div >
132+ { error && (
133+ < div
134+ className = { cn (
135+ 'flex items-start gap-2 px-3 py-2.5 rounded-kb-md mb-4' ,
136+ 'bg-[#F43F5E]/10 border border-[#F43F5E]/30' ,
153137 ) }
138+ >
139+ < AlertCircle className = "w-icon-sm h-icon-sm text-[#F43F5E] flex-shrink-0 mt-0.5" strokeWidth = { 1.5 } />
140+ < p className = "text-b3 text-[#F43F5E]" > { error } </ p >
141+ </ div >
142+ ) }
154143
144+ { viewMode === 'request' ? (
145+ < form onSubmit = { handleRequestReset } className = "flex flex-col gap-kb-md" >
155146 < Input
156147 label = "注册邮箱"
157148 type = "email"
@@ -163,22 +154,21 @@ export default function ResetPassword() {
163154 />
164155
165156 < Button type = "submit" variant = "primary" size = "lg" loading = { loading } className = "w-full mt-1" >
166- 发送重置链接
157+ 发送验证码
167158 </ Button >
168159 </ form >
169160 ) : (
170161 < form onSubmit = { handleResetPassword } className = "flex flex-col gap-kb-md" >
171- { error && (
172- < div
173- className = { cn (
174- 'flex items-start gap-2 px-3 py-2.5 rounded-kb-md' ,
175- 'bg-[#F43F5E]/10 border border-[#F43F5E]/30' ,
176- ) }
177- >
178- < AlertCircle className = "w-icon-sm h-icon-sm text-[#F43F5E] flex-shrink-0 mt-0.5" strokeWidth = { 1.5 } />
179- < p className = "text-b3 text-[#F43F5E]" > { error } </ p >
180- </ div >
181- ) }
162+ < Input
163+ label = "邮箱验证码"
164+ type = "text"
165+ inputMode = "numeric"
166+ placeholder = "邮件中的 6 位验证码"
167+ autoComplete = "one-time-code"
168+ prefix = { < KeyRound className = "w-icon-sm h-icon-sm" strokeWidth = { 1.5 } /> }
169+ value = { otpCode }
170+ onChange = { ( e ) => setOtpCode ( e . target . value ) }
171+ />
182172
183173 < Input
184174 label = "新密码"
@@ -203,6 +193,14 @@ export default function ResetPassword() {
203193 < Button type = "submit" variant = "primary" size = "lg" loading = { loading } className = "w-full mt-1" >
204194 重置密码
205195 </ Button >
196+
197+ < button
198+ type = "button"
199+ onClick = { ( ) => { setViewMode ( 'request' ) ; setError ( null ) ; } }
200+ className = "text-b3 text-text-tertiary hover:text-text-secondary transition-colors text-center"
201+ >
202+ 没收到验证码?返回重新发送
203+ </ button >
206204 </ form >
207205 ) }
208206 </ >
0 commit comments