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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user