diff --git a/implementation_plan.md b/implementation_plan.md index 3311ed84..5463bc70 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -388,3 +388,196 @@ export default function RootLayout({ children }: { children: React.ReactNode }) 7. **新建 global.d.ts**:声明 `window.google.accounts.id.*` 类型 8. **依次新建** `auth_event_bridge.ts`、`initialize_*.ts`、`auth_platform.ts`、`web_auth_platform.ts`、`mock_auth_platform.ts`、`auth_bridge_component.tsx` 9. **验证**:`npx tsc --noEmit`、`pnpm lint`、`pnpm build` + +--- + +# 收口 OAuth 登录调用到 Auth 状态机 + +## [Overview] + +把 `new AuthPlatform(provider).signIn()` 的直接调用从 UI 组件移到 Auth 状态机里。改完后所有 OAuth 入口都通过 `useAuthDispatch()` 派发 `AuthGoogleLoginSubmitted` / `AuthFacebookLoginSubmitted` 事件,状态机自己用 `next-auth/react` 的 `signIn(provider)` 触发流程。NextAuth 重定向期间用一个 `loadingOAuth` 子状态表示"正在跳走",期间 `isLoading` 为 true,UI 复用现有的 `state.isLoading` 控制按钮 disabled;失败把 `errorMessage` 写入 context。 + +## [Goals] + +- UI 组件不再 `import { AuthPlatform }`,仅派发事件 +- 状态机里承载 OAuth 流程(actor 调 `signIn`、成功/失败分支) +- 复用既有 `isLoading` / `errorMessage` 形态,UI 侧零概念变化 +- 行为对齐:成功 = NextAuth 重定向到 `callbackUrl`(默认 `/chat`),所以状态机不需要"成功"分支——和邮箱登录的 `success` 终态语义不同。OAuth 的"完成"由 NextAuth 回调 + `SessionProvider` 处理 + +## [Non-Goals] + +- 不动邮箱登录的 actor / 事件 +- 不动 `Logout` 类(实际未在 UI 直接 `new Logout()`,由 `userMachine` 调 `authRepository.logout` 走,OAuth 注销是另一条线) +- 不在状态机里尝试"成功"——NextAuth 跳转后页面会被 Next.js 接管,state 在那时已被销毁,无需 `success` 终态 +- 不引入新的 busy 字段 + +## [Files] + +### 修改 + +| 路径 | 变更 | +|---|---| +| `src/stores/auth/machine/auth-events.ts` | `AuthGoogleLoginSubmitted` / `AuthFacebookLoginSubmitted` 携带 `provider: AuthProvider` 字段(或保留无 payload + 状态机内部按事件类型路由) | +| `src/stores/auth/machine/auth-machine.ts` | 新增 `oauthSignIn: fromPromise` actor;`idle` 中两个事件指向新状态 `loadingOAuth`;`loadingOAuth.invoke` 调 actor;`onError` 把错误写入 `errorMessage` 并回到 `idle`;`onDone` 也回到 `idle`(NextAuth 一旦调起就会重定向,`onDone` 极少触发;但状态机结构上仍要正确处理以防 SDK 同步返回) | +| `src/app/splash/components/splash-button.tsx` | 移除 `import { AuthPlatform }`,改用 `useAuthDispatch` 派发 `AuthFacebookLoginSubmitted`;移除 `handleFacebookLogin` 的 try/catch(错误走状态机) | +| `src/app/auth/components/auth-facebook-panel.tsx` | 移除 `import { AuthPlatform }`、移除本地 `busy` 状态(用 `state.isLoading` 替代) / `error` 状态(用 `state.errorMessage` 替代);改用 `useAuthDispatch` 派发对应事件;`disabled` 绑 `isLoading`;按钮 spinner 用 `isLoading` 判断(不能再区分是 Facebook 还是 Google 在加载——OAuth 跳转期间整个 UI 都该锁住) | +| `src/lib/auth/nextauth.ts` | 仍 re-export `AuthPlatform` 类型(给 `authRepository` 内部用),但**删除**面向 UI 的 export 仅保留内部使用;或将 export 改为 `internal` barrel 命名(不强制,看用户偏好) | + +### 不变 + +- `src/lib/auth/auth_platform.ts` 保持原样(其他模块可能用) +- `src/stores/auth/machine/auth-context.tsx` 不用动(`isLoading` / `errorMessage` 已暴露) +- `src/stores/auth/auth-state.ts` / `auth-types.ts` / `index.ts` 不用动 + +## [State Machine Diff] + +```ts +// auth-events.ts —— OAuth 事件保留无 payload;provider 由事件类型决定 +export type AuthEvent = + | { type: "AuthPanelModeChanged"; mode: AuthPanelMode } + | { type: "AuthModeChanged"; mode: AuthMode } + | { type: "AuthFormCleared" } + | { type: "AuthReset" } + | { type: "AuthEmailLoginSubmitted"; email: string; password: string } + | { type: "AuthEmailRegisterSubmitted"; email: string; password: string; username: string; confirmPassword: string } + | { type: "AuthGoogleLoginSubmitted" } + | { type: "AuthFacebookLoginSubmitted" } + | { type: "AuthAppleLoginSubmitted" }; +``` + +```ts +// auth-machine.ts —— 新增 actor + 状态 +import { signIn } from "next-auth/react"; + +const oauthSignInActor = fromPromise(async ({ input }) => { + await signIn(input); +}); + +export const authMachine = setup({ + types: { context: {} as AuthState, events: {} as AuthEvent }, + actors: { + emailLogin: emailLoginActor, + emailRegisterThenLogin: emailRegisterThenLoginActor, + oauthSignIn: oauthSignInActor, + }, +}).createMachine({ + id: "auth", + initial: "idle", + context: initialState, + states: { + idle: { + on: { + // ... 既有事件保持 ... + AuthGoogleLoginSubmitted: { target: "loadingOAuth" }, + AuthFacebookLoginSubmitted: { target: "loadingOAuth" }, + AuthAppleLoginSubmitted: { + actions: assign({ errorMessage: "Apple login not implemented" }), + }, + }, + }, + + loadingOAuth: { + // 记录是哪个 provider 出错 + entry: assign({ errorMessage: null }), + invoke: { + src: "oauthSignIn", + input: ({ context }) => context.pendingOAuthProvider ?? "google", + onDone: { target: "idle" }, // 罕见分支:NextAuth 同步 resolve(不应发生) + onError: { + target: "idle", + actions: assign({ + errorMessage: ({ event }) => + event.error instanceof Error ? event.error.message : String(event.error), + }), + }, + }, + on: { + AuthGoogleLoginSubmitted: { /* 同 provider 重新触发:重新进入状态即可;XState v5 默认会重 invoke */ target: "loadingOAuth", reenter: true }, + AuthFacebookLoginSubmitted: { target: "loadingOAuth", reenter: true }, + }, + }, + + // loadingEmailLogin / loadingEmailRegister / success 不变 + }, +}); +``` + +**注意**:把 provider 传给 actor 需要从事件读取。两种实现: + +1. **input 走 event(推荐)**:在 `idle` 的 transition 上用 `actions: assign({ pendingOAuthProvider: ... })` + 用 `target: "loadingOAuth"`。但 XState v5 在 `invoke.input` 只能拿 `event`(触发 transition 的那个),且 `loadingOAuth` 没有 transition 是用 `event` 进入的(是 target 跳转),所以更稳的做法是给事件本身带 `provider`: + +```ts +// 推荐方案:事件携带 provider +| { type: "AuthGoogleLoginSubmitted"; provider: "google" } +| { type: "AuthFacebookLoginSubmitted"; provider: "facebook" } +``` + +2. 状态机入口用 `actions: assign({ pendingOAuthProvider: ... })` 写入 context,再从 `context` 读。 + +**采用方案 1**——更显式、无需 context 字段。详见 [Implementation Order] 第 3 步。 + +## [AuthState 增量] + +不需要新字段。如果采用"从 context 读 provider"才需要 `pendingOAuthProvider`,但方案 1 不用。 + +## [UI Diff] + +### `splash-button.tsx` + +```tsx +// 旧 +const handleFacebookLogin = async () => { + try { await new AuthPlatform("facebook").signIn(); } + catch (e) { console.error("[splash-button] Facebook login failed", e); } +}; +// 新 +const authDispatch = useAuthDispatch(); +const handleFacebookLogin = () => authDispatch({ type: "AuthFacebookLoginSubmitted" }); +``` + +### `auth-facebook-panel.tsx` + +```tsx +// 旧 +const [showOptions, setShowOptions] = useState(false); +const [busy, setBusy] = useState(null); +const [error, setError] = useState(null); +const handleFacebook = async () => { /* setBusy + try new AuthPlatform().signIn() */ }; +const handleGoogle = async () => { /* setBusy + try new AuthPlatform().signIn() */ }; +// 新 +const { isLoading, errorMessage } = useAuthState(); +const authDispatch = useAuthDispatch(); +const handleFacebook = () => authDispatch({ type: "AuthFacebookLoginSubmitted" }); +const handleGoogle = () => authDispatch({ type: "AuthGoogleLoginSubmitted" }); +// 渲染:disabled={isLoading};spinner 用 isLoading;error 用 errorMessage +``` + +**取舍**: + +- **去掉了"哪个按钮在转"区分**——OAuth 跳转期间 `isLoading` 一直 true,两个按钮都禁用并显示 spinner。这是预期行为(避免重复触发),且和 splash 一致 +- **去掉了本地 error 状态**——错误统一到 `state.errorMessage`,与邮箱登录同源 + +## [Trade-offs] + +- ✅ 调用方零概念:UI 只 dispatch,不知道有 `next-auth/react` +- ✅ 错误处理统一:`state.errorMessage` 涵盖邮箱 + OAuth +- ✅ `isLoading` 统一:UI 不用为 OAuth 单写 busy +- ⚠️ `signIn` 副作用(重定向)从 React 组件挪到 `fromPromise` actor;XState v5 支持 promise actor 在浏览器环境跑 +- ⚠️ `auth-facebook-panel` 失去"区分两个 spinner"的能力(已知取舍) + +## [Testing] + +- `npx tsc --noEmit`:0 错误 +- `pnpm lint`:通过 +- 手动 smoke:点 Facebook / Google 按钮 → 状态机进入 `loadingOAuth` → NextAuth 重定向到 provider +- 检查:刷新 / 失败场景下 `errorMessage` 正确显示在 `` + +## [Implementation Order] + +1. **`auth-events.ts`**:OAuth 事件携带 `provider: "google" | "facebook"`(如果用方案 1) +2. **`auth-machine.ts`**:新增 `oauthSignInActor` + `loadingOAuth` 状态 + idle transition 指向它;移除 `// 社交登录已迁移到 NextAuth + AuthPlatform 类` 注释 +3. **`splash-button.tsx`**:替换 `handleFacebookLogin` 为 dispatch;移除 `AuthPlatform` import +4. **`auth-facebook-panel.tsx`**:替换两个 handler;移除本地 `busy` / `error`;按钮 `disabled` 绑 `isLoading`;spinner 用 `isLoading`;`` 改为 `message={errorMessage}`(来自 `useAuthState`) +5. **`auth-context.tsx`**:检查 `isLoading` 表达式是否覆盖 `loadingOAuth`——需要把 `state.matches("loadingOAuth")` 加进去 +6. **验证**:`npx tsc --noEmit` + `pnpm lint` + dev server smoke +7. **可选清理**:`src/lib/auth/nextauth.ts` 的 `export { AuthPlatform, type AuthProvider }` 改为仅在 auth_repository / 其他内部消费者使用;如果没有别的 import,可保留 diff --git a/src/app/auth/components/auth-facebook-panel.tsx b/src/app/auth/components/auth-facebook-panel.tsx index a5199df8..01079370 100644 --- a/src/app/auth/components/auth-facebook-panel.tsx +++ b/src/app/auth/components/auth-facebook-panel.tsx @@ -7,14 +7,16 @@ * 视觉规格(与 Dart 对齐): * - Spacer → logo(120h)→ 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); - const [error, setError] = useState(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) { @@ -97,15 +89,15 @@ export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
- + setShowOptions(false)} mode="facebook" - onFacebook={() => void handleFacebook()} - onGoogle={() => void handleGoogle()} + onFacebook={() => handleFacebook()} + onGoogle={() => handleGoogle()} onEmail={onSwitchToEmail} />
diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx index 5c5965ed..cf290bd1 100644 --- a/src/app/splash/components/splash-button.tsx +++ b/src/app/splash/components/splash-button.tsx @@ -7,20 +7,19 @@ * * 本组件是 splash 鉴权流程的**自治单元**: * - 消费 `useSession()`(next-auth/react)监听 NextAuth session - * - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录流程) + * - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录 + OAuth 流程统一收口到状态机) * - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化) * - 自管路由跳转(已登录 / 登录成功 → /chat) * - Skip 用 `` 保留 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 ( diff --git a/src/stores/auth/auth-context.tsx b/src/stores/auth/auth-context.tsx index 020356fe..0b338a94 100644 --- a/src/stores/auth/auth-context.tsx +++ b/src/stores/auth/auth-context.tsx @@ -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, diff --git a/src/stores/auth/auth-events.ts b/src/stores/auth/auth-events.ts index 676869ef..8bfa4d11 100644 --- a/src/stores/auth/auth-events.ts +++ b/src/stores/auth/auth-events.ts @@ -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" }; diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index 55789d25..83c39b7e 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -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(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") 判断 },