200fb1642f
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)
289 lines
8.9 KiB
TypeScript
289 lines
8.9 KiB
TypeScript
/**
|
||
* Auth 状态机(XState v5)
|
||
*/
|
||
import { setup, assign } from "xstate";
|
||
|
||
import type { AuthProvider } from "@/lib/auth/auth_platform";
|
||
|
||
import { AuthState, initialState } from "./auth-state";
|
||
import type { AuthEvent } from "./auth-events";
|
||
import {
|
||
emailLoginActor,
|
||
emailRegisterThenLoginActor,
|
||
oauthSignInActor,
|
||
syncGoogleBackendActor,
|
||
syncFacebookBackendActor,
|
||
guestLoginActor,
|
||
checkAuthStatusActor,
|
||
} from "./auth-actors";
|
||
|
||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
||
export type { AuthState } from "./auth-state";
|
||
export { initialState } from "./auth-state";
|
||
export type { AuthEvent } from "./auth-events";
|
||
|
||
// ============================================================
|
||
// Machine
|
||
//
|
||
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
|
||
//
|
||
// `pendingRedirect: boolean`("用户显式触发了登录"信号)——
|
||
// 在登录成功的 onDone 里自动置 true:
|
||
// splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat
|
||
// 跳完派 `AuthClearPendingRedirect` 置 false
|
||
// checkingAuthStatus 不设 flag —— 被动查 token 不算"用户意图"。
|
||
//
|
||
// 注:onDone 用了内联assign(不是 helper function)—— XState v5 的 TypeScript
|
||
// 推断对 helper return type 不友好;内联能保留 `state.matches("loadingXxx")` 的类型。
|
||
// ============================================================
|
||
export const authMachine = setup({
|
||
types: {
|
||
context: {} as AuthState,
|
||
events: {} as AuthEvent,
|
||
},
|
||
actors: {
|
||
emailLogin: emailLoginActor,
|
||
emailRegisterThenLogin: emailRegisterThenLoginActor,
|
||
oauthSignIn: oauthSignInActor,
|
||
syncGoogleBackend: syncGoogleBackendActor,
|
||
syncFacebookBackend: syncFacebookBackendActor,
|
||
guestLogin: guestLoginActor,
|
||
checkAuthStatus: checkAuthStatusActor,
|
||
},
|
||
}).createMachine({
|
||
id: "auth",
|
||
initial: "idle",
|
||
context: initialState,
|
||
states: {
|
||
idle: {
|
||
on: {
|
||
AuthPanelModeChanged: {
|
||
actions: assign({
|
||
authPanelMode: ({ event }) => event.mode,
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
AuthModeChanged: {
|
||
actions: assign({
|
||
authMode: ({ event }) => event.mode,
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
AuthFormCleared: {
|
||
actions: assign({
|
||
email: "",
|
||
password: "",
|
||
username: "",
|
||
confirmPassword: "",
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
AuthReset: {
|
||
actions: assign(() => initialState),
|
||
},
|
||
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
|
||
AuthStatusCheckSubmitted: "checkingAuthStatus",
|
||
|
||
AuthGuestLoginSubmitted: "loadingGuestLogin",
|
||
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||
AuthGoogleLoginSubmitted: "loadingOAuth",
|
||
AuthFacebookLoginSubmitted: "loadingOAuth",
|
||
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 }),
|
||
},
|
||
},
|
||
},
|
||
|
||
checkingAuthStatus: {
|
||
entry: assign({ errorMessage: null }),
|
||
invoke: {
|
||
src: "checkAuthStatus",
|
||
onDone: {
|
||
target: "idle", // ← status check 完回 idle(不再假冒 success)
|
||
actions: assign({
|
||
loginStatus: ({ event }) => event.output,
|
||
// 不置 pendingRedirect —— 状态查询是被动读 token,不算"用户意图"
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
onError: {
|
||
target: "idle",
|
||
actions: assign({
|
||
errorMessage: ({ event }) =>
|
||
event.error instanceof Error ? event.error.message : String(event.error),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
|
||
loadingGuestLogin: {
|
||
entry: assign({ errorMessage: null }),
|
||
invoke: {
|
||
src: "guestLogin",
|
||
onDone: {
|
||
target: "idle",
|
||
// 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型
|
||
actions: assign({
|
||
loginStatus: ({ event }) => event.output,
|
||
pendingRedirect: true, // ← 显式登录成功 → splash / auth 看到跳 /chat
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
onError: {
|
||
target: "idle",
|
||
actions: assign({
|
||
errorMessage: ({ event }) =>
|
||
event.error instanceof Error ? event.error.message : String(event.error),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
|
||
loadingEmailLogin: {
|
||
entry: assign({ errorMessage: null }),
|
||
invoke: {
|
||
src: "emailLogin",
|
||
input: ({ event }) => {
|
||
if (event.type !== "AuthEmailLoginSubmitted")
|
||
return { email: "", password: "" };
|
||
return { email: event.email, password: event.password };
|
||
},
|
||
onDone: {
|
||
target: "idle",
|
||
actions: assign({
|
||
loginStatus: ({ event }) => event.output,
|
||
pendingRedirect: true, // ← 邮箱登录成功 → 跳
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
onError: {
|
||
target: "idle",
|
||
actions: assign({
|
||
errorMessage: ({ event }) =>
|
||
event.error instanceof Error ? event.error.message : String(event.error),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
|
||
loadingEmailRegister: {
|
||
entry: assign({ errorMessage: null }),
|
||
invoke: {
|
||
src: "emailRegisterThenLogin",
|
||
input: ({ event }) => {
|
||
if (event.type !== "AuthEmailRegisterSubmitted")
|
||
return { email: "", password: "", username: "", confirmPassword: "" };
|
||
return {
|
||
email: event.email,
|
||
password: event.password,
|
||
username: event.username,
|
||
confirmPassword: event.confirmPassword,
|
||
};
|
||
},
|
||
onDone: {
|
||
target: "idle",
|
||
actions: assign({
|
||
loginStatus: ({ event }) => event.output,
|
||
pendingRedirect: true, // ← 注册成功 → 跳
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
onError: {
|
||
target: "idle",
|
||
actions: assign({
|
||
errorMessage: ({ event }) =>
|
||
event.error instanceof Error ? event.error.message : String(event.error),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
|
||
loadingOAuth: {
|
||
entry: assign({ errorMessage: null }),
|
||
invoke: {
|
||
src: "oauthSignIn",
|
||
input: ({ event }) => {
|
||
if (event.type === "AuthGoogleLoginSubmitted") return event.provider;
|
||
if (event.type === "AuthFacebookLoginSubmitted") return event.provider;
|
||
return "google" as AuthProvider;
|
||
},
|
||
onDone: {
|
||
target: "idle",
|
||
},
|
||
onError: {
|
||
target: "idle",
|
||
actions: assign({
|
||
errorMessage: ({ event }) =>
|
||
event.error instanceof Error ? event.error.message : String(event.error),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
|
||
syncingGoogleBackend: {
|
||
entry: assign({ errorMessage: null }),
|
||
invoke: {
|
||
src: "syncGoogleBackend",
|
||
input: ({ event }) => {
|
||
if (event.type !== "AuthGoogleSyncSubmitted") return { idToken: "" };
|
||
return { idToken: event.idToken };
|
||
},
|
||
onDone: {
|
||
target: "idle",
|
||
actions: assign({
|
||
loginStatus: ({ event }) => event.output,
|
||
pendingRedirect: true, // ← OAuth sync 成功 → 跳
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
onError: {
|
||
target: "idle",
|
||
actions: assign({
|
||
errorMessage: ({ event }) =>
|
||
event.error instanceof Error ? event.error.message : String(event.error),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
|
||
syncingFacebookBackend: {
|
||
entry: assign({ errorMessage: null }),
|
||
invoke: {
|
||
src: "syncFacebookBackend",
|
||
input: ({ event }) => {
|
||
if (event.type !== "AuthFacebookSyncSubmitted") return { accessToken: "" };
|
||
return { accessToken: event.accessToken };
|
||
},
|
||
onDone: {
|
||
target: "idle",
|
||
actions: assign({
|
||
loginStatus: ({ event }) => event.output,
|
||
pendingRedirect: true, // ← OAuth sync 成功 → 跳
|
||
errorMessage: null,
|
||
}),
|
||
},
|
||
onError: {
|
||
target: "idle",
|
||
actions: assign({
|
||
errorMessage: ({ event }) =>
|
||
event.error instanceof Error ? event.error.message : String(event.error),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
export type AuthMachine = typeof authMachine;
|