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
+193
View File
@@ -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` 为 trueUI 复用现有的 `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<void, AuthProvider>` 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 事件保留无 payloadprovider 由事件类型决定
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<void, "google" | "facebook">(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 | "facebook" | "google">(null);
const [error, setError] = useState<string | null>(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 用 isLoadingerror 用 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` actorXState v5 支持 promise actor 在浏览器环境跑
- ⚠️ `auth-facebook-panel` 失去"区分两个 spinner"的能力(已知取舍)
## [Testing]
- `npx tsc --noEmit`0 错误
- `pnpm lint`:通过
- 手动 smoke:点 Facebook / Google 按钮 → 状态机进入 `loadingOAuth` → NextAuth 重定向到 provider
- 检查:刷新 / 失败场景下 `errorMessage` 正确显示在 `<AuthErrorMessage />`
## [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``<AuthErrorMessage message={error} />` 改为 `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,可保留