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>
+1 -8
View File
@@ -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",
+37
View File
@@ -25,8 +25,22 @@ const authRepo: IAuthRepository = authRepository;
export const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
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;
});
+7 -5
View File
@@ -83,21 +83,21 @@ export const authMachine = setup({
},
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
AuthStatusCheckSubmitted: "checkingAuthStatus",
// 显式游客登录 —— 由 splash UI 派发(不自动)
AuthGuestLoginSubmitted: "loadingGuestLogin",
AuthEmailLoginSubmitted: "loadingEmailLogin",
AuthEmailRegisterSubmitted: "loadingEmailRegister",
AuthGoogleLoginSubmitted: "loadingOAuth",
AuthFacebookLoginSubmitted: "loadingOAuth",
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
AuthAppleLoginSubmitted: {
actions: assign({
errorMessage: "Apple login not implemented",
}),
},
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
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 }) => {