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";
|
"use client";
|
||||||
/**
|
/**
|
||||||
* 邮箱登录表单
|
* 邮箱登录表单
|
||||||
*
|
|
||||||
* 原始 Dart: lib/ui/auth/widgets/email_login_form.dart
|
|
||||||
*
|
|
||||||
* 视觉规格(与 Dart 对齐):
|
|
||||||
* - 字段无 label(仅 placeholder)
|
|
||||||
* - 错误仅以红色横幅显示(AuthErrorMessage),无字段级错误
|
|
||||||
* - 不显示 "Forgot password?" 链接
|
|
||||||
* - 切换链接由父级 AuthEmailPanel 渲染
|
|
||||||
* - 提交派发 `AuthEmailLoginSubmitted`
|
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -36,11 +27,41 @@ export function EmailLoginForm({
|
|||||||
const dispatch = useAuthDispatch();
|
const dispatch = useAuthDispatch();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = 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 = () => {
|
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 emailErr = validateEmail(email);
|
||||||
const passwordErr = validatePassword(password);
|
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 });
|
dispatch({ type: "AuthEmailLoginSubmitted", email, password });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,19 +76,20 @@ export function EmailLoginForm({
|
|||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={setEmail}
|
onChange={onEmailChange}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={onPasswordChange}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
/>
|
/>
|
||||||
<AuthErrorMessage message={globalError ?? null} />
|
{/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError) */}
|
||||||
|
<AuthErrorMessage message={validationError ?? globalError ?? null} />
|
||||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||||
Login
|
Login
|
||||||
</AuthPrimaryButton>
|
</AuthPrimaryButton>
|
||||||
|
|||||||
@@ -40,13 +40,64 @@ export function EmailRegisterForm({
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = 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 = () => {
|
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 emailErr = validateEmail(email);
|
||||||
const passwordErr = validatePassword(password);
|
const passwordErr = validatePassword(password);
|
||||||
const usernameErr = validateUsername(username);
|
const usernameErr = validateUsername(username);
|
||||||
const confirmErr = validateConfirmPassword(password, confirmPassword);
|
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({
|
dispatch({
|
||||||
type: "AuthEmailRegisterSubmitted",
|
type: "AuthEmailRegisterSubmitted",
|
||||||
email,
|
email,
|
||||||
@@ -66,33 +117,34 @@ export function EmailRegisterForm({
|
|||||||
>
|
>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
value={username}
|
value={username}
|
||||||
onChange={setUsername}
|
onChange={onUsernameChange}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={setEmail}
|
onChange={onEmailChange}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={onPasswordChange}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={setConfirmPassword}
|
onChange={onConfirmPasswordChange}
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
/>
|
/>
|
||||||
<AuthErrorMessage message={globalError ?? null} />
|
{/* 优先级:本地校验错误 > 后端 globalError */}
|
||||||
|
<AuthErrorMessage message={validationError ?? globalError ?? null} />
|
||||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||||
Register
|
Register
|
||||||
</AuthPrimaryButton>
|
</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 = {
|
export const LoginStatus = {
|
||||||
/** 未登录(默认初值) */
|
/** 未登录(默认初值) */
|
||||||
NotLoggedIn: "notLoggedIn",
|
NotLoggedIn: "notLoggedIn",
|
||||||
/** 游客登录(无需账号,10 轮免费消息) */
|
/** 游客登录 */
|
||||||
Guest: "guest",
|
Guest: "guest",
|
||||||
/** Facebook OAuth 登录 */
|
/** Facebook OAuth 登录 */
|
||||||
Facebook: "facebook",
|
Facebook: "facebook",
|
||||||
|
|||||||
@@ -25,8 +25,22 @@ const authRepo: IAuthRepository = authRepository;
|
|||||||
|
|
||||||
export const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
|
export const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
|
||||||
async ({ input }) => {
|
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();
|
const guestId = await readGuestId();
|
||||||
|
console.log("[auth-machine] emailLoginActor calling authRepo.emailLogin", {
|
||||||
|
hasGuestId: !!guestId,
|
||||||
|
});
|
||||||
const result = await authRepo.emailLogin({ ...input, 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;
|
if (Result.isErr(result)) throw result.error;
|
||||||
return "email" as LoginStatus;
|
return "email" as LoginStatus;
|
||||||
},
|
},
|
||||||
@@ -36,17 +50,40 @@ export const emailRegisterThenLoginActor = fromPromise<
|
|||||||
LoginStatus,
|
LoginStatus,
|
||||||
{ email: string; password: string; username: string; confirmPassword: string }
|
{ email: string; password: string; username: string; confirmPassword: string }
|
||||||
>(async ({ input }) => {
|
>(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();
|
const guestId = await readGuestId();
|
||||||
|
|
||||||
|
console.log("[auth-machine] emailRegisterThenLoginActor calling authRepo.register");
|
||||||
const registerResult = await authRepo.register({ ...input, guestId });
|
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;
|
if (Result.isErr(registerResult)) throw registerResult.error;
|
||||||
|
|
||||||
// 注册后自动登录(对齐 Dart 行为)
|
// 注册后自动登录(对齐 Dart 行为)
|
||||||
|
console.log(
|
||||||
|
"[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)",
|
||||||
|
);
|
||||||
const loginResult = await authRepo.emailLogin({
|
const loginResult = await authRepo.emailLogin({
|
||||||
email: input.email,
|
email: input.email,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
guestId,
|
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;
|
if (Result.isErr(loginResult)) throw loginResult.error;
|
||||||
return "email" as LoginStatus;
|
return "email" as LoginStatus;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,21 +83,21 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
|
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
|
||||||
AuthStatusCheckSubmitted: "checkingAuthStatus",
|
AuthStatusCheckSubmitted: "checkingAuthStatus",
|
||||||
// 显式游客登录 —— 由 splash UI 派发(不自动)
|
|
||||||
AuthGuestLoginSubmitted: "loadingGuestLogin",
|
AuthGuestLoginSubmitted: "loadingGuestLogin",
|
||||||
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
||||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||||
AuthGoogleLoginSubmitted: "loadingOAuth",
|
AuthGoogleLoginSubmitted: "loadingOAuth",
|
||||||
AuthFacebookLoginSubmitted: "loadingOAuth",
|
AuthFacebookLoginSubmitted: "loadingOAuth",
|
||||||
|
|
||||||
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
|
|
||||||
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
|
|
||||||
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
|
|
||||||
AuthAppleLoginSubmitted: {
|
AuthAppleLoginSubmitted: {
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: "Apple login not implemented",
|
errorMessage: "Apple login not implemented",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
|
||||||
|
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
|
||||||
|
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
|
||||||
// splash / auth screen 跳完 /chat 后清flag
|
// splash / auth screen 跳完 /chat 后清flag
|
||||||
AuthClearPendingRedirect: {
|
AuthClearPendingRedirect: {
|
||||||
actions: assign({ pendingRedirect: false }),
|
actions: assign({ pendingRedirect: false }),
|
||||||
@@ -151,6 +151,7 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadingEmailLogin: {
|
loadingEmailLogin: {
|
||||||
|
entry: assign({ errorMessage: null }),
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "emailLogin",
|
src: "emailLogin",
|
||||||
input: ({ event }) => {
|
input: ({ event }) => {
|
||||||
@@ -177,6 +178,7 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadingEmailRegister: {
|
loadingEmailRegister: {
|
||||||
|
entry: assign({ errorMessage: null }),
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "emailRegisterThenLogin",
|
src: "emailRegisterThenLogin",
|
||||||
input: ({ event }) => {
|
input: ({ event }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user