From 200fb1642ff01c840ac7d957f68d1f73c7f4da3c Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 16 Jun 2026 17:29:58 +0800 Subject: [PATCH] fix(auth): surface validation errors in email login/register forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation failure was previously silent — the form's submit() returned without dispatching or showing anything on screen, so users could not tell why the login/register button appeared to do nothing. Changes: - Add validationError local state in EmailLoginForm and EmailRegisterForm - Display first validation error via AuthErrorMessage, prioritised over the server's globalError (validation errors feel more immediate) - Clear validation error on field change (standard "error fades as you fix it" UX) — implemented as onChange handlers, not useEffect, to avoid React's cascading-render anti-pattern - Add observability to the entire email flow (was previously silent end to end): form submit ENTRY, validator rejection, actor ENTRY, actor DONE — mirrors the logging style already used by guestLoginActor - Add entry: assign({ errorMessage: null }) to loadingEmailLogin and loadingEmailRegister so stale errors clear on transition into the loading state (matches loadingGuestLogin) --- src/app/auth/components/email-login-form.tsx | 48 ++++++++++---- .../auth/components/email-register-form.tsx | 64 +++++++++++++++++-- src/models/auth/login-status.ts | 9 +-- src/stores/auth/auth-actors.ts | 37 +++++++++++ src/stores/auth/auth-machine.ts | 12 ++-- 5 files changed, 138 insertions(+), 32 deletions(-) diff --git a/src/app/auth/components/email-login-form.tsx b/src/app/auth/components/email-login-form.tsx index f0d067f7..094e58f5 100644 --- a/src/app/auth/components/email-login-form.tsx +++ b/src/app/auth/components/email-login-form.tsx @@ -1,15 +1,6 @@ "use client"; /** * 邮箱登录表单 - * - * 原始 Dart: lib/ui/auth/widgets/email_login_form.dart - * - * 视觉规格(与 Dart 对齐): - * - 字段无 label(仅 placeholder) - * - 错误仅以红色横幅显示(AuthErrorMessage),无字段级错误 - * - 不显示 "Forgot password?" 链接 - * - 切换链接由父级 AuthEmailPanel 渲染 - * - 提交派发 `AuthEmailLoginSubmitted` */ import { useState } from "react"; @@ -36,11 +27,41 @@ export function EmailLoginForm({ const dispatch = useAuthDispatch(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + // 本地校验错误(client-side)—— 校验失败时展示第一个非 null 的错误 + // 优先级:local validationError > server globalError + const [validationError, setValidationError] = useState(null); + + // 用户在任一字段开始输入 → 清掉旧校验错误(标准 UX:边改边消) + // 直接在 onChange 调 setState —— React useState 对相同值自动 bail-out 不触发额外渲染 + // 避免 useEffect 内 setState 的"级联渲染"反模式 + const onEmailChange = (v: string) => { + setEmail(v); + setValidationError(null); + }; + const onPasswordChange = (v: string) => { + setPassword(v); + setValidationError(null); + }; const submit = () => { + console.log("[email-login-form] submit ENTRY", { + emailLength: email.length, + emailPreview: email.slice(0, 8), + passwordLength: password.length, + }); const emailErr = validateEmail(email); const passwordErr = validatePassword(password); - if (emailErr || passwordErr) return; + if (emailErr || passwordErr) { + const firstErr = emailErr ?? passwordErr ?? null; + console.warn( + "[email-login-form] submit VALIDATION FAILED (silent return)", + { emailErr, passwordErr, surfaced: firstErr }, + ); + setValidationError(firstErr); + return; + } + setValidationError(null); + console.log("[email-login-form] submit dispatching AuthEmailLoginSubmitted"); dispatch({ type: "AuthEmailLoginSubmitted", email, password }); }; @@ -55,19 +76,20 @@ export function EmailLoginForm({ - + {/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError) */} + Login diff --git a/src/app/auth/components/email-register-form.tsx b/src/app/auth/components/email-register-form.tsx index a47902f7..9a7c8c04 100644 --- a/src/app/auth/components/email-register-form.tsx +++ b/src/app/auth/components/email-register-form.tsx @@ -40,13 +40,64 @@ export function EmailRegisterForm({ const [password, setPassword] = useState(""); const [username, setUsername] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + // 本地校验错误(client-side)—— 校验失败时展示第一个非 null 的错误 + // 优先级:local validationError > server globalError + const [validationError, setValidationError] = useState(null); + + // 用户在任一字段开始输入 → 清掉旧校验错误(标准 UX:边改边消) + // 直接在 onChange 调 setState —— React useState 对相同值自动 bail-out 不触发额外渲染 + // 避免 useEffect 内 setState 的"级联渲染"反模式 + const clearValidationOnType = () => setValidationError(null); + const onUsernameChange = (v: string) => { + setUsername(v); + clearValidationOnType(); + }; + const onEmailChange = (v: string) => { + setEmail(v); + clearValidationOnType(); + }; + const onPasswordChange = (v: string) => { + setPassword(v); + clearValidationOnType(); + }; + const onConfirmPasswordChange = (v: string) => { + setConfirmPassword(v); + clearValidationOnType(); + }; const submit = () => { + console.log("[email-register-form] submit ENTRY", { + emailLength: email.length, + emailPreview: email.slice(0, 8), + passwordLength: password.length, + usernameLength: username.length, + confirmPasswordLength: confirmPassword.length, + }); const emailErr = validateEmail(email); const passwordErr = validatePassword(password); const usernameErr = validateUsername(username); const confirmErr = validateConfirmPassword(password, confirmPassword); - if (emailErr || passwordErr || usernameErr || confirmErr) return; + if (emailErr || passwordErr || usernameErr || confirmErr) { + // 按 username → email → password → confirm 顺序挑第一个错(与字段顺序一致) + const firstErr = + usernameErr ?? emailErr ?? passwordErr ?? confirmErr ?? null; + console.warn( + "[email-register-form] submit VALIDATION FAILED (silent return)", + { + emailErr, + passwordErr, + usernameErr, + confirmErr, + surfaced: firstErr, + }, + ); + setValidationError(firstErr); + return; + } + setValidationError(null); + console.log( + "[email-register-form] submit dispatching AuthEmailRegisterSubmitted", + ); dispatch({ type: "AuthEmailRegisterSubmitted", email, @@ -66,33 +117,34 @@ export function EmailRegisterForm({ > - + {/* 优先级:本地校验错误 > 后端 globalError */} + Register diff --git a/src/models/auth/login-status.ts b/src/models/auth/login-status.ts index d00848bc..28e55524 100644 --- a/src/models/auth/login-status.ts +++ b/src/models/auth/login-status.ts @@ -1,17 +1,10 @@ /** * 登录状态枚举 - * - * 原始 Dart: lib/ui/auth/bloc/auth_state.dart 的 `LoginType` 枚举(v7.0 新增)。 - * - * 2025-XX 改造:语义从"登录方式"扩展为"当前登录状态"—— - * 包含 notLoggedIn / guest / 各 OAuth provider 登录后态。 - * - * 字段命名(state)从 `loginType` 改为 `loginStatus`,更贴合语义。 */ export const LoginStatus = { /** 未登录(默认初值) */ NotLoggedIn: "notLoggedIn", - /** 游客登录(无需账号,10 轮免费消息) */ + /** 游客登录 */ Guest: "guest", /** Facebook OAuth 登录 */ Facebook: "facebook", diff --git a/src/stores/auth/auth-actors.ts b/src/stores/auth/auth-actors.ts index 74e5a0c8..a249a1bf 100644 --- a/src/stores/auth/auth-actors.ts +++ b/src/stores/auth/auth-actors.ts @@ -25,8 +25,22 @@ const authRepo: IAuthRepository = authRepository; export const emailLoginActor = fromPromise( async ({ input }) => { + console.log("[auth-machine] emailLoginActor ENTRY", { + emailLength: input.email.length, + emailPreview: input.email.slice(0, 8), + passwordLength: input.password.length, + }); const guestId = await readGuestId(); + console.log("[auth-machine] emailLoginActor calling authRepo.emailLogin", { + hasGuestId: !!guestId, + }); const result = await authRepo.emailLogin({ ...input, guestId }); + console.log("[auth-machine] emailLoginActor authRepo.emailLogin DONE", { + success: result.success, + hasData: result.success ? !!result.data : null, + errorName: result.success ? null : result.error?.name, + errorMessage: result.success ? null : result.error?.message, + }); if (Result.isErr(result)) throw result.error; return "email" as LoginStatus; }, @@ -36,17 +50,40 @@ export const emailRegisterThenLoginActor = fromPromise< LoginStatus, { email: string; password: string; username: string; confirmPassword: string } >(async ({ input }) => { + console.log("[auth-machine] emailRegisterThenLoginActor ENTRY", { + emailLength: input.email.length, + usernameLength: input.username.length, + passwordLength: input.password.length, + confirmPasswordLength: input.confirmPassword.length, + }); const guestId = await readGuestId(); + console.log("[auth-machine] emailRegisterThenLoginActor calling authRepo.register"); const registerResult = await authRepo.register({ ...input, guestId }); + console.log("[auth-machine] emailRegisterThenLoginActor authRepo.register DONE", { + success: registerResult.success, + errorName: registerResult.success ? null : registerResult.error?.name, + errorMessage: registerResult.success ? null : registerResult.error?.message, + }); if (Result.isErr(registerResult)) throw registerResult.error; // 注册后自动登录(对齐 Dart 行为) + console.log( + "[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)", + ); const loginResult = await authRepo.emailLogin({ email: input.email, password: input.password, guestId, }); + console.log( + "[auth-machine] emailRegisterThenLoginActor authRepo.emailLogin DONE", + { + success: loginResult.success, + errorName: loginResult.success ? null : loginResult.error?.name, + errorMessage: loginResult.success ? null : loginResult.error?.message, + }, + ); if (Result.isErr(loginResult)) throw loginResult.error; return "email" as LoginStatus; }); diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index baa9b644..bc0b1df4 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -83,21 +83,21 @@ export const authMachine = setup({ }, // 启动 / 恢复时检查登录态 —— 由 派发 AuthStatusCheckSubmitted: "checkingAuthStatus", - // 显式游客登录 —— 由 splash UI 派发(不自动) + AuthGuestLoginSubmitted: "loadingGuestLogin", AuthEmailLoginSubmitted: "loadingEmailLogin", AuthEmailRegisterSubmitted: "loadingEmailRegister", AuthGoogleLoginSubmitted: "loadingOAuth", AuthFacebookLoginSubmitted: "loadingOAuth", - - // OAuth 回调后 sync 入口 —— 由 派发 - AuthGoogleSyncSubmitted: "syncingGoogleBackend", - AuthFacebookSyncSubmitted: "syncingFacebookBackend", AuthAppleLoginSubmitted: { actions: assign({ errorMessage: "Apple login not implemented", }), }, + + // OAuth 回调后 sync 入口 —— 由 派发 + AuthGoogleSyncSubmitted: "syncingGoogleBackend", + AuthFacebookSyncSubmitted: "syncingFacebookBackend", // splash / auth screen 跳完 /chat 后清flag AuthClearPendingRedirect: { actions: assign({ pendingRedirect: false }), @@ -151,6 +151,7 @@ export const authMachine = setup({ }, loadingEmailLogin: { + entry: assign({ errorMessage: null }), invoke: { src: "emailLogin", input: ({ event }) => { @@ -177,6 +178,7 @@ export const authMachine = setup({ }, loadingEmailRegister: { + entry: assign({ errorMessage: null }), invoke: { src: "emailRegisterThenLogin", input: ({ event }) => {