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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user