refactor(splash): convert Skip to explicit guest login flow
- Splash Skip button now dispatches `AuthGuestLoginSubmitted` instead of
direct routing, keeping guest auth under the state machine
- Update PWA install dialog copy ("Add to Home Screen") and drop favicon
entry from manifest icons
- Add debug logging and routing sequence docs to splash-button
This commit is contained in:
@@ -10,12 +10,6 @@
|
|||||||
"theme_color": "#f84d96",
|
"theme_color": "#f84d96",
|
||||||
"lang": "en",
|
"lang": "en",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
|
||||||
"src": "/favicon.ico",
|
|
||||||
"sizes": "any",
|
|
||||||
"type": "image/x-icon",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"src": "/images/splash/Icon-192.png",
|
"src": "/images/splash/Icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
*/
|
*/
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { AppConstants } from "@/core/constants/app_constants";
|
|
||||||
|
|
||||||
import styles from "./pwa-install-dialog.module.css";
|
import styles from "./pwa-install-dialog.module.css";
|
||||||
|
|
||||||
export interface PwaInstallDialogProps {
|
export interface PwaInstallDialogProps {
|
||||||
@@ -48,10 +46,10 @@ export function PwaInstallDialog({ onClose, onInstall }: PwaInstallDialogProps)
|
|||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<h2 id="pwa-dialog-title" className={styles.title}>
|
<h2 id="pwa-dialog-title" className={styles.title}>
|
||||||
Install {AppConstants.appTitle}
|
Add to Home Screen
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.content}>
|
<p className={styles.content}>
|
||||||
{AppConstants.appTitle} can be installed on your device for a faster, full-screen experience.
|
Add CozSweet to Home Screen{"\n"}for the best experience
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
* Splash 底部按钮组
|
* Splash 底部按钮组
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/splash/widgets/splash_button.dart
|
* 原始 Dart: lib/ui/splash/widgets/splash_button.dart
|
||||||
* `Row(children: [Skip 文本, SizedBox 26, Facebook 登录按钮])`
|
* `Row(children: [Skip 文本, SizedBox 26, 社交登录按钮])`
|
||||||
*
|
*
|
||||||
* 本组件是 splash 鉴权流程的**自治单元**:
|
* 本组件是 splash 鉴权流程的**自治单元**:
|
||||||
* - 消费 `useSession()`(next-auth/react)监听 NextAuth session
|
* - 消费 `useSession()`(next-auth/react)监听 NextAuth session
|
||||||
* - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录 + OAuth 流程统一收口到状态机)
|
* - 消费 `useAuthState` / `useAuthDispatch`(邮箱登录 + OAuth 流程统一收口到状态机)
|
||||||
* - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
|
* - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
|
||||||
* - 自管路由跳转(已登录 / 登录成功 → /chat)
|
* - 自管路由跳转(已登录 / 登录成功 → /chat)
|
||||||
* - Skip 用 `<Link>` 保留 SPA 体验
|
* - **Skip 按钮 = 显式游客登录入口**(派发 `AuthGuestLoginSubmitted`,不是路由跳转)
|
||||||
* - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth
|
* - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth
|
||||||
|
*
|
||||||
|
* 跳转时序:
|
||||||
|
* 1. 派发事件(如 `AuthGuestLoginSubmitted`)
|
||||||
|
* 2. auth machine 进 loading state → API 调用 → onDone → state.isSuccess = true
|
||||||
|
* 3. useEffect 监听到 isSuccess → 调 chat/user store 初始化 + router.replace("/chat")
|
||||||
*/
|
*/
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
@@ -41,16 +45,28 @@ export function SplashButton() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const wasSuccess = useRef(false);
|
const wasSuccess = useRef(false);
|
||||||
|
|
||||||
// 邮箱登录成功跳 /chat + 通知 chat/user 初始化
|
// 邮箱/OAuth/游客登录成功跳 /chat + 通知 chat/user 初始化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// DEBUG:isSuccess 状态透明化
|
||||||
|
console.log("[splash-button] useEffect state.isSuccess", {
|
||||||
|
isSuccess: state.isSuccess,
|
||||||
|
wasSuccess: wasSuccess.current,
|
||||||
|
willRedirect: state.isSuccess && !wasSuccess.current,
|
||||||
|
});
|
||||||
if (state.isSuccess && !wasSuccess.current) {
|
if (state.isSuccess && !wasSuccess.current) {
|
||||||
wasSuccess.current = true;
|
wasSuccess.current = true;
|
||||||
chatDispatch({ type: "ChatAuthStatusChanged" });
|
chatDispatch({ type: "ChatAuthStatusChanged" });
|
||||||
userDispatch({ type: "UserInit" });
|
userDispatch({ type: "UserInit" });
|
||||||
|
console.log("[splash-button] useEffect → router.replace(/chat) [via isSuccess]");
|
||||||
router.replace(ROUTES.chat);
|
router.replace(ROUTES.chat);
|
||||||
}
|
}
|
||||||
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
||||||
|
|
||||||
|
// Skip = 显式游客登录入口("以游客身份继续")
|
||||||
|
const handleSkip = () => {
|
||||||
|
authDispatch({ type: "AuthGuestLoginSubmitted" });
|
||||||
|
};
|
||||||
|
|
||||||
const handleFacebookLogin = () => {
|
const handleFacebookLogin = () => {
|
||||||
authDispatch({
|
authDispatch({
|
||||||
type: "AuthFacebookLoginSubmitted",
|
type: "AuthFacebookLoginSubmitted",
|
||||||
@@ -60,16 +76,19 @@ export function SplashButton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
{/* Skip 文本链接(next/link 渲染为 <a>,保留 SPA 体验) */}
|
{/* Skip 按钮 = 显式游客登录入口(派发 AuthGuestLoginSubmitted,**不**做路由跳转) */}
|
||||||
<Link
|
<button
|
||||||
href={ROUTES.chat}
|
type="button"
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={isLoading}
|
||||||
className={styles.skip}
|
className={styles.skip}
|
||||||
aria-disabled={isLoading}
|
|
||||||
tabIndex={isLoading ? -1 : 0}
|
|
||||||
style={isLoading ? { pointerEvents: "none", opacity: 0.5 } : undefined}
|
|
||||||
>
|
>
|
||||||
Skip
|
{isLoading ? (
|
||||||
</Link>
|
<LoadingIndicator color="#ffffff" />
|
||||||
|
) : (
|
||||||
|
"Skip"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className={styles.separator} aria-hidden="true" />
|
<div className={styles.separator} aria-hidden="true" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||||
|
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
|
import { ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
import { SplashBackground } from "./splash-background";
|
import { SplashBackground } from "./splash-background";
|
||||||
import { SplashLogo } from "./splash-logo";
|
import { SplashLogo } from "./splash-logo";
|
||||||
@@ -7,6 +14,49 @@ import { SplashButton } from "./splash-button";
|
|||||||
import styles from "./splash-screen.module.css";
|
import styles from "./splash-screen.module.css";
|
||||||
|
|
||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 启动时检查 localStorage 里的 token:
|
||||||
|
// - loginToken(OAuth/email sync 写下的)→ 视为"已登录"跳 /chat
|
||||||
|
// - guestToken(用户**显式**走"游客模式"留下的)→ 同样视为"已登录"跳 /chat
|
||||||
|
// - 都没有 → 留在 splash(**不**自动创建游客账号 —— 见 auth-machine.ts 的 checkAuthStatusActor)
|
||||||
|
//
|
||||||
|
// proxy 读不到 localStorage(Edge runtime),所以这条 fallback 必须在 Client 侧。
|
||||||
|
//
|
||||||
|
// DEBUG:client 端**不**用 Logger(pino 引入会爆 client bundle),改用 console.log
|
||||||
|
// 临时调试,bug 修完即删。
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const storage = AuthStorage.getInstance();
|
||||||
|
// 用 getXxxToken() 拿 string | null(不是 hasXxxToken() 拿 boolean)
|
||||||
|
const [loginR, guestR] = await Promise.all([
|
||||||
|
storage.getLoginToken(),
|
||||||
|
storage.getGuestToken(),
|
||||||
|
]);
|
||||||
|
const loginTok = loginR.success ? loginR.data : null;
|
||||||
|
const guestTok = guestR.success ? guestR.data : null;
|
||||||
|
const hasAny =
|
||||||
|
(loginTok != null && loginTok.length > 0) ||
|
||||||
|
(guestTok != null && guestTok.length > 0);
|
||||||
|
|
||||||
|
console.log("[splash] useEffect token check", {
|
||||||
|
loginToken: loginTok ? `${loginTok.slice(0, 8)}...` : null,
|
||||||
|
guestToken: guestTok ? `${guestTok.slice(0, 8)}...` : null,
|
||||||
|
hasAny,
|
||||||
|
willRedirect: hasAny && !cancelled,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled && hasAny) {
|
||||||
|
console.log("[splash] useEffect → router.replace(/chat)");
|
||||||
|
router.replace(ROUTES.chat);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell background="var(--color-sidebar-background)">
|
<MobileShell background="var(--color-sidebar-background)">
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
|
|||||||
+27
-2
@@ -23,11 +23,16 @@
|
|||||||
* - 函数导出名建议为 `proxy`(可默认导出)。
|
* - 函数导出名建议为 `proxy`(可默认导出)。
|
||||||
*
|
*
|
||||||
* 用 `src/` 时 proxy 必须放在 `src/` 内(参见 `src-folder.md`)。
|
* 用 `src/` 时 proxy 必须放在 `src/` 内(参见 `src-folder.md`)。
|
||||||
|
*
|
||||||
|
* DEBUG:每条请求都打日志(`hasSession` / `isAuthOnly` / `isProtected` + 决策),
|
||||||
|
* 用于排查"进入 /splash 后仍自动跳 /chat"类路由 bug。bug 修完可考虑保留 INFO 级别 + 删 DEBUG。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { Logger } from "@/utils/logger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AUTH_ONLY_ROUTES,
|
AUTH_ONLY_ROUTES,
|
||||||
PROTECTED_ROUTES,
|
PROTECTED_ROUTES,
|
||||||
@@ -40,6 +45,9 @@ const SESSION_COOKIE_NAMES = [
|
|||||||
"__Secure-next-auth.session-token",
|
"__Secure-next-auth.session-token",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** proxy 入口日志(Node.js runtime 跑,pino 兼容) */
|
||||||
|
const log = new Logger("Proxy");
|
||||||
|
|
||||||
function isAuthOnlyRoute(pathname: string): boolean {
|
function isAuthOnlyRoute(pathname: string): boolean {
|
||||||
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
|
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
|
||||||
}
|
}
|
||||||
@@ -58,21 +66,38 @@ export function proxy(request: NextRequest) {
|
|||||||
const hasSession = SESSION_COOKIE_NAMES.some(
|
const hasSession = SESSION_COOKIE_NAMES.some(
|
||||||
(name) => Boolean(request.cookies.get(name)?.value),
|
(name) => Boolean(request.cookies.get(name)?.value),
|
||||||
);
|
);
|
||||||
|
const isAuthOnly = isAuthOnlyRoute(pathname);
|
||||||
|
const isProtected = isProtectedRoute(pathname);
|
||||||
|
|
||||||
|
// DEBUG:每条请求都打(区分 path 1=proxy 跳 vs path 2=splash useEffect 跳)
|
||||||
|
log.info(
|
||||||
|
{ pathname, hasSession, isAuthOnly, isProtected },
|
||||||
|
"[proxy] request",
|
||||||
|
);
|
||||||
|
|
||||||
// 已登录访问未登录专属页 → /chat
|
// 已登录访问未登录专属页 → /chat
|
||||||
if (hasSession && isAuthOnlyRoute(pathname)) {
|
if (hasSession && isAuthOnly) {
|
||||||
|
log.info(
|
||||||
|
{ pathname, target: ROUTES.chat, reason: "logged-in → auth-only" },
|
||||||
|
"[proxy] redirect",
|
||||||
|
);
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = ROUTES.chat;
|
url.pathname = ROUTES.chat;
|
||||||
return NextResponse.redirect(url, 308);
|
return NextResponse.redirect(url, 308);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未登录访问登录态专属页 → /splash(首界面)
|
// 未登录访问登录态专属页 → /splash(首界面)
|
||||||
if (!hasSession && isProtectedRoute(pathname)) {
|
if (!hasSession && isProtected) {
|
||||||
|
log.info(
|
||||||
|
{ pathname, target: ROUTES.splash, reason: "not-logged-in → protected" },
|
||||||
|
"[proxy] redirect",
|
||||||
|
);
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = ROUTES.splash;
|
url.pathname = ROUTES.splash;
|
||||||
return NextResponse.redirect(url, 308);
|
return NextResponse.redirect(url, 308);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info({ pathname }, "[proxy] pass-through");
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
password: state.context.password,
|
password: state.context.password,
|
||||||
username: state.context.username,
|
username: state.context.username,
|
||||||
confirmPassword: state.context.confirmPassword,
|
confirmPassword: state.context.confirmPassword,
|
||||||
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)
|
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/ 显式游客登录
|
||||||
isLoading:
|
isLoading:
|
||||||
state.matches("loadingEmailLogin") ||
|
state.matches("loadingEmailLogin") ||
|
||||||
state.matches("loadingEmailRegister") ||
|
state.matches("loadingEmailRegister") ||
|
||||||
state.matches("loadingOAuth"),
|
state.matches("loadingOAuth") ||
|
||||||
|
state.matches("loadingGuestLogin"),
|
||||||
errorMessage: state.context.errorMessage,
|
errorMessage: state.context.errorMessage,
|
||||||
isSuccess: state.matches("success"),
|
isSuccess: state.matches("success"),
|
||||||
loginStatus: state.context.loginStatus,
|
loginStatus: state.context.loginStatus,
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ export type AuthEvent =
|
|||||||
| { type: "AuthModeChanged"; mode: AuthMode }
|
| { type: "AuthModeChanged"; mode: AuthMode }
|
||||||
| { type: "AuthFormCleared" }
|
| { type: "AuthFormCleared" }
|
||||||
| { type: "AuthReset" }
|
| { type: "AuthReset" }
|
||||||
/** 启动 / 恢复时检查登录态 —— 拿 deviceId → 查 token → 必要时游客登录 */
|
/** 启动 / 恢复时检查登录态 —— 拿 deviceId → 查 token(**不**自动创建游客账号) */
|
||||||
| { type: "AuthStatusCheckSubmitted" }
|
| { type: "AuthStatusCheckSubmitted" }
|
||||||
|
/** 显式游客登录 —— splash 上点"游客模式"按钮触发(**不**自动) */
|
||||||
|
| { type: "AuthGuestLoginSubmitted" }
|
||||||
// 业务事件(提交)
|
// 业务事件(提交)
|
||||||
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
|
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ async function readGuestId(): Promise<string | undefined> {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Actors(异步服务)
|
// Actors(异步服务)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 邮箱登录 actor 仅调 authRepository.emailLogin 拿业务 token。
|
|
||||||
// 本轮起不再调 /api/auth/marker 写 cookie(统一迁到 NextAuth session
|
|
||||||
// cookie 检测,邮箱登录 proxy 行为在后续轮恢复)。
|
|
||||||
|
|
||||||
const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
|
const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
|
||||||
async ({ input }) => {
|
async ({ input }) => {
|
||||||
@@ -79,7 +76,7 @@ const oauthSignInActor = fromPromise<void, AuthProvider>(async ({ input }) => {
|
|||||||
await signIn(input);
|
await signIn(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== 新增:OAuth token → 后端业务 token sync actors ==========
|
// ========== OAuth token → 后端业务 token sync actors ==========
|
||||||
// 由 <OAuthSessionSync /> 在 NextAuth 回调后派发(监听 useSession())。
|
// 由 <OAuthSessionSync /> 在 NextAuth 回调后派发(监听 useSession())。
|
||||||
// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。
|
// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。
|
||||||
// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。
|
// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。
|
||||||
@@ -109,24 +106,35 @@ const syncFacebookBackendActor = fromPromise<
|
|||||||
return "facebook" as LoginStatus;
|
return "facebook" as LoginStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== 新增:启动 / 恢复时检查登录态 actor ==========
|
// ========== 显式游客登录 actor(opt-in) ==========
|
||||||
|
// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,**不**自动跑。
|
||||||
|
// 取代了之前 checkAuthStatusActor 的 auto-guest-login 行为 —— 避免每次
|
||||||
|
// 访 splash 都自动创建游客账号污染后端。
|
||||||
|
|
||||||
|
const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
|
||||||
|
const deviceId = await deviceIdentifier.getDeviceId();
|
||||||
|
const result = await authRepository.guestLogin(deviceId);
|
||||||
|
if (Result.isErr(result)) throw result.error;
|
||||||
|
return "guest" as LoginStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 启动 / 恢复时检查登录态 actor(只查不创) ==========
|
||||||
// 由 <AuthStatusChecker /> 在 mount 时派发(一次性)。
|
// 由 <AuthStatusChecker /> 在 mount 时派发(一次性)。
|
||||||
// 流程:
|
// 流程(**纯只读**):
|
||||||
// 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘
|
// 1. deviceIdentifier.getDeviceId() —— 无则通过 fingerprintjs 生成 + 落盘
|
||||||
// 2. AuthStorage.getLoginToken() —— 有 → "email"(具体 provider 由 OAuthSessionSync 后续覆盖)
|
// (给后续 OAuth/email 登录准备 deviceId,**不**在 splash 首次访问就 auto-create 游客)
|
||||||
|
// 2. AuthStorage.getLoginToken() —— 有 → "email"
|
||||||
// 3. AuthStorage.getGuestToken() —— 有 → "guest"
|
// 3. AuthStorage.getGuestToken() —— 有 → "guest"
|
||||||
// 4. 都没有 → authRepository.guestLogin(deviceId) 内部自动 setGuestToken + setDeviceId + setUserId
|
// 4. 都没有 → "notLoggedIn"(让用户**显式**选 OAuth / Email / Guest 入口)
|
||||||
|
|
||||||
const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
|
const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
|
||||||
// 1. 拿 deviceId
|
// 1. 拿 deviceId(**不**用于 auto-guest-login,仅供后续登录用)
|
||||||
const deviceId = await deviceIdentifier.getDeviceId();
|
await deviceIdentifier.getDeviceId();
|
||||||
const storage = AuthStorage.getInstance();
|
const storage = AuthStorage.getInstance();
|
||||||
|
|
||||||
// 2. 查 loginToken(OAuth / 邮箱登录后端写下的)
|
// 2. 查 loginToken
|
||||||
const loginTokenR = await storage.getLoginToken();
|
const loginTokenR = await storage.getLoginToken();
|
||||||
if (loginTokenR.success && loginTokenR.data) {
|
if (loginTokenR.success && loginTokenR.data) {
|
||||||
// token 字符串无法区分 google/facebook/email/apple
|
|
||||||
// 默认 "email" —— 若实际是 OAuth,OAuthSessionSync 后续会通过 AuthGoogle/FacebookSyncSubmitted 覆盖
|
|
||||||
return "email" as LoginStatus;
|
return "email" as LoginStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +144,8 @@ const checkAuthStatusActor = fromPromise<LoginStatus, void>(async () => {
|
|||||||
return "guest" as LoginStatus;
|
return "guest" as LoginStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 都没有 —— 调游客登录接口(内部自动 setGuestToken + setDeviceId + setUserId)
|
// 4. 都没有 → "notLoggedIn"(保持 splash 页面 + 登录入口)
|
||||||
const result = await authRepository.guestLogin(deviceId);
|
return "notLoggedIn" as LoginStatus;
|
||||||
if (Result.isErr(result)) throw result.error;
|
|
||||||
return "guest" as LoginStatus;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -156,6 +162,7 @@ export const authMachine = setup({
|
|||||||
oauthSignIn: oauthSignInActor,
|
oauthSignIn: oauthSignInActor,
|
||||||
syncGoogleBackend: syncGoogleBackendActor,
|
syncGoogleBackend: syncGoogleBackendActor,
|
||||||
syncFacebookBackend: syncFacebookBackendActor,
|
syncFacebookBackend: syncFacebookBackendActor,
|
||||||
|
guestLogin: guestLoginActor,
|
||||||
checkAuthStatus: checkAuthStatusActor,
|
checkAuthStatus: checkAuthStatusActor,
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
@@ -191,6 +198,8 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
|
// 启动 / 恢复时检查登录态 —— 由 <AuthStatusChecker /> 派发
|
||||||
AuthStatusCheckSubmitted: "checkingAuthStatus",
|
AuthStatusCheckSubmitted: "checkingAuthStatus",
|
||||||
|
// 显式游客登录 —— 由 splash UI 派发(**不**自动)
|
||||||
|
AuthGuestLoginSubmitted: "loadingGuestLogin",
|
||||||
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
||||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||||
AuthGoogleLoginSubmitted: "loadingOAuth",
|
AuthGoogleLoginSubmitted: "loadingOAuth",
|
||||||
@@ -227,6 +236,27 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadingGuestLogin: {
|
||||||
|
entry: assign({ errorMessage: null }),
|
||||||
|
invoke: {
|
||||||
|
src: "guestLogin",
|
||||||
|
onDone: {
|
||||||
|
target: "success",
|
||||||
|
actions: assign({
|
||||||
|
loginStatus: ({ event }) => event.output,
|
||||||
|
errorMessage: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
onError: {
|
||||||
|
target: "idle",
|
||||||
|
actions: assign({
|
||||||
|
errorMessage: ({ event }) =>
|
||||||
|
event.error instanceof Error ? event.error.message : String(event.error),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
loadingEmailLogin: {
|
loadingEmailLogin: {
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "emailLogin",
|
src: "emailLogin",
|
||||||
@@ -287,14 +317,11 @@ export const authMachine = setup({
|
|||||||
invoke: {
|
invoke: {
|
||||||
src: "oauthSignIn",
|
src: "oauthSignIn",
|
||||||
input: ({ event }) => {
|
input: ({ event }) => {
|
||||||
// event 来自 idle 跳转(AuthGoogle/FacebookLoginSubmitted)
|
|
||||||
if (event.type === "AuthGoogleLoginSubmitted") return event.provider;
|
if (event.type === "AuthGoogleLoginSubmitted") return event.provider;
|
||||||
if (event.type === "AuthFacebookLoginSubmitted") return event.provider;
|
if (event.type === "AuthFacebookLoginSubmitted") return event.provider;
|
||||||
return "google" as AuthProvider;
|
return "google" as AuthProvider;
|
||||||
},
|
},
|
||||||
onDone: {
|
onDone: {
|
||||||
// 罕见分支:NextAuth 同步 resolve(实际几乎不会发生,
|
|
||||||
// OAuth 流程会通过重定向离开本应用)。
|
|
||||||
target: "idle",
|
target: "idle",
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
@@ -307,13 +334,11 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ========== 新增:OAuth token → 后端 sync 状态 ==========
|
|
||||||
syncingGoogleBackend: {
|
syncingGoogleBackend: {
|
||||||
entry: assign({ errorMessage: null }),
|
entry: assign({ errorMessage: null }),
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "syncGoogleBackend",
|
src: "syncGoogleBackend",
|
||||||
input: ({ event }) => {
|
input: ({ event }) => {
|
||||||
// event 来自 idle 跳转(AuthGoogleSyncSubmitted)
|
|
||||||
if (event.type !== "AuthGoogleSyncSubmitted") return { idToken: "" };
|
if (event.type !== "AuthGoogleSyncSubmitted") return { idToken: "" };
|
||||||
return { idToken: event.idToken };
|
return { idToken: event.idToken };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
* → 业务 token 写入本地 storage
|
* → 业务 token 写入本地 storage
|
||||||
*
|
*
|
||||||
* 此组件**无可见 UI**(返回 null),仅作为 NextAuth session 与 auth state
|
* 此组件**无可见 UI**(返回 null),仅作为 NextAuth session 与 auth state
|
||||||
* machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后:
|
* machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后。
|
||||||
* <AuthProvider> ← 提供 useAuthState / useAuthDispatch
|
*
|
||||||
* <OAuthSessionSync /> ← 用上面两个 hook
|
* DEBUG:client 端**不**用 Logger(pino 引入会爆 client bundle),改用 console.log
|
||||||
* </AuthProvider>
|
* 临时调试,bug 修完即删。
|
||||||
*/
|
*/
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
@@ -26,6 +26,22 @@ export function OAuthSessionSync() {
|
|||||||
const { loginStatus } = useAuthState();
|
const { loginStatus } = useAuthState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// DEBUG:每条 session 变化都打(区分 OAuth 残留 session 是否触发 sync)
|
||||||
|
console.log("[OAuthSessionSync] session change", {
|
||||||
|
status,
|
||||||
|
hasSession: !!session,
|
||||||
|
provider: session?.provider,
|
||||||
|
idToken: session?.idToken ? `${session.idToken.slice(0, 8)}...` : null,
|
||||||
|
accessToken: session?.accessToken
|
||||||
|
? `${session.accessToken.slice(0, 8)}...`
|
||||||
|
: null,
|
||||||
|
loginStatus,
|
||||||
|
willDispatch:
|
||||||
|
status === "authenticated" &&
|
||||||
|
!!session?.provider &&
|
||||||
|
loginStatus !== session.provider,
|
||||||
|
});
|
||||||
|
|
||||||
// 1) NextAuth 还没就绪
|
// 1) NextAuth 还没就绪
|
||||||
if (status !== "authenticated" || !session) return;
|
if (status !== "authenticated" || !session) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user