未登录态下的密码重置页(TAPD #1003252),从 EmailAuthPage 登录表单 Continue 按钮下方的 "Forgot password?" 链接进入。三步收在同一个 ForgotPasswordPage widget 内部切换(_Step 枚举:email → code → password),不像注册走独立的 EmailAuthPage → EmailCodePage 两个路由。底层是 GoTrue 的 recovery 流程:resetPasswordForEmail → verifyOTP(recovery) → updateUser——recovery 验证码通过的那一刻就已经在 GoTrue 侧铸造出一个真实 session,所以走完第三步改密码后用户是已登录状态,不需要再手动登录一次。
lib/features/auth/presentation/forgot_password_page.dart(367 行)authControllerProvider(AuthController.startPasswordReset / resendPasswordReset / verifyPasswordResetCode / applyNewPassword)PasswordPolicy.validate(lib/features/auth/domain/password_policy.dart)、EmailCodeThrottle.remaining(重发倒计时,与 EmailCodePage 共用同一套节流公式)SupabaseAuthService(lib/features/auth/application/supabase_auth_service.dart)——startPasswordReset / resendPasswordReset / verifyPasswordResetCode / applyNewPassword 四个方法,均建立在私有的 _pendingReset(_PendingReset 记录 email / sendsMade / lastSentAt / verified)之上;applyNewPassword 会先检查 _pendingReset.verified == true 才放行 updateUser 调用,防止跳过第二步直接改密码/forgot-password(ForgotPasswordPage.routeName)lib/app.dart 的 onGenerateRoute——arguments 是一个 Dart record (String? initialEmail, String? deviceId)?,不是单个字符串EmailAuthPage 登录表单,Navigator.pushNamed(context, ForgotPasswordPage.routeName, arguments: (_email.text.trim(), widget.deviceId))——把用户已经输入过的邮箱预填过去,deviceId 原样透传(WiFi 配网漏斗中途触发时不丢上下文;账号入口独立触发时为 null)外壳复用与其它认证页相同的 AuthScaffold(标题/副标题 + 内容区 + PolicyFooter),标题/副标题按当前步骤切换:
authForgotTitle / authForgotSubtitleauthCodeTitle / authCodeSubtitle(sentTo)——与注册流程的验证码页共用同一组文案 keyaccountChangePwTitle / authResetPwSubtitle(sentTo)三步内容:
initialEmail 预填)+ "Continue" 按钮letterSpacing: 10,仅接受数字,maxLength: 6)、重发链接(冷却期内显示倒计时秒数,冷却结束后可点击)、重发节流规则提示文字;若当前是 stub/dev 模式(state.debugCode != null),验证码上方会额外出现一个蓝色提示框直接显示 6 位码,方便无邮箱环境下测试@),真正的账号存在性判断留给服务端;校验失败时在邮箱输入框下方显示 authInvalidEmail(_emailError),不发网络请求、不弹 SnackBar_submitEmail()
→ authControllerProvider.notifier.startPasswordReset(email: email)
→ SupabaseAuthService.startPasswordReset() → GoTrue resetPasswordForEmail(email)
(出于隐私考虑,未注册邮箱也回 200,不会向前端泄露"这个邮箱有没有注册"的信号)
→ 成功 → step = code,sendsMade 重置为 1,启动倒计时
EmailCodePage 注册流程共用同一套 EmailCodeThrottle 重发节流阶梯_resend() → authControllerProvider.notifier.resendPasswordReset() → 内部复用同一个 _pendingReset.email 再次调用 resetPasswordForEmail,sendsMade 自增、重发计时器重置_verify()
→ authControllerProvider.notifier.verifyPasswordResetCode(code)
→ SupabaseAuthService.verifyPasswordResetCode()
→ GoTrue verifyOTP(email, token: code, type: OtpType.recovery)
→ 验证通过即铸造一个真实 session(此时用户在 GoTrue 侧已经"登录",只是 UI 上还没走完改密码步骤)
→ _pendingReset = _pendingReset.copyWith(verified: true)
→ 成功 → step = password
_map() 统一异常映射为 AuthError.codeIncorrect,由页面顶部 ref.listen(authControllerProvider) 弹 SnackBar 提示,step 不变,用户可重试或重发PasswordPolicy.validate(8–12 位,需同时含大写、小写、数字、符号各至少一个——TAPD #1003178,Liang 2026-06-08 从"必须正好 8 位"放宽为区间),再校验两次输入一致_submitPassword()
→ PasswordPolicy.validate(password) 不通过 → 显示对应错误文案,不发请求
→ 两次密码不一致 → 显示 authPwMismatch,不发请求
→ authControllerProvider.notifier.applyNewPassword(password)
→ SupabaseAuthService.applyNewPassword()
→ 检查 _pendingReset.verified == true(未过第 2 步会直接抛异常,防跳步)
→ GoTrue updateUser(password: ...)——写在第 3 步铸造的 session 上
→ 成功 → SnackBar 提示密码已修改
→ finishAuthFunnel(context, deviceId)——弹出整个认证路由栈;若 deviceId 非空则继续 WiFi 配网漏斗,
否则直接回到发起登录的入口(如 Settings 的 account-only 入口)
authControllerProvider 的 status 翻到 signedIn;用户全程未输入原密码,也未经历一次独立的"登录"动作same_password,经 _map() 映射为 AuthError.samePassword,由顶部 listener 弹 SnackBar(authErrSamePassword),用户需换一个不同的密码重试watch authControllerProvider 读取 state.busy(驱动按钮的 loading 态)与 state.debugCode(stub 模式下的调试码提示框);ref.listen(authControllerProvider) 监听 state.error 变化统一弹 SnackBar,弹完立即 clearError(),避免同一个错误在 rebuild 时重复弹出。_step 是纯本地 setState 状态,不经 Provider;重发倒计时靠一个本地 Timer.periodic(_timer),页面 dispose() 时取消。
无——纯 GoTrue REST 调用,无平台分支。
_pendingReset 是纯内存态,不落盘:如果用户在验证码通过(第 2 步完成)之后、提交新密码(第 3 步)之前把 App 杀掉或长时间挂后台导致进程被回收,SupabaseAuthService 实例重建后 _pendingReset 会丢失,applyNewPassword 的 verified 检查会失败,用户需要从第 1 步重新走一遍。这与本 App 其它未完成流程(如注册验证码)的行为一致——都不做跨进程重启的断点续传。@,真正的邮箱有效性完全交给服务端判断;未注册邮箱也会走完"验证码已发送"的 UI 流程(GoTrue 隐私设计使然),用户体验上不会提前告知"这个邮箱没有账号"。