fix(auth): surface validation errors in email login/register forms

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)
This commit is contained in:
2026-06-16 17:29:58 +08:00
parent 2792d0c9c5
commit 200fb1642f
5 changed files with 138 additions and 32 deletions
+35 -13
View File
@@ -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<string | null>(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({
<AuthTextField
type="email"
value={email}
onChange={setEmail}
onChange={onEmailChange}
placeholder="Email"
autoComplete="email"
/>
<AuthTextField
type="password"
value={password}
onChange={setPassword}
onChange={onPasswordChange}
placeholder="Password"
autoComplete="current-password"
onSubmit={submit}
/>
<AuthErrorMessage message={globalError ?? null} />
{/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError */}
<AuthErrorMessage message={validationError ?? globalError ?? null} />
<AuthPrimaryButton type="submit" isLoading={isLoading}>
Login
</AuthPrimaryButton>
@@ -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<string | null>(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({
>
<AuthTextField
value={username}
onChange={setUsername}
onChange={onUsernameChange}
placeholder="Username"
autoComplete="username"
/>
<AuthTextField
type="email"
value={email}
onChange={setEmail}
onChange={onEmailChange}
placeholder="Email"
autoComplete="email"
/>
<AuthTextField
type="password"
value={password}
onChange={setPassword}
onChange={onPasswordChange}
placeholder="Password"
autoComplete="new-password"
/>
<AuthTextField
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
onChange={onConfirmPasswordChange}
placeholder="Confirm Password"
autoComplete="new-password"
onSubmit={submit}
/>
<AuthErrorMessage message={globalError ?? null} />
{/* 优先级:本地校验错误 > 后端 globalError */}
<AuthErrorMessage message={validationError ?? globalError ?? null} />
<AuthPrimaryButton type="submit" isLoading={isLoading}>
Register
</AuthPrimaryButton>