feat(auth): migrate Flutter IAuthPlatform to Next.js 16 SDK service layer
Adds the third-party social login SDK integration layer (Google + Facebook),
mirroring the Flutter `IAuthPlatform` interface from
`lib/data/services/auth/`. The platform produces identity tokens only; callers
use the existing `authRepository.googleLogin / facebookLogin /
facebookIdLogin` (already migrated) to complete the login.
Architecture (event-bridge pattern):
- `IAuthPlatform` is plain TypeScript (no React dependency)
- `WebAuthPlatform` dispatches DOM `CustomEvent`s; `AuthPlatformBridge`
("use client" component) hosts the React SDK hooks and resolves promises
- `MockAuthPlatform` returns hardcoded tokens when
`NEXT_PUBLIC_USE_MOCK_AUTH=true` (mirrors Dart `kDebugMode` branch)
SDK choices:
- `@react-oauth/google@0.13.5` for Google
- `@greatsumini/react-facebook-login@3.4.0` (maintained fork of
archived `react-facebook`, React 19 compatible) for Facebook
- For Google: uses raw GIS `google.accounts.id.initialize` + `prompt` API
since `@react-oauth/google` does not expose an `id_token` flow variant
(existing `GoogleLoginRequest.idToken` field expects JWT id_token)
Files added:
- src/data/services/auth/{auth_platform,web_auth_platform,
mock_auth_platform,auth_event_bridge,auth_bridge_component,
initialize_google_auth,initialize_facebook_auth,global.d.ts,index}.ts(x)
- src/data/schemas/auth/facebook_user_data.ts (Zod schema)
- src/data/dto/auth/facebook_user_data.ts (DTO class)
Files modified:
- package.json, pnpm-lock.yaml: added 2 deps
- .env.example: 4 new NEXT_PUBLIC_* env vars
- barrelsby.json: registered src/data/services[/auth] directories
- implementation_plan.md: appended third-party auth section
- src/data/dto/{auth,chat,metrics,user}/index.ts: regenerated barrels
- src/data/schemas/{auth,chat,metrics,user}/index.ts: regenerated barrels
Files deleted:
- src/data/storage/index.ts: pre-existing TS2308 conflict (sync + async
`AuthStorage` both re-exported from auto-generated barrel; no consumer
uses the barrel — all imports use direct paths)
Scope (per user direction):
- Client SDK integration only; no React hook/context, no UI, no Apple
- Platform produces tokens; caller drives `authRepository` / `authApi`
- v1 single-popup semantics (concurrent popups overwrite)
- Bridge component is code-ready but NOT mounted in
`src/app/layout.tsx`; next-step UI migration wires the provider chain
Verified: `npx tsc --noEmit` (0 errors), `pnpm lint` (0 errors),
`pnpm build` (Next.js 16.2.7 + Turbopack compiled successfully).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+12
-6
@@ -1,17 +1,23 @@
|
||||
{
|
||||
"directory": [
|
||||
"./src/data/models",
|
||||
"./src/data/models/auth",
|
||||
"./src/data/models/chat",
|
||||
"./src/data/models/metrics",
|
||||
"./src/data/models/user",
|
||||
"./src/data/dto",
|
||||
"./src/data/dto/auth",
|
||||
"./src/data/dto/chat",
|
||||
"./src/data/dto/metrics",
|
||||
"./src/data/dto/user",
|
||||
"./src/data/schemas",
|
||||
"./src/data/schemas/auth",
|
||||
"./src/data/schemas/chat",
|
||||
"./src/data/schemas/metrics",
|
||||
"./src/data/schemas/user",
|
||||
"./src/data/api",
|
||||
"./src/data/storage",
|
||||
"./src/data/storage/auth",
|
||||
"./src/data/storage/user",
|
||||
"./src/data/storage/chat",
|
||||
"./src/data/storage/app",
|
||||
"./src/data/repositories",
|
||||
"./src/data/services",
|
||||
"./src/data/services/auth",
|
||||
"./src/design"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -229,3 +229,162 @@ pnpm add ofetch
|
||||
- `npx tsc --noEmit`
|
||||
- `pnpm lint`
|
||||
- 端到端 smoke test
|
||||
|
||||
---
|
||||
|
||||
# 后续迁移:第三方登录平台(IAuthPlatform)
|
||||
|
||||
## [Overview]
|
||||
|
||||
将 Flutter `IAuthPlatform`(`/Users/chase/Documents/cozsweet/lib/data/services/auth/`)迁移为 Next.js 16 项目的纯 TypeScript 服务层。SDK 选用 `@react-oauth/google`(Google Identity Services React 包装器)+ `@greatsumini/react-facebook-login`(`react-facebook` 的活跃 fork,兼容 React 19)。
|
||||
|
||||
**Flutter 端的 `AuthRepository` 已经完整迁移**(`src/data/repositories/auth_repository.ts`,含 `googleLogin`/`facebookLogin`/`facebookIdLogin`/`appleLogin`),本步只补齐"生产第三方 identity token"那一段——平台层产 token,调用方拿 token 去调 `authRepository`(或 `authApi`)完成登录。
|
||||
|
||||
**范围限定**:
|
||||
- 客户端 SDK 接入层,**不**写 React Context/useAuth,**不**写 UI
|
||||
- 跳过 Apple(Flutter 端本来就是 stub)
|
||||
- 平台**只产 token**,不调 `authRepository` / `authApi`
|
||||
- 保留 Mock(`NEXT_PUBLIC_USE_MOCK_AUTH=true` 切换,对齐 Dart `kDebugMode`)
|
||||
|
||||
## [Architecture: event-bridge 模式]
|
||||
|
||||
`IAuthPlatform` 必须是 plain TypeScript(不依赖 React),但 React 包装器 SDK(`useGoogleOAuth`、`FacebookLoginClient`)只能在 React 组件里用。解决方案是 **plain-TS 平台类 ↔ React Bridge 组件通过 DOM `CustomEvent` 通信**:
|
||||
|
||||
```
|
||||
WebAuthPlatform (plain TS) AuthPlatformBridge ("use client")
|
||||
┌──────────────────────┐ ┌──────────────────────────┐
|
||||
│ googleSignIn() │ │ │
|
||||
│ ├─ new Promise │ dispatchEvent │ useGoogleOAuth() │
|
||||
│ │ store requestId │ ────────────▶ │ window.google.accounts │
|
||||
│ │ → resolver │ │ .id.initialize({...}) │
|
||||
│ └─ return Promise │ │ on auth event: │
|
||||
│ │ resolveGoogle │ window.google.accounts│
|
||||
│ resolveGoogle( │ ◀──────────── │ .id.prompt() │
|
||||
│ requestId, result)│ │ GIS callback: │
|
||||
└──────────────────────┘ │ resolve the promise │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
- `WebAuthPlatform` 暴露公开方法 `resolveGoogle(requestId, result)` / `resolveFacebook(...)` 给 Bridge 回写 Promise
|
||||
- `AuthPlatformBridge` 监听 `cozsweet:auth:google:start` / `cozsweet:auth:facebook:start` 事件,触发对应 SDK 流程
|
||||
- **v1 限制**:单 popup 语义——一次只追踪一个待处理 Google / Facebook 请求;并发调用会被覆盖
|
||||
|
||||
## [Files]
|
||||
|
||||
### 新建(9 个文件)
|
||||
|
||||
| 路径 | 作用 |
|
||||
|---|---|
|
||||
| `src/data/services/auth/auth_platform.ts` | `IAuthPlatform` 接口 + `AuthPlatformFactory` + `getAuthPlatform()` 单例 + 命名类型 `GoogleCredential` / `FacebookLoginResult` |
|
||||
| `src/data/services/auth/web_auth_platform.ts` | 真机平台类(plain TS),通过 DOM 事件与 Bridge 通信;含 `resolveGoogle()` / `resolveFacebook()` 公开方法 |
|
||||
| `src/data/services/auth/mock_auth_platform.ts` | 调试用 Mock(返回固定 token + 250ms 模拟延迟) |
|
||||
| `src/data/services/auth/auth_event_bridge.ts` | 跨 plain-TS / React 边界的 DOM 事件通道(无 React 依赖;导出事件名常量 + dispatch / subscribe 助手) |
|
||||
| `src/data/services/auth/auth_bridge_component.tsx` | `"use client"` 组件,承载 `useGoogleOAuth` + `FacebookLoginClient`,监听事件并 resolve Promise |
|
||||
| `src/data/services/auth/initialize_google_auth.ts` | Google GIS 预热辅助(占位) |
|
||||
| `src/data/services/auth/initialize_facebook_auth.ts` | Facebook SDK 预热辅助(占位) |
|
||||
| `src/data/schemas/auth/facebook_user_data.ts` | Zod schema + `Input` / `Data` 类型 |
|
||||
| `src/data/dto/auth/facebook_user_data.ts` | `FacebookUserData` DTO 类(`from` / `fromJson` / `toJson`) |
|
||||
| `src/data/services/auth/global.d.ts` | `window.google.accounts.id.*` 的全局类型声明 |
|
||||
|
||||
### 修改
|
||||
|
||||
| 路径 | 变更 |
|
||||
|---|---|
|
||||
| `package.json` | 新增 `@react-oauth/google@0.13.5`、`@greatsumini/react-facebook-login@3.4.0` |
|
||||
| `.env.example` | 新增 `NEXT_PUBLIC_GOOGLE_CLIENT_ID`、`NEXT_PUBLIC_FACEBOOK_APP_ID`、`NEXT_PUBLIC_FACEBOOK_API_VERSION=v25.0`、`NEXT_PUBLIC_USE_MOCK_AUTH=false` |
|
||||
| `barrelsby.json` | 修整:删除已不存在的 `./src/data/models` 残留;新增 `./src/data/services` + `./src/data/services/auth` |
|
||||
|
||||
### 删除
|
||||
|
||||
| 路径 | 原因 |
|
||||
|---|---|
|
||||
| `src/data/storage/index.ts` | barrelsby 自动生成的 barrel,把同步 `AuthStorage`(`./auth_storage`)和异步 `AuthStorage`(`./auth/auth_storage`)同时 re-export,触发 TS2308 类型冲突。该 barrel 在仓库内**无消费者**(所有 import 都走直接路径 `@/data/storage/auth_storage` 或 `@/data/storage/auth/auth_storage`),可安全删除;后续若需要 barrel 须先解决命名冲突 |
|
||||
|
||||
## [Google Flow 决策(重要)]
|
||||
|
||||
`@react-oauth/google@0.13.5` 的 `useGoogleLogin(options)` 只支持两种 flow:
|
||||
- `flow: 'implicit'` → 回调 `TokenResponse`,含 `access_token`(**不是** id_token)
|
||||
- `flow: 'auth-code'` → 回调 `CodeResponse`,含 `code`(授权码,需后端交换)
|
||||
|
||||
**但** 后端 `GoogleLoginRequest.idToken: z.string()` 期望的是 JWT id_token(与 Flutter `GoogleSignInAccount.authentication.idToken` 字段同名同义),所以 `useGoogleLogin` 两种 flow 都不适配。
|
||||
|
||||
**采用方案**:直接调用 GIS 全局 API(在 Bridge 的 `useEffect` 中),由 `GoogleOAuthProvider` 加载 GIS 脚本后:
|
||||
- `window.google.accounts.id.initialize({ client_id, callback })` —— 注册 callback
|
||||
- `window.google.accounts.id.prompt()` —— 触发弹窗
|
||||
- callback 收到 `{ credential: '<JWT>' }`,`credential` 即 id_token
|
||||
|
||||
JWT payload 通过 `atob(parts[1])` 解析拿到 `email` / `name` / `picture`,填充 `GoogleCredential` 接口字段。
|
||||
|
||||
## [调用方契约(参考实现,**不在本步交付**)]
|
||||
|
||||
```ts
|
||||
import { getAuthPlatform } from "@/data/services/auth";
|
||||
import { authRepository } from "@/data/repositories";
|
||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||
|
||||
const platform = getAuthPlatform({
|
||||
googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
||||
facebookAppId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID!,
|
||||
});
|
||||
|
||||
async function googleLogin() {
|
||||
const r = await platform.googleSignIn();
|
||||
if (r.kind === "failure") return handleError(r.error);
|
||||
const guestId = (await AuthStorage.getInstance().getDeviceId()).value ?? "";
|
||||
await authRepository.googleLogin({ idToken: r.value.idToken, guestId });
|
||||
// token 持久化由 authRepository._saveLoginData 自动完成
|
||||
}
|
||||
|
||||
async function facebookLogin() {
|
||||
const r = await platform.facebookSignIn();
|
||||
if (r.kind === "failure") return handleError(r.error);
|
||||
await authRepository.facebookLogin({ accessToken: r.value.accessToken, guestId: "" });
|
||||
// 拉头像 + by-id 登录(与 Flutter AuthBloc._uploadFacebookAvatar 一致)
|
||||
const ud = await platform.getFacebookUserData();
|
||||
if (ud.kind === "success" && ud.value.id && ud.value.pictureUrl) {
|
||||
await AuthStorage.getInstance().setFacebookId(ud.value.id);
|
||||
await authRepository.facebookIdLogin({ fbId: ud.value.id, avatarUrl: ud.value.pictureUrl });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## [下一步:UI 挂载方式(留给下一轮迁移)]
|
||||
|
||||
本步**不**挂载到 `src/app/layout.tsx`。下一轮 UI 接入时按如下方式挂载:
|
||||
|
||||
```tsx
|
||||
// src/app/layout.tsx
|
||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
import { getAuthPlatform, AuthPlatformBridge } from "@/data/services/auth";
|
||||
|
||||
const platform = getAuthPlatform({
|
||||
googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
||||
facebookAppId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID!,
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}>
|
||||
<AuthPlatformBridge platform={platform}>
|
||||
{children}
|
||||
</AuthPlatformBridge>
|
||||
</GoogleOAuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## [Implementation Order]
|
||||
|
||||
1. **安装**:`pnpm add @react-oauth/google @greatsumini/react-facebook-login`
|
||||
2. **追加 env** 到 `.env.example`
|
||||
3. **修整 `barrelsby.json`**:删除 `./src/data/models` 残留、新增 `./src/data/services` 与 `./src/data/services/auth`
|
||||
4. **删除** `src/data/storage/index.ts`(命名冲突)
|
||||
5. **新建 Zod schema** + **DTO 类** for `FacebookUserData`
|
||||
6. **生成 barrel**:`pnpm generate-barrels`
|
||||
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`
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@greatsumini/react-facebook-login": "^3.4.0",
|
||||
"@react-oauth/google": "^0.13.5",
|
||||
"dexie": "^4.4.3",
|
||||
"next": "16.2.7",
|
||||
"ofetch": "^1.5.1",
|
||||
|
||||
Generated
+26
@@ -11,6 +11,12 @@ importers:
|
||||
'@fingerprintjs/fingerprintjs':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
'@greatsumini/react-facebook-login':
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0(react@19.2.4)
|
||||
'@react-oauth/google':
|
||||
specifier: ^0.13.5
|
||||
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
dexie:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
@@ -181,6 +187,11 @@ packages:
|
||||
'@fingerprintjs/fingerprintjs@5.2.0':
|
||||
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
|
||||
|
||||
'@greatsumini/react-facebook-login@3.4.0':
|
||||
resolution: {integrity: sha512-NJYqTOcDghZ6zLppC+LJVLep7Xi3gfx9ssQFRd7EhDTU07lC15SG/eZ3ZyvUPsuPBkbhVl6E/Py0w4M5m7Lh9Q==}
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -450,6 +461,12 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@react-oauth/google@0.13.5':
|
||||
resolution: {integrity: sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@rtsao/scc@1.1.0':
|
||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||
|
||||
@@ -2317,6 +2334,10 @@ snapshots:
|
||||
|
||||
'@fingerprintjs/fingerprintjs@5.2.0': {}
|
||||
|
||||
'@greatsumini/react-facebook-login@3.4.0(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -2500,6 +2521,11 @@ snapshots:
|
||||
|
||||
'@nolyfill/is-core-module@1.0.39': {}
|
||||
|
||||
'@react-oauth/google@0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Facebook 用户数据 DTO
|
||||
*
|
||||
* 原始 Dart: FacebookUserData (lib/data/services/auth/auth_platform.dart)
|
||||
*/
|
||||
import {
|
||||
FacebookUserDataSchema,
|
||||
type FacebookUserDataInput,
|
||||
type FacebookUserDataData,
|
||||
} from "@/data/schemas/auth/facebook_user_data";
|
||||
|
||||
export class FacebookUserData {
|
||||
declare readonly id: string | null;
|
||||
declare readonly name: string | null;
|
||||
declare readonly email: string | null;
|
||||
declare readonly pictureUrl: string | null;
|
||||
|
||||
private constructor(input: FacebookUserDataInput) {
|
||||
const data = FacebookUserDataSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: FacebookUserDataInput): FacebookUserData {
|
||||
return new FacebookUserData(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FacebookUserData {
|
||||
return FacebookUserData.from(json as FacebookUserDataInput);
|
||||
}
|
||||
|
||||
toJson(): FacebookUserDataData {
|
||||
return FacebookUserDataSchema.parse(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./apple_login_request";
|
||||
export * from "./facebook_login_request";
|
||||
export * from "./facebook_user_data";
|
||||
export * from "./fb_id_login_request";
|
||||
export * from "./google_login_request";
|
||||
export * from "./guest_login_request";
|
||||
export * from "./guest_login_response";
|
||||
export * from "./login_request";
|
||||
export * from "./login_response";
|
||||
export * from "./logout_response";
|
||||
export * from "./refresh_token_request";
|
||||
export * from "./refresh_token_response";
|
||||
export * from "./register_request";
|
||||
export * from "./send_code_request";
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./chat_history_response";
|
||||
export * from "./chat_message";
|
||||
export * from "./chat_send_response";
|
||||
export * from "./chat_sync_data";
|
||||
export * from "./chat_sync_request";
|
||||
export * from "./guest_chat_quota";
|
||||
export * from "./image_upload_response";
|
||||
export * from "./send_message_request";
|
||||
export * from "./stt_data";
|
||||
export * from "./sync_message";
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* DTO - 集中导出
|
||||
*
|
||||
* 不可变运行时类(API 层使用)
|
||||
* 原始 Dart: lib/data/models/
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./auth/apple_login_request";
|
||||
export * from "./auth/facebook_login_request";
|
||||
export * from "./auth/facebook_user_data";
|
||||
export * from "./auth/fb_id_login_request";
|
||||
export * from "./auth/google_login_request";
|
||||
export * from "./auth/guest_login_request";
|
||||
@@ -17,7 +16,6 @@ export * from "./auth/refresh_token_request";
|
||||
export * from "./auth/refresh_token_response";
|
||||
export * from "./auth/register_request";
|
||||
export * from "./auth/send_code_request";
|
||||
|
||||
export * from "./chat/chat_history_response";
|
||||
export * from "./chat/chat_message";
|
||||
export * from "./chat/chat_send_response";
|
||||
@@ -28,10 +26,8 @@ export * from "./chat/image_upload_response";
|
||||
export * from "./chat/send_message_request";
|
||||
export * from "./chat/stt_data";
|
||||
export * from "./chat/sync_message";
|
||||
|
||||
export * from "./metrics/app_event";
|
||||
export * from "./metrics/pwa_event";
|
||||
|
||||
export * from "./user/avatar_data";
|
||||
export * from "./user/credits_data";
|
||||
export * from "./user/credits_history_data";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./app_event";
|
||||
export * from "./pwa_event";
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./avatar_data";
|
||||
export * from "./credits_data";
|
||||
export * from "./credits_history_data";
|
||||
export * from "./personality_traits";
|
||||
export * from "./recent_memory";
|
||||
export * from "./update_profile_request";
|
||||
export * from "./user";
|
||||
export * from "./user_stats_response";
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Facebook 用户数据 schema
|
||||
* 原始 Dart: FacebookUserData (lib/data/services/auth/auth_platform.dart)
|
||||
*
|
||||
* 字段对齐 Dart 端 `String?` 可空语义:缺字段时存 `null`。
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const FacebookUserDataSchema = z.object({
|
||||
id: z.string().nullable().default(null),
|
||||
name: z.string().nullable().default(null),
|
||||
email: z.string().nullable().default(null),
|
||||
pictureUrl: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
export type FacebookUserDataInput = z.input<typeof FacebookUserDataSchema>;
|
||||
export type FacebookUserDataData = z.output<typeof FacebookUserDataSchema>;
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./apple_login_request";
|
||||
export * from "./facebook_login_request";
|
||||
export * from "./facebook_user_data";
|
||||
export * from "./fb_id_login_request";
|
||||
export * from "./google_login_request";
|
||||
export * from "./guest_login_request";
|
||||
export * from "./guest_login_response";
|
||||
export * from "./login_request";
|
||||
export * from "./login_response";
|
||||
export * from "./logout_response";
|
||||
export * from "./refresh_token_request";
|
||||
export * from "./refresh_token_response";
|
||||
export * from "./register_request";
|
||||
export * from "./send_code_request";
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./chat_history_response";
|
||||
export * from "./chat_message";
|
||||
export * from "./chat_send_response";
|
||||
export * from "./chat_sync_data";
|
||||
export * from "./chat_sync_request";
|
||||
export * from "./guest_chat_quota";
|
||||
export * from "./image_upload_response";
|
||||
export * from "./send_message_request";
|
||||
export * from "./stt_data";
|
||||
export * from "./sync_message";
|
||||
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Schemas - 集中导出
|
||||
*
|
||||
* Zod schema + 类型(Source of Truth)
|
||||
* 原始 Dart: lib/data/models/
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./auth/apple_login_request";
|
||||
export * from "./auth/facebook_login_request";
|
||||
export * from "./auth/facebook_user_data";
|
||||
export * from "./auth/fb_id_login_request";
|
||||
export * from "./auth/google_login_request";
|
||||
export * from "./auth/guest_login_request";
|
||||
@@ -17,7 +16,6 @@ export * from "./auth/refresh_token_request";
|
||||
export * from "./auth/refresh_token_response";
|
||||
export * from "./auth/register_request";
|
||||
export * from "./auth/send_code_request";
|
||||
|
||||
export * from "./chat/chat_history_response";
|
||||
export * from "./chat/chat_message";
|
||||
export * from "./chat/chat_send_response";
|
||||
@@ -28,10 +26,8 @@ export * from "./chat/image_upload_response";
|
||||
export * from "./chat/send_message_request";
|
||||
export * from "./chat/stt_data";
|
||||
export * from "./chat/sync_message";
|
||||
|
||||
export * from "./metrics/app_event";
|
||||
export * from "./metrics/pwa_event";
|
||||
|
||||
export * from "./user/avatar_data";
|
||||
export * from "./user/credits_data";
|
||||
export * from "./user/credits_history_data";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./app_event";
|
||||
export * from "./pwa_event";
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./avatar_data";
|
||||
export * from "./credits_data";
|
||||
export * from "./credits_history_data";
|
||||
export * from "./personality_traits";
|
||||
export * from "./recent_memory";
|
||||
export * from "./update_profile_request";
|
||||
export * from "./user";
|
||||
export * from "./user_stats_response";
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 第三方登录 Bridge 组件。
|
||||
*
|
||||
* 原始 Dart: lib/utils/google_auth_initializer.dart + lib/utils/facebook_auth_initializer.dart
|
||||
*
|
||||
* 设计:
|
||||
* - 不渲染任何 UI(`children` 透传)。
|
||||
* - 通过 `@react-oauth/google` 的 `GoogleOAuthProvider` 加载的 GIS 脚本,
|
||||
* 调用 `window.google.accounts.id.initialize` + `prompt` 实现 Google 登录弹窗;
|
||||
* callback 收到 id_token 后解析 JWT payload 拿到 email/displayName/photoUrl。
|
||||
* - 通过 `@greatsumini/react-facebook-login` 的 `FacebookLoginClient.init` +
|
||||
* `FacebookLoginClient.login` 实现 Facebook 登录弹窗。
|
||||
* - 监听 `auth_event_bridge` 派发的 `cozsweet:auth:*:start` 事件,触发对应
|
||||
* SDK 流程;拿到凭证后回调 `platform.resolveGoogle()` / `platform.resolveFacebook()`。
|
||||
* - 单 popup 语义:v1 一次只追踪一个待处理 Google / Facebook 请求,多个并发
|
||||
* 调用会被覆盖。后续如需并发支持,需把 ref 改为 `Map<requestId, resolver>`。
|
||||
*
|
||||
* 部署:本步骤**不**挂载到 `src/app/layout.tsx`。下一轮 UI 接入时按如下方式挂载:
|
||||
*
|
||||
* import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
* import { AuthPlatformBridge } from "@/data/services/auth";
|
||||
*
|
||||
* <GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}>
|
||||
* <AuthPlatformBridge platform={getAuthPlatform({...})}>
|
||||
* {children}
|
||||
* </AuthPlatformBridge>
|
||||
* </GoogleOAuthProvider>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGoogleOAuth } from "@react-oauth/google";
|
||||
import { FacebookLoginClient, LoginStatus } from "@greatsumini/react-facebook-login";
|
||||
import { Result, toError, type Result as ResultT } from "@/utils/result";
|
||||
import type {
|
||||
IAuthPlatform,
|
||||
IAuthPlatformOptions,
|
||||
GoogleCredential,
|
||||
FacebookLoginResult,
|
||||
} from "./auth_platform";
|
||||
import { AUTH_EVENTS, onAuthEvent } from "./auth_event_bridge";
|
||||
|
||||
/**
|
||||
* Bridge 要求 platform 是带 `opts` + `resolveGoogle` + `resolveFacebook` 的
|
||||
* WebAuthPlatform。结构化类型便于 mock / 子类扩展。
|
||||
*/
|
||||
export interface AuthPlatformBridgeProps {
|
||||
platform: IAuthPlatform & {
|
||||
readonly opts: IAuthPlatformOptions;
|
||||
resolveGoogle(
|
||||
requestId: string,
|
||||
result: ResultT<GoogleCredential>,
|
||||
): void;
|
||||
resolveFacebook(
|
||||
requestId: string,
|
||||
result: ResultT<FacebookLoginResult>,
|
||||
): void;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthPlatformBridge({
|
||||
platform,
|
||||
children,
|
||||
}: AuthPlatformBridgeProps) {
|
||||
const platformRef = useRef(platform);
|
||||
// 在 effect 中更新 ref(react-hooks/refs 规则禁止 render 期更新 ref.current)。
|
||||
// 这样后续 effect / callback 闭包总能拿到最新的 platform 引用。
|
||||
useEffect(() => {
|
||||
platformRef.current = platform;
|
||||
});
|
||||
|
||||
// ======== Google GIS ========
|
||||
|
||||
const { scriptLoadedSuccessfully } = useGoogleOAuth();
|
||||
const pendingGoogleRef = useRef<{ requestId: string } | null>(null);
|
||||
|
||||
// 初始化 GIS callback(一次性;GIS 全局只允许一个 id initialize 回调)
|
||||
useEffect(() => {
|
||||
if (!scriptLoadedSuccessfully) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const gis = window.google?.accounts?.id;
|
||||
if (!gis) return;
|
||||
|
||||
gis.initialize({
|
||||
client_id: platformRef.current.opts.googleClientId,
|
||||
callback: (response) => {
|
||||
const pending = pendingGoogleRef.current;
|
||||
pendingGoogleRef.current = null;
|
||||
if (!pending) return;
|
||||
if (response.credential) {
|
||||
const decoded = decodeJwtPayload(response.credential);
|
||||
platformRef.current.resolveGoogle(
|
||||
pending.requestId,
|
||||
Result.ok<GoogleCredential>({
|
||||
idToken: response.credential,
|
||||
email: decoded?.email ?? null,
|
||||
displayName: decoded?.name ?? null,
|
||||
photoUrl: decoded?.picture ?? null,
|
||||
}),
|
||||
);
|
||||
// 全局成功回调
|
||||
platformRef.current.opts.onGoogleSignInSuccess?.(
|
||||
response.credential,
|
||||
decoded?.email ?? null,
|
||||
);
|
||||
} else {
|
||||
platformRef.current.resolveGoogle(
|
||||
pending.requestId,
|
||||
Result.err<GoogleCredential>(
|
||||
new Error("Google sign-in failed: no credential"),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [scriptLoadedSuccessfully]);
|
||||
|
||||
// 监听 googleStart 事件 → 触发 prompt
|
||||
useEffect(() => {
|
||||
return onAuthEvent(AUTH_EVENTS.googleStart, (detail) => {
|
||||
pendingGoogleRef.current = { requestId: detail.requestId };
|
||||
const gis =
|
||||
typeof window !== "undefined" ? window.google?.accounts?.id : undefined;
|
||||
if (gis) {
|
||||
gis.prompt();
|
||||
} else {
|
||||
// GIS 未加载完成:立即 reject
|
||||
pendingGoogleRef.current = null;
|
||||
platformRef.current.resolveGoogle(
|
||||
detail.requestId,
|
||||
Result.err<GoogleCredential>(
|
||||
new Error("Google Identity Services script not loaded"),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ======== Facebook SDK ========
|
||||
|
||||
const pendingFacebookRef = useRef<{ requestId: string } | null>(null);
|
||||
|
||||
// 初始化 Facebook SDK(一次性)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
// 版本联合类型截止 v21.0,但底层 FB JS SDK 支持 v25.0;用类型断言绕过。
|
||||
FacebookLoginClient.init({
|
||||
appId: platformRef.current.opts.facebookAppId,
|
||||
version: (platformRef.current.opts.facebookApiVersion ??
|
||||
"v25.0") as "v21.0",
|
||||
cookie: true,
|
||||
xfbml: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("[auth] FacebookLoginClient.init failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听 facebookStart 事件 → 触发 login
|
||||
useEffect(() => {
|
||||
return onAuthEvent(AUTH_EVENTS.facebookStart, (detail) => {
|
||||
pendingFacebookRef.current = { requestId: detail.requestId };
|
||||
try {
|
||||
FacebookLoginClient.login(
|
||||
(response) => {
|
||||
const pending = pendingFacebookRef.current;
|
||||
pendingFacebookRef.current = null;
|
||||
if (!pending) return;
|
||||
if (
|
||||
response.status === LoginStatus.Connected &&
|
||||
response.authResponse
|
||||
) {
|
||||
const expiresIn = Number(response.authResponse.expiresIn);
|
||||
const expiresAt = Number.isFinite(expiresIn)
|
||||
? Date.now() + expiresIn * 1000
|
||||
: null;
|
||||
platformRef.current.resolveFacebook(
|
||||
pending.requestId,
|
||||
Result.ok<FacebookLoginResult>({
|
||||
status: "success",
|
||||
accessToken: response.authResponse.accessToken,
|
||||
userId: response.authResponse.userID,
|
||||
expiresAt,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// NotAuthorized / Unknown / loginCancelled / facebookNotLoaded
|
||||
platformRef.current.resolveFacebook(
|
||||
pending.requestId,
|
||||
Result.ok<FacebookLoginResult>({
|
||||
status: "cancelled",
|
||||
accessToken: "",
|
||||
userId: "",
|
||||
expiresAt: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
{ scope: "public_profile,email" },
|
||||
);
|
||||
} catch (e) {
|
||||
const pending = pendingFacebookRef.current;
|
||||
pendingFacebookRef.current = null;
|
||||
if (pending) {
|
||||
platformRef.current.resolveFacebook(
|
||||
pending.requestId,
|
||||
Result.err<FacebookLoginResult>(toError(e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// ======== Helpers ========
|
||||
|
||||
/**
|
||||
* 解码 JWT payload 段。不做签名校验——签名由后端校验。
|
||||
* 返回 null 表示解码失败。
|
||||
*/
|
||||
function decodeJwtPayload(
|
||||
idToken: string,
|
||||
): { email?: string; name?: string; picture?: string } | null {
|
||||
try {
|
||||
const parts = idToken.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
||||
const decoded = atob(padded);
|
||||
return JSON.parse(decoded) as {
|
||||
email?: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 跨 plain-TS / React 边界的 DOM 事件通道。
|
||||
*
|
||||
* 用途:
|
||||
* - `WebAuthPlatform`(plain TS class,挂在 `getAuthPlatform()` 单例上)dispatch
|
||||
* `cozsweet:auth:*:start` 事件,并等待 `AuthPlatformBridge`("use client" 组件)
|
||||
* 调回 `platform.resolveGoogle()` / `platform.resolveFacebook()` 完成 Promise。
|
||||
* - 这样既满足"接口保持 plain TS、可以被非 React 上下文消费"的需求,又让
|
||||
* `@react-oauth/google` 的 `GoogleOAuthProvider`(脚本加载)和
|
||||
* `window.google.accounts.id.*` GIS API 都在 React 侧执行。
|
||||
*
|
||||
* 本文件不依赖 React,可以被 `WebAuthPlatform`(无 React 引用)安全 import。
|
||||
*/
|
||||
export const AUTH_EVENTS = {
|
||||
googleStart: "cozsweet:auth:google:start",
|
||||
facebookStart: "cozsweet:auth:facebook:start",
|
||||
} as const;
|
||||
|
||||
export type AuthEventName = (typeof AUTH_EVENTS)[keyof typeof AUTH_EVENTS];
|
||||
|
||||
/** Payload attached to *:start events. */
|
||||
export interface AuthStartDetail {
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export function dispatchAuthEvent(
|
||||
name: AuthEventName,
|
||||
detail: AuthStartDetail,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent<AuthStartDetail>(name, { detail }));
|
||||
}
|
||||
|
||||
export function onAuthEvent(
|
||||
name: AuthEventName,
|
||||
handler: (detail: AuthStartDetail) => void,
|
||||
): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const listener = (e: Event) => {
|
||||
const detail = (e as CustomEvent<AuthStartDetail>).detail;
|
||||
handler(detail);
|
||||
};
|
||||
window.addEventListener(name, listener);
|
||||
return () => window.removeEventListener(name, listener);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 第三方登录平台抽象接口 + 工厂 + 单例
|
||||
*
|
||||
* 原始 Dart: lib/data/services/auth/auth_platform.dart
|
||||
*
|
||||
* 设计:
|
||||
* - `IAuthPlatform` 是 plain TypeScript 接口(不依赖 React)。
|
||||
* - 真机实现 `WebAuthPlatform` 通过 DOM 事件与 `AuthPlatformBridge`("use client"
|
||||
* 组件)通信,由 Bridge 持有 React SDK 钩子。
|
||||
* - Mock 实现 `MockAuthPlatform` 跳过 SDK,直接返回固定 token。
|
||||
* - 工厂根据 `NEXT_PUBLIC_USE_MOCK_AUTH` 环境变量二选一(对齐 Dart `kDebugMode`)。
|
||||
*
|
||||
* 调用方契约:
|
||||
* - 平台**只产 token**,不调 `authRepository` / `authApi`。
|
||||
* - 拿到 `Result<GoogleCredential>` / `Result<FacebookLoginResult>` 后,
|
||||
* 由调用方自行 `authRepository.googleLogin({ idToken })` 完成登录。
|
||||
*/
|
||||
import { Result, type Result as ResultT } from "@/utils/result";
|
||||
import type { FacebookUserData } from "@/data/dto/auth/facebook_user_data";
|
||||
import { WebAuthPlatform } from "./web_auth_platform";
|
||||
import { MockAuthPlatform } from "./mock_auth_platform";
|
||||
|
||||
/** 归一化的 Google 凭证(id_token + 解析自 JWT payload 的展示字段)。 */
|
||||
export interface GoogleCredential {
|
||||
readonly idToken: string;
|
||||
readonly email: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly photoUrl: string | null;
|
||||
}
|
||||
|
||||
/** 归一化的 Facebook 登录结果。 */
|
||||
export interface FacebookLoginResult {
|
||||
readonly status: "success" | "cancelled" | "error";
|
||||
readonly accessToken: string;
|
||||
readonly userId: string;
|
||||
/** epoch ms;null 表示后端未返回 expiresIn。 */
|
||||
readonly expiresAt: number | null;
|
||||
}
|
||||
|
||||
export interface IAuthPlatform {
|
||||
/** Google 登录。Web 平台通过 GIS 弹窗流程。 */
|
||||
googleSignIn(): Promise<ResultT<GoogleCredential>>;
|
||||
/** 是否支持 Google "authenticate" 流程。Web 平台恒为 false。 */
|
||||
supportsGoogleAuthenticate(): boolean;
|
||||
/** Facebook 登录。 */
|
||||
facebookSignIn(): Promise<ResultT<FacebookLoginResult>>;
|
||||
/** 拉取 Facebook 用户数据(id / name / email / pictureUrl)。 */
|
||||
getFacebookUserData(): Promise<ResultT<FacebookUserData>>;
|
||||
}
|
||||
|
||||
export interface IAuthPlatformOptions {
|
||||
readonly googleClientId: string;
|
||||
readonly facebookAppId: string;
|
||||
/** Facebook Graph API 版本,默认 "v25.0"(对齐 Flutter 端)。 */
|
||||
readonly facebookApiVersion?: string;
|
||||
/** 全局 Google 登录成功回调(对齐 Dart `onGoogleSignInSuccess`)。 */
|
||||
readonly onGoogleSignInSuccess?: (
|
||||
idToken: string,
|
||||
email: string | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export class AuthPlatformFactory {
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* 根据 `NEXT_PUBLIC_USE_MOCK_AUTH` 二选一返回平台实现。
|
||||
* 对齐 Dart `AuthPlatformFactory.create()` 的 `kDebugMode` 分支。
|
||||
*/
|
||||
static create(opts: IAuthPlatformOptions): IAuthPlatform {
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK_AUTH === "true") {
|
||||
return new MockAuthPlatform();
|
||||
}
|
||||
return new WebAuthPlatform(opts);
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶层单例(懒加载)。 */
|
||||
let _instance: IAuthPlatform | null = null;
|
||||
export function getAuthPlatform(opts: IAuthPlatformOptions): IAuthPlatform {
|
||||
if (!_instance) _instance = AuthPlatformFactory.create(opts);
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/** @internal 测试用:重置单例。 */
|
||||
export function _resetAuthPlatformForTests(): void {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
// re-export 让 barrelsby 抓得到
|
||||
export { Result };
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 全局类型声明:Google Identity Services (`gis`) 添加的 `window.google` 全局对象。
|
||||
*
|
||||
* 由 `<GoogleOAuthProvider>` / `useGoogleOAuth()` 注入的 GIS 脚本会向 `window`
|
||||
* 挂载 `google.accounts.id` / `google.accounts.oauth2` 等命名空间。本文件让
|
||||
* TypeScript 知道这些属性的存在,避免在 Bridge 组件中用 `as unknown as ...`
|
||||
* 强转。
|
||||
*
|
||||
* 仅声明我们用到的子集;未列出的 GIS 属性走 `unknown`。
|
||||
*/
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts?: {
|
||||
id?: {
|
||||
initialize(config: {
|
||||
client_id: string;
|
||||
callback: (response: { credential?: string }) => void;
|
||||
auto_select?: boolean;
|
||||
cancel_on_tap_outside?: boolean;
|
||||
itp_support?: boolean;
|
||||
login_hint?: string;
|
||||
hosted_domain?: string;
|
||||
use_fedcm_for_prompt?: boolean;
|
||||
nonce?: string;
|
||||
state_cookie_domain?: string;
|
||||
prompt_parent_id?: string;
|
||||
}): void;
|
||||
prompt(): void;
|
||||
renderButton(
|
||||
parent: HTMLElement,
|
||||
options: {
|
||||
type?: "standard" | "icon";
|
||||
theme?: "outline" | "filled_blue" | "filled_black";
|
||||
size?: "large" | "medium" | "small";
|
||||
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
|
||||
shape?: "rectangular" | "pill" | "circle" | "square";
|
||||
logo_alignment?: "left" | "center";
|
||||
width?: string | number;
|
||||
},
|
||||
): void;
|
||||
cancel(): void;
|
||||
};
|
||||
oauth2?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./auth_bridge_component";
|
||||
export * from "./auth_event_bridge";
|
||||
export * from "./auth_platform";
|
||||
export * from "./global";
|
||||
export * from "./initialize_facebook_auth";
|
||||
export * from "./initialize_google_auth";
|
||||
export * from "./mock_auth_platform";
|
||||
export * from "./web_auth_platform";
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Facebook SDK 预热辅助。
|
||||
*
|
||||
* 原始 Dart: `lib/utils/facebook_auth_initializer.dart` 的 `initializeFacebookAuth()`
|
||||
*
|
||||
* 行为:
|
||||
* - 对齐 Dart `FacebookAuth.i.webAndDesktopInitialize(appId, cookie: true,
|
||||
* xfbml: true, version: 'v25.0')` 的参数语义。
|
||||
* - 真实 SDK 初始化(注入 `<div id="facebook-jssdk">` + 调用 `FB.init`)由
|
||||
* `@greatsumini/react-facebook-login` 的 `FacebookLoginClient.init()` 完成,
|
||||
* 见 `auth_bridge_component.tsx`。本函数仅做存在性校验 + console.warn。
|
||||
*/
|
||||
export function initializeFacebookAuth(
|
||||
appId: string,
|
||||
version: string = "v25.0",
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!appId) {
|
||||
console.warn(
|
||||
"[auth] initializeFacebookAuth: NEXT_PUBLIC_FACEBOOK_APP_ID is not set",
|
||||
);
|
||||
}
|
||||
// version 占位:实际由 FacebookLoginClient.init({ version }) 消费
|
||||
void version;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Google OAuth 预热辅助。
|
||||
*
|
||||
* 原始 Dart: `lib/utils/google_auth_initializer.dart` 的 `initializeGoogleAuth()`
|
||||
*
|
||||
* 行为:
|
||||
* - `@react-oauth/google` 的 `<GoogleOAuthProvider>` 已经负责加载 GIS 脚本;
|
||||
* 本函数在 script 加载完成时记录一条调试日志(如果 env 缺失则 warn)。
|
||||
* - 不发起任何网络请求;副作用仅限 console。
|
||||
*
|
||||
* 部署:本文件对应 Dart 端 `initializeGoogleAuth()` 调用位点;
|
||||
* 真实使用时机由调用方在 `useEffect` 中安排,不需要在模块顶层自动执行。
|
||||
*/
|
||||
export function initializeGoogleAuth(clientId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!clientId) {
|
||||
console.warn(
|
||||
"[auth] initializeGoogleAuth: NEXT_PUBLIC_GOOGLE_CLIENT_ID is not set",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 模拟第三方登录平台。
|
||||
*
|
||||
* 原始 Dart: lib/data/services/auth/mock_auth_platform.dart
|
||||
*
|
||||
* 在 `NEXT_PUBLIC_USE_MOCK_AUTH=true` 时由 `AuthPlatformFactory` 注入。
|
||||
* 返回固定的 idToken / accessToken / 用户信息,附 250ms 模拟延迟便于观察 loading
|
||||
* 状态。
|
||||
*/
|
||||
import { Result, type Result as ResultT } from "@/utils/result";
|
||||
import { FacebookUserData } from "@/data/dto/auth/facebook_user_data";
|
||||
import type {
|
||||
IAuthPlatform,
|
||||
GoogleCredential,
|
||||
FacebookLoginResult,
|
||||
} from "./auth_platform";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export class MockAuthPlatform implements IAuthPlatform {
|
||||
constructor(private readonly simulatedDelayMs: number = 250) {}
|
||||
|
||||
async googleSignIn(): Promise<ResultT<GoogleCredential>> {
|
||||
await sleep(this.simulatedDelayMs);
|
||||
return Result.ok({
|
||||
idToken: `mock_google_id_token_${Date.now()}`,
|
||||
email: "mock@example.com",
|
||||
displayName: "Mock User",
|
||||
photoUrl: "https://via.placeholder.com/150",
|
||||
});
|
||||
}
|
||||
|
||||
supportsGoogleAuthenticate(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async facebookSignIn(): Promise<ResultT<FacebookLoginResult>> {
|
||||
await sleep(this.simulatedDelayMs);
|
||||
return Result.ok({
|
||||
status: "success",
|
||||
accessToken: `mock_facebook_token_${Date.now()}`,
|
||||
userId: "mock_facebook_user_id",
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
});
|
||||
}
|
||||
|
||||
async getFacebookUserData(): Promise<ResultT<FacebookUserData>> {
|
||||
await sleep(this.simulatedDelayMs);
|
||||
return Result.ok(
|
||||
FacebookUserData.from({
|
||||
id: "mock_facebook_id",
|
||||
name: "Mock User",
|
||||
email: "mock@example.com",
|
||||
pictureUrl: "https://via.placeholder.com/150",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Web 平台真机实现。
|
||||
*
|
||||
* 原始 Dart: lib/data/services/auth/auth_platform_web.dart
|
||||
*
|
||||
* 类本身是 plain TS,不调用 React 钩子;通过 `auth_event_bridge` 派发
|
||||
* `cozsweet:auth:*:start` 事件,由 `AuthPlatformBridge` 持有 GIS / FB SDK 钩子
|
||||
* 并在拿到凭证后回调 `platform.resolveGoogle()` / `platform.resolveFacebook()`。
|
||||
*
|
||||
* SSR 安全:
|
||||
* - 构造函数、supportsGoogleAuthenticate() 在 server 端可用;
|
||||
* - 业务方法在 SSR 下直接返回 failure(与 Flutter WebAuthPlatform 一致:
|
||||
* 必须由用户点击触发弹窗,服务器侧无法完成 OAuth)。
|
||||
*/
|
||||
import { Result, toError, type Result as ResultT } from "@/utils/result";
|
||||
import { FacebookUserData } from "@/data/dto/auth/facebook_user_data";
|
||||
import {
|
||||
AUTH_EVENTS,
|
||||
dispatchAuthEvent,
|
||||
} from "./auth_event_bridge";
|
||||
import type {
|
||||
IAuthPlatform,
|
||||
IAuthPlatformOptions,
|
||||
GoogleCredential,
|
||||
FacebookLoginResult,
|
||||
} from "./auth_platform";
|
||||
|
||||
const TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
interface PendingResolver<T> {
|
||||
resolve: (r: ResultT<T>) => void;
|
||||
}
|
||||
|
||||
export class WebAuthPlatform implements IAuthPlatform {
|
||||
readonly opts: IAuthPlatformOptions;
|
||||
private readonly pendingGoogle = new Map<
|
||||
string,
|
||||
PendingResolver<GoogleCredential>
|
||||
>();
|
||||
private readonly pendingFacebook = new Map<
|
||||
string,
|
||||
PendingResolver<FacebookLoginResult>
|
||||
>();
|
||||
private nextId = 0;
|
||||
|
||||
constructor(opts: IAuthPlatformOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
/** 由 AuthPlatformBridge 在拿到 GIS 凭证后回调。 */
|
||||
resolveGoogle(
|
||||
requestId: string,
|
||||
result: ResultT<GoogleCredential>,
|
||||
): void {
|
||||
const pending = this.pendingGoogle.get(requestId);
|
||||
if (pending) {
|
||||
pending.resolve(result);
|
||||
this.pendingGoogle.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 由 AuthPlatformBridge 在拿到 FB login response 后回调。 */
|
||||
resolveFacebook(
|
||||
requestId: string,
|
||||
result: ResultT<FacebookLoginResult>,
|
||||
): void {
|
||||
const pending = this.pendingFacebook.get(requestId);
|
||||
if (pending) {
|
||||
pending.resolve(result);
|
||||
this.pendingFacebook.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async googleSignIn(): Promise<ResultT<GoogleCredential>> {
|
||||
if (typeof window === "undefined") {
|
||||
return Result.err(new Error("googleSignIn requires a browser"));
|
||||
}
|
||||
if (!this.opts.googleClientId) {
|
||||
return Result.err(
|
||||
new Error("NEXT_PUBLIC_GOOGLE_CLIENT_ID is not set"),
|
||||
);
|
||||
}
|
||||
const requestId = this.allocId();
|
||||
return new Promise<ResultT<GoogleCredential>>((resolve) => {
|
||||
this.pendingGoogle.set(requestId, { resolve });
|
||||
dispatchAuthEvent(AUTH_EVENTS.googleStart, { requestId });
|
||||
setTimeout(() => {
|
||||
if (this.pendingGoogle.has(requestId)) {
|
||||
this.pendingGoogle.delete(requestId);
|
||||
resolve(Result.err(new Error("Google sign-in timed out")));
|
||||
}
|
||||
}, TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Web 平台使用 GIS 弹窗流程,`google.accounts.id.prompt()`,不调用
|
||||
* "authenticate" API。返回 false 以对齐 Dart `WebAuthPlatform` 行为。
|
||||
*/
|
||||
supportsGoogleAuthenticate(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async facebookSignIn(): Promise<ResultT<FacebookLoginResult>> {
|
||||
if (typeof window === "undefined") {
|
||||
return Result.err(new Error("facebookSignIn requires a browser"));
|
||||
}
|
||||
if (!this.opts.facebookAppId) {
|
||||
return Result.err(
|
||||
new Error("NEXT_PUBLIC_FACEBOOK_APP_ID is not set"),
|
||||
);
|
||||
}
|
||||
const requestId = this.allocId();
|
||||
return new Promise<ResultT<FacebookLoginResult>>((resolve) => {
|
||||
this.pendingFacebook.set(requestId, { resolve });
|
||||
dispatchAuthEvent(AUTH_EVENTS.facebookStart, { requestId });
|
||||
setTimeout(() => {
|
||||
if (this.pendingFacebook.has(requestId)) {
|
||||
this.pendingFacebook.delete(requestId);
|
||||
resolve(Result.err(new Error("Facebook sign-in timed out")));
|
||||
}
|
||||
}, TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取 Facebook 用户数据。
|
||||
*
|
||||
* 与 Dart `WebAuthPlatform.getFacebookUserData()` 对齐:调用 FB JS SDK 的
|
||||
* `FB.api('/me', { fields })`。前提是 Bridge 已经 `FacebookLoginClient.init`
|
||||
* 初始化过 SDK(`window.FB` 全局存在)。若 SDK 未加载则返回 failure。
|
||||
*/
|
||||
async getFacebookUserData(): Promise<ResultT<FacebookUserData>> {
|
||||
if (typeof window === "undefined") {
|
||||
return Result.err(
|
||||
new Error("getFacebookUserData requires a browser"),
|
||||
);
|
||||
}
|
||||
const FB = (window as unknown as { FB?: unknown }).FB;
|
||||
if (!FB) {
|
||||
return Result.err(
|
||||
new Error(
|
||||
"Facebook SDK not loaded; call facebookSignIn() first or mount <AuthPlatformBridge>",
|
||||
),
|
||||
);
|
||||
}
|
||||
return new Promise<ResultT<FacebookUserData>>((resolve) => {
|
||||
try {
|
||||
(window as unknown as {
|
||||
FB: {
|
||||
api: (
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
cb: (resp: Record<string, unknown>) => void,
|
||||
) => void;
|
||||
};
|
||||
}).FB.api(
|
||||
"/me",
|
||||
{ fields: "id,name,email,picture" },
|
||||
(resp) => {
|
||||
if (!resp || (resp as { error?: unknown }).error) {
|
||||
resolve(Result.err(new Error("FB.api /me failed")));
|
||||
return;
|
||||
}
|
||||
const r = resp as {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
picture?: { data?: { url?: string } };
|
||||
};
|
||||
resolve(
|
||||
Result.ok(
|
||||
FacebookUserData.from({
|
||||
id: r.id ?? null,
|
||||
name: r.name ?? null,
|
||||
email: r.email ?? null,
|
||||
pictureUrl: r.picture?.data?.url ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
resolve(Result.err(toError(e)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private allocId(): string {
|
||||
this.nextId += 1;
|
||||
return `auth-${Date.now()}-${this.nextId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./auth/auth_bridge_component";
|
||||
export * from "./auth/auth_event_bridge";
|
||||
export * from "./auth/auth_platform";
|
||||
export * from "./auth/global";
|
||||
export * from "./auth/initialize_facebook_auth";
|
||||
export * from "./auth/initialize_google_auth";
|
||||
export * from "./auth/mock_auth_platform";
|
||||
export * from "./auth/web_auth_platform";
|
||||
Reference in New Issue
Block a user