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:
2026-06-11 15:58:48 +08:00
parent e557f084c4
commit a6bc6941d4
9 changed files with 183 additions and 53 deletions
-6
View File
@@ -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
+31 -12
View File
@@ -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(() => {
// DEBUGisSuccess 状态透明化
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
// - loginTokenOAuth/email sync 写下的)→ 视为"已登录"跳 /chat
// - guestToken(用户**显式**走"游客模式"留下的)→ 同样视为"已登录"跳 /chat
// - 都没有 → 留在 splash(**不**自动创建游客账号 —— 见 auth-machine.ts 的 checkAuthStatusActor
//
// proxy 读不到 localStorageEdge runtime),所以这条 fallback 必须在 Client 侧。
//
// DEBUGclient 端**不**用 Loggerpino 引入会爆 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
View File
@@ -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();
} }
+3 -2
View File
@@ -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,
+3 -1
View File
@@ -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 }
| { | {
+47 -22
View File
@@ -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 ========== // ========== 显式游客登录 actoropt-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. 查 loginTokenOAuth / 邮箱登录后端写下的) // 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" —— 若实际是 OAuthOAuthSessionSync 后续会通过 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 };
}, },
+20 -4
View File
@@ -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 * DEBUGclient 端**不**用 Loggerpino 引入会爆 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;