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:
@@ -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<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 事件保留无 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<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 用 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` 正确显示在 `<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,可保留
|
||||
|
||||
Reference in New Issue
Block a user