refactor(auth): funnel OAuth signIn into auth state machine

Move new AuthPlatform(provider).signIn() out of UI components and into
the auth state machine. UI now dispatches AuthGoogle/FacebookLoginSubmitted
events; a new oauthSignIn actor inside the machine calls next-auth/react's
signIn(provider). Errors and the OAuth redirect window are exposed via
the existing state.isLoading / state.errorMessage surface, removing the
local busy / error state in auth-facebook-panel.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
kanban
2026-06-11 10:05:14 +08:00
committed by chenhang
parent d4cb40a74b
commit e2a57386e7
6 changed files with 274 additions and 54 deletions
+23 -31
View File
@@ -7,14 +7,16 @@
* 视觉规格(与 Dart 对齐):
* - Spacer → logo120h)→ Spacer → Facebook 主按钮 → 链接 → Spacer → 法律
* - Facebook 主按钮:46px 白胶囊 + Facebook "f" 图标(#1877f2+ "Login with Facebook"
* - 社交登录走 NextAuth`new AuthPlatform("facebook").signIn()` / `new AuthPlatform("google").signIn()`
* - 社交登录统一收口到 Auth 状态机:派发 `AuthFacebookLoginSubmitted` /
* `AuthGoogleLoginSubmitted` 事件,状态机内部调 next-auth/react 的 signIn(provider)
* - busy / error 状态来自 `useAuthState()`(与邮箱登录同源)
* - "Other Sign-in Options" 链接打开底部弹层
* - 弹层 Email 按钮 → 触发 `onSwitchToEmail`(父级派发 `AuthPanelModeChanged`
*/
import Image from "next/image";
import { useState } from "react";
import { AuthPlatform } from "@/lib/auth/nextauth";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { AuthErrorMessage } from "./auth-error-message";
import { AuthLegalText } from "./auth-legal-text";
@@ -27,31 +29,21 @@ export interface AuthFacebookPanelProps {
export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
const [showOptions, setShowOptions] = useState(false);
const [busy, setBusy] = useState<null | "facebook" | "google">(null);
const [error, setError] = useState<string | null>(null);
const { isLoading, errorMessage } = useAuthState();
const authDispatch = useAuthDispatch();
const handleFacebook = async () => {
setBusy("facebook");
setError(null);
try {
await new AuthPlatform("facebook").signIn();
} catch (e) {
setError(e instanceof Error ? e.message : "Facebook login failed");
} finally {
setBusy(null);
}
const handleFacebook = () => {
authDispatch({
type: "AuthFacebookLoginSubmitted",
provider: "facebook",
});
};
const handleGoogle = async () => {
setBusy("google");
setError(null);
try {
await new AuthPlatform("google").signIn();
} catch (e) {
setError(e instanceof Error ? e.message : "Google login failed");
} finally {
setBusy(null);
}
const handleGoogle = () => {
authDispatch({
type: "AuthGoogleLoginSubmitted",
provider: "google",
});
};
return (
@@ -70,10 +62,10 @@ export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
<button
type="button"
className={styles.facebookButton}
onClick={() => void handleFacebook()}
disabled={busy !== null}
onClick={() => handleFacebook()}
disabled={isLoading}
>
{busy === "facebook" ? (
{isLoading ? (
<span className={styles.spinner} aria-hidden="true" />
) : (
<span className={styles.facebookIcon} aria-hidden="true">
@@ -83,7 +75,7 @@ export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
</span>
)}
<span className={styles.facebookLabel}>
{busy === "facebook" ? "Logging in..." : "Login with Facebook"}
{isLoading ? "Logging in..." : "Login with Facebook"}
</span>
</button>
@@ -97,15 +89,15 @@ export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
<div className={styles.spacer} />
<AuthErrorMessage message={error} />
<AuthErrorMessage message={errorMessage} />
<AuthLegalText />
<AuthOtherOptionsDialog
open={showOptions}
onClose={() => setShowOptions(false)}
mode="facebook"
onFacebook={() => void handleFacebook()}
onGoogle={() => void handleGoogle()}
onFacebook={() => handleFacebook()}
onGoogle={() => handleGoogle()}
onEmail={onSwitchToEmail}
/>
</div>
+9 -10
View File
@@ -7,20 +7,19 @@
*
* 本组件是 splash 鉴权流程的**自治单元**
* - 消费 `useSession()`next-auth/react)监听 NextAuth session
* - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录流程
* - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录 + OAuth 流程统一收口到状态机
* - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
* - 自管路由跳转(已登录 / 登录成功 → /chat)
* - Skip 用 `<Link>` 保留 SPA 体验
* - 社交登录按钮走 `new AuthPlatform("facebook").signIn()`NextAuth 流程)
* - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth
*/
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { useAuthState } from "@/stores/auth/auth-context";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { AuthPlatform } from "@/lib/auth/nextauth";
import { ROUTES } from "@/router/routes";
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
import styles from "./splash-button.module.css";
@@ -33,6 +32,7 @@ export function SplashButton() {
// 不是鉴权判断;proxy 会让通过因为 cookie 已设)
const state = useAuthState();
const authDispatch = useAuthDispatch();
const isLoading = state.isLoading;
// ===== 跨 store 初始化(登录成功后触发) =====
@@ -51,12 +51,11 @@ export function SplashButton() {
}
}, [state.isSuccess, chatDispatch, userDispatch, router]);
const handleFacebookLogin = async () => {
try {
await new AuthPlatform("facebook").signIn();
} catch (e) {
console.error("[splash-button] Facebook login failed", e);
}
const handleFacebookLogin = () => {
authDispatch({
type: "AuthFacebookLoginSubmitted",
provider: "facebook",
});
};
return (
+5 -3
View File
@@ -55,9 +55,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
password: state.context.password,
username: state.context.username,
confirmPassword: state.context.confirmPassword,
// isLoading 仅来自邮箱登录 actor(社交登录改由 NextAuth 处理,UI 自行管 busy 状态
isLoading: state.matches("loadingEmailLogin") ||
state.matches("loadingEmailRegister"),
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间
isLoading:
state.matches("loadingEmailLogin") ||
state.matches("loadingEmailRegister") ||
state.matches("loadingOAuth"),
errorMessage: state.context.errorMessage,
isSuccess: state.matches("success"),
loginType: state.context.loginType,
+4 -3
View File
@@ -3,6 +3,7 @@
*
* 事件名与原 Dart AuthEvent 对齐。
*/
import type { AuthProvider } from "@/lib/auth/auth_platform";
import type { AuthMode } from "@/models/auth/auth-mode";
import type { AuthPanelMode } from "@/models/auth/auth-panel-mode";
@@ -21,7 +22,7 @@ export type AuthEvent =
username: string;
confirmPassword: string;
}
// 社交登录(已迁移到 NextAuth)— 业务层直接 await nextauth.googleLogin() 即可
| { type: "AuthGoogleLoginSubmitted" }
| { type: "AuthFacebookLoginSubmitted" }
// 社交登录 —— 状态机内部调 next-auth/react 的 signIn(provider)
| { type: "AuthGoogleLoginSubmitted"; provider: AuthProvider }
| { type: "AuthFacebookLoginSubmitted"; provider: AuthProvider }
| { type: "AuthAppleLoginSubmitted" };
+40 -7
View File
@@ -3,12 +3,14 @@
*
* 原始 Dart: lib/ui/auth/bloc/auth_bloc.dart + auth_state.dart + auth_event.dart
*
* 本轮迁移:社交登录改由 NextAuth 处理(`@/lib/auth/nextauth`),状态机仅保留
* 邮箱注册/登录 + 通用 reset 事件。社交登录走 `new AuthPlatform("google").signIn()` /
* `new AuthPlatform("facebook").signIn()`
* 本轮迁移:所有认证登录调用(含 OAuth 社交登录)统一收口到状态机内。
* 业务层只派发事件(`AuthEmailLoginSubmitted` / `AuthGoogleLoginSubmitted` / 等),
* 状态机通过 actors 调 `authRepository`(邮箱)或 `next-auth/react.signIn`OAuth
*/
import { setup, fromPromise, assign } from "xstate";
import { signIn } from "next-auth/react";
import type { AuthProvider } from "@/lib/auth/auth_platform";
import type { LoginType } from "@/models/auth/login-type";
import { authRepository } from "@/data/repositories/auth_repository";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
@@ -68,6 +70,13 @@ const emailRegisterThenLoginActor = fromPromise<
return "email" as LoginType;
});
// OAuth 登录 actor:调 next-auth/react 的 signIn(provider) 触发 OAuth 跳转。
// 成功路径下 NextAuth 会重定向离开本应用,actor 通常不会 resolve
// onDone 仍保留以处理 SDK 同步返回(极少见)。
const oauthSignInActor = fromPromise<void, AuthProvider>(async ({ input }) => {
await signIn(input);
});
// ============================================================
// Machine
// ============================================================
@@ -79,6 +88,7 @@ export const authMachine = setup({
actors: {
emailLogin: emailLoginActor,
emailRegisterThenLogin: emailRegisterThenLoginActor,
oauthSignIn: oauthSignInActor,
},
}).createMachine({
id: "auth",
@@ -113,10 +123,8 @@ export const authMachine = setup({
},
AuthEmailLoginSubmitted: "loadingEmailLogin",
AuthEmailRegisterSubmitted: "loadingEmailRegister",
// 社交登录已迁移到 NextAuth + AuthPlatform 类
// 状态机保留事件以兼容旧调用方(实际不再触发)
AuthGoogleLoginSubmitted: {},
AuthFacebookLoginSubmitted: {},
AuthGoogleLoginSubmitted: "loadingOAuth",
AuthFacebookLoginSubmitted: "loadingOAuth",
AuthAppleLoginSubmitted: {
actions: assign({
errorMessage: "Apple login not implemented",
@@ -180,6 +188,31 @@ export const authMachine = setup({
},
},
loadingOAuth: {
entry: assign({ errorMessage: null }),
invoke: {
src: "oauthSignIn",
input: ({ event }) => {
// event 来自 idle 跳转(AuthGoogle/FacebookLoginSubmitted
if (event.type === "AuthGoogleLoginSubmitted") return event.provider;
if (event.type === "AuthFacebookLoginSubmitted") return event.provider;
return "google" as AuthProvider;
},
onDone: {
// 罕见分支:NextAuth 同步 resolve(实际几乎不会发生,
// OAuth 流程会通过重定向离开本应用)。
target: "idle",
},
onError: {
target: "idle",
actions: assign({
errorMessage: ({ event }) =>
event.error instanceof Error ? event.error.message : String(event.error),
}),
},
},
},
success: {
// 终态:业务层通过 state.matches("success") 判断
},