fix(auth): defer guest login until chat entry

This commit is contained in:
2026-07-07 14:11:36 +08:00
parent fd96cdb3d6
commit 2aaf60a497
8 changed files with 84 additions and 29 deletions
+21 -1
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context"; import { useUserState } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
@@ -42,6 +42,7 @@ export function ChatScreen() {
const state = useChatState(); const state = useChatState();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const authState = useAuthState(); const authState = useAuthState();
const authDispatch = useAuthDispatch();
const userState = useUserState(); const userState = useUserState();
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] = const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
@@ -70,6 +71,25 @@ export function ChatScreen() {
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10; const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
const externalBrowserPromptShownRef = useRef(false); const externalBrowserPromptShownRef = useRef(false);
const guestLoginRequestedRef = useRef(false);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
if (authState.loginStatus !== "notLoggedIn") {
guestLoginRequestedRef.current = false;
return;
}
if (guestLoginRequestedRef.current) return;
guestLoginRequestedRef.current = true;
authDispatch({ type: "AuthGuestLoginSubmitted" });
}, [
authDispatch,
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
]);
useEffect(() => { useEffect(() => {
if ( if (
+1 -1
View File
@@ -12,7 +12,7 @@ export interface SplashButtonProps {
export function SplashButton({ onStartChat }: SplashButtonProps) { export function SplashButton({ onStartChat }: SplashButtonProps) {
const state = useAuthState(); const state = useAuthState();
const isLoading = state.isLoading; const isLoading = !state.hasInitialized || state.isLoading;
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
@@ -33,12 +33,21 @@ describe("navigation resolver", () => {
).toBe("/subscription?type=topup"); ).toBe("/subscription?type=topup");
}); });
it("redirects not logged in session routes to splash", () => { it("allows not logged in users to enter chat for guest bootstrap", () => {
expect( expect(
resolveRouteGuardRedirect({ resolveRouteGuardRedirect({
loginStatus: "notLoggedIn", loginStatus: "notLoggedIn",
pathname: ROUTES.chat, pathname: ROUTES.chat,
}), }),
).toBeNull();
});
it("redirects not logged in session routes except chat to splash", () => {
expect(
resolveRouteGuardRedirect({
loginStatus: "notLoggedIn",
pathname: ROUTES.sidebar,
}),
).toBe(ROUTES.splash); ).toBe(ROUTES.splash);
}); });
+7 -2
View File
@@ -1,12 +1,17 @@
import { ROUTES } from "./routes"; import { ROUTES } from "./routes";
export type RouteAccess = "public" | "session" | "realUser" | "authOnly"; export type RouteAccess =
| "public"
| "guestEntry"
| "session"
| "realUser"
| "authOnly";
const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = { const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.root]: "public", [ROUTES.root]: "public",
[ROUTES.splash]: "authOnly", [ROUTES.splash]: "authOnly",
[ROUTES.auth]: "authOnly", [ROUTES.auth]: "authOnly",
[ROUTES.chat]: "session", [ROUTES.chat]: "guestEntry",
[ROUTES.sidebar]: "session", [ROUTES.sidebar]: "session",
[ROUTES.subscription]: "realUser", [ROUTES.subscription]: "realUser",
[ROUTES.coinsRules]: "public", [ROUTES.coinsRules]: "public",
+19 -1
View File
@@ -95,13 +95,31 @@ describe("authMachine", () => {
actor.stop(); actor.stop();
}); });
it("automatically guest logs in when initialization finds no login state", async () => { it("initializes as notLoggedIn without automatically guest logging in", async () => {
const guestLoginSpy = vi.fn<() => void>(); const guestLoginSpy = vi.fn<() => void>();
const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start(); const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start();
actor.send({ type: "AuthInit" }); actor.send({ type: "AuthInit" });
await waitFor(actor, (snapshot) => snapshot.matches("idle")); await waitFor(actor, (snapshot) => snapshot.matches("idle"));
const context = actor.getSnapshot().context;
expect(context.loginStatus).toBe("notLoggedIn");
expect(context.hasInitialized).toBe(true);
expect(guestLoginSpy).not.toHaveBeenCalled();
actor.stop();
});
it("guest logs in only after explicit guest login submission", async () => {
const guestLoginSpy = vi.fn<() => void>();
const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start();
actor.send({ type: "AuthInit" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
actor.send({ type: "AuthGuestLoginSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.loginStatus).toBe("guest"); expect(context.loginStatus).toBe("guest");
expect(context.hasInitialized).toBe(true); expect(context.hasInitialized).toBe(true);
+1 -1
View File
@@ -58,7 +58,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
username: state.context.username, username: state.context.username,
confirmPassword: state.context.confirmPassword, confirmPassword: state.context.confirmPassword,
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/ // isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/
// OAuth 回调后端 sync / 自动游客登录 // OAuth 回调后端 sync / 显式游客登录
isLoading: isLoading:
state.matches("loadingEmailLogin") || state.matches("loadingEmailLogin") ||
state.matches("loadingEmailRegister") || state.matches("loadingEmailRegister") ||
+1
View File
@@ -15,6 +15,7 @@ export type AuthEvent =
/** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */ /** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */
| { type: "AuthInit" } | { type: "AuthInit" }
// 业务事件(提交) // 业务事件(提交)
| { type: "AuthGuestLoginSubmitted" }
| { type: "AuthEmailLoginSubmitted"; email: string; password: string } | { type: "AuthEmailLoginSubmitted"; email: string; password: string }
| { | {
type: "AuthEmailRegisterSubmitted"; type: "AuthEmailRegisterSubmitted";
+17 -15
View File
@@ -32,7 +32,7 @@ const toAuthErrorMessage = (error: unknown): string =>
// //
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
// //
// 未找到任何登录态时,初始化流程会自动进入 guest login,补齐游客会话 // 初始化只恢复已有登录态;游客登录必须由用户明确操作触发
// ============================================================ // ============================================================
export const authMachine = setup({ export const authMachine = setup({
types: { types: {
@@ -49,6 +49,13 @@ export const authMachine = setup({
checkAuthStatus: checkAuthStatusActor, checkAuthStatus: checkAuthStatusActor,
logout: logoutActor, logout: logoutActor,
}, },
guards: {
canSubmitGuestLogin: ({ context }) =>
context.loginStatus === "notLoggedIn",
canLogoutToGuest: ({ context }) =>
context.loginStatus !== "notLoggedIn" &&
context.loginStatus !== "guest",
},
}).createMachine({ }).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgOgJYQBswBiAQQ0wAVkA7MAgWQHsIwBhTWmCAbQAYAuolAAHJrFzpcTGsJAAPRABYA7NgAc6gEzqAjCvUBOLbvUqlWgDQgAnohWnsAZgCsSpXyeHdTgGy6lXwBfIOs0LDxCEnIsZlYOLkh+ISQQMQkpGTlFBF81Y1MzX3UvdT5NaztcrS1sD10tQ1dvPi0+YNCQcJx8IjIKADEmACcAWzYiZGGkwTl0yWlZVJzzbHMarSUvNpctYsrEXd86pT0SvfULXyUQsIpIvpjMACU4MHRkufEFrOXEXT4fGwvkMLicmxUTT4qj4hgOCC2QKUoL4KkaShc5ycty69160QoAEkaJJPql5pklqAcl4nGtDCCtGDdqotCp4YY9MC0Xw3CpfH5Gi4cd0HgSsABxVBwdAAGSYUFwNAAyqgAEajSToGYpUTfSnZRC0+mM5kQtnwlzlDQuQynEpOJz6BoqEV4qL9LAAUVGyFwBHlipV6s16G1vFm5P1i0NCGNkNNrnN7Ns9gx2D2LhUDjcl0BN06ovxnswPr9BFeitg2uGqo1Wp1XwyMb+caaJuMZtZKaquhBQKMsOhHiafY6dwixaeEqYCqIgaVddD4bJeubv2pRvbCc7Se78P0SjqYLaewuTtObsnHqeA2QAGMwGrZwBrBfB+thxtR9dUhRbukdyZPc0R7RAaiPUEnRKREjH0dQrx6G8KFIEQRHnBVFxDBsI11NJow3f8EVqVRfCzJ14L4Qp4UdI83F0Bkz3BJxtEQsUSxnOcwGVGwaHvJccNXfDf1jeMGV3FlQIPDEj1OQITAYjwijYqdBgfJ9Xx4viBK-XCmx+P8aW3cTgMki1UwQBpHFRB1IQMdRMXHXFr0eCgJjAKZKDAGgICVKBXl86Z7w+SM1wM2MLGwQFopimKAnhLQWOwTFNF2JxAWuDwtBUkkpGQAhcAALz84gIBkMA8BoAA3JgXwqotctwfKir8hAlRq+9kEpZIhIpFtNwRAVkt8JkdCaXxWhMeFNHUNZTEMTwXExaEXF0HKFma4qaCgYgwGGYYRmwdCuoAMxGUZsAajaCq2qA2uqphOu6wReoIwzlGubAFoFUw3BMEpQXhSEXDWMiVDSlkJtdQt7gIJhkF87apRld9SvKyqarqy7YfhxGoGR6t33ujqusWHrQuE8LWytIFwT0fxAW0F14SUXQQe+x0HDZRy7TYuGEb8gm5Uwmhdv2w7jvQM6xmxiJ+bxoWifax7SZkcm8L6wicitXRuQYq1-C2cwXBomovrBX7jDaVonNFeW-LLf1UbKugMdq+qcYF7bHYDEXiZV56BFekTqdaPXxs5PJdGj+EyNqJ0z08Cx6b53GHd9J2RbFg7hiOghTvO2WcHt72M99oN-aesmXopzX3oQK0jyolxrkS05JuzS1bWcW1Am8Jw0SdW3Pbxn3K1was9rR13laxu209L8tx8n4ZK9Vmh1f0g1WwsXWLacEcjAPxuD1tfIB-B8FPBUcFsphuWF6gMewCrGts4l-OpcL+evafsvl5rGvQOwcqYDQsLUMwjcBQGxbmBSyjcvqXAmgxTkAQDCp1-gAeSeNPCqs8PYPywU8IB1cg61zehFUEyUDCmD8M6TQ-IErg2PEPCwB9QQmAwXjbBFB3650ltLC6P9uHEOVlXNWNcNYUJ3gyL6-1NCGAMDTPw0097RVWrvCEAQ2KwF4veQWs4oBEAAEIPjqj5XBbs573F0XxAxXFTH3nMRAEhEiyFSJDmArYUU2Y-VMCCTQugDx0OSheEEnIHKeDvhOHAtj9FI0MSYsx3kIB8LzgXGWoo4n2KMWARxzjXEb0kVvfqRF3BHmaBYVEo4TYWQaMUahJRTAGEaIUHRei-J3kfM+Wq+SUmWPwUXbA2TtpdI0r05JPlCmbx-KAspexnANHIiYTkqIBQHiWdgJZ-jszgnWO0uxoz1I9JfH0ixe0c7pK-pkmxHSjndNfGclxYj14zLCtvMBbJgQtzZA5PQ0cmgHiiVsnkrhYHFE5CEToNAWBwDkN0EpWtEAAFp2aGHRRizF6L1C+HhMi4eLkwCIvrpsowlxsxgjRAEUwqjDDOFRBoxKI1ygIXvj0Rqm0-LEtjAEnudo-DjQ8CNVRQJo43zWQKR0pguGC2lITEW3LWy8sqQKvwQqrAWU5vS1QrNEo6BvgS4uj8fbvkVQNZVvdVXXHaBqqo5E5oAl8GDXkvyZWL39AAvaZqiIWv5SCNVNrT4MWcHQ8iQ5mluqgDwrA3qci+q2P661wq6lh30E68pmh3CbGxGy4Zdz8aJLyZMiAsbEDxqteqg8OhajGE5OYWEA8jDQxiXmw5UAxknKeaW3InI+UJsFYGlNgRkqJVtOUJRE1WUhCAA */ /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgOgJYQBswBiAQQ0wAVkA7MAgWQHsIwBhTWmCAbQAYAuolAAHJrFzpcTGsJAAPRABYA7NgAc6gEzqAjCvUBOLbvUqlWgDQgAnohWnsAZgCsSpXyeHdTgGy6lXwBfIOs0LDxCEnIsZlYOLkh+ISQQMQkpGTlFBF81Y1MzX3UvdT5NaztcrS1sD10tQ1dvPi0+YNCQcJx8IjIKADEmACcAWzYiZGGkwTl0yWlZVJzzbHMarSUvNpctYsrEXd86pT0SvfULXyUQsIpIvpjMACU4MHRkufEFrOXEXT4fGwvkMLicmxUTT4qj4hgOCC2QKUoL4KkaShc5ycty69160QoAEkaJJPql5pklqAcl4nGtDCCtGDdqotCp4YY9MC0Xw3CpfH5Gi4cd0HgSsABxVBwdAAGSYUFwNAAyqgAEajSToGYpUTfSnZRC0+mM5kQtnwlzlDQuQynEpOJz6BoqEV4qL9LAAUVGyFwBHlipV6s16G1vFm5P1i0NCGNkNNrnN7Ns9gx2D2LhUDjcl0BN06ovxnswPr9BFeitg2uGqo1Wp1XwyMb+caaJuMZtZKaquhBQKMsOhHiafY6dwixaeEqYCqIgaVddD4bJeubv2pRvbCc7Se78P0SjqYLaewuTtObsnHqeA2QAGMwGrZwBrBfB+thxtR9dUhRbukdyZPc0R7RAaiPUEnRKREjH0dQrx6G8KFIEQRHnBVFxDBsI11NJow3f8EVqVRfCzJ14L4Qp4UdI83F0Bkz3BJxtEQsUSxnOcwGVGwaHvJccNXfDf1jeMGV3FlQIPDEj1OQITAYjwijYqdBgfJ9Xx4viBK-XCmx+P8aW3cTgMki1UwQBpHFRB1IQMdRMXHXFr0eCgJjAKZKDAGgICVKBXl86Z7w+SM1wM2MLGwQFopimKAnhLQWOwTFNF2JxAWuDwtBUkkpGQAhcAALz84gIBkMA8BoAA3JgXwqotctwfKir8hAlRq+9kEpZIhIpFtNwRAVkt8JkdCaXxWhMeFNHUNZTEMTwXExaEXF0HKFma4qaCgYgwGGYYRmwdCuoAMxGUZsAajaCq2qA2uqphOu6wReoIwzlGubAFoFUw3BMEpQXhSEXDWMiVDSlkJtdQt7gIJhkF87apRld9SvKyqarqy7YfhxGoGR6t33ujqusWHrQuE8LWytIFwT0fxAW0F14SUXQQe+x0HDZRy7TYuGEb8gm5Uwmhdv2w7jvQM6xmxiJ+bxoWifax7SZkcm8L6wicitXRuQYq1-C2cwXBomovrBX7jDaVonNFeW-LLf1UbKugMdq+qcYF7bHYDEXiZV56BFekTqdaPXxs5PJdGj+EyNqJ0z08Cx6b53GHd9J2RbFg7hiOghTvO2WcHt72M99oN-aesmXopzX3oQK0jyolxrkS05JuzS1bWcW1Am8Jw0SdW3Pbxn3K1was9rR13laxu209L8tx8n4ZK9Vmh1f0g1WwsXWLacEcjAPxuD1tfIB-B8FPBUcFsphuWF6gMewCrGts4l-OpcL+evafsvl5rGvQOwcqYDQsLUMwjcBQGxbmBSyjcvqXAmgxTkAQDCp1-gAeSeNPCqs8PYPywU8IB1cg61zehFUEyUDCmD8M6TQ-IErg2PEPCwB9QQmAwXjbBFB3650ltLC6P9uHEOVlXNWNcNYUJ3gyL6-1NCGAMDTPw0097RVWrvCEAQ2KwF4veQWs4oBEAAEIPjqj5XBbs573F0XxAxXFTH3nMRAEhEiyFSJDmArYUU2Y-VMCCTQugDx0OSheEEnIHKeDvhOHAtj9FI0MSYsx3kIB8LzgXGWoo4n2KMWARxzjXEb0kVvfqRF3BHmaBYVEo4TYWQaMUahJRTAGEaIUHRei-J3kfM+Wq+SUmWPwUXbA2TtpdI0r05JPlCmbx-KAspexnANHIiYTkqIBQHiWdgJZ-jszgnWO0uxoz1I9JfH0ixe0c7pK-pkmxHSjndNfGclxYj14zLCtvMBbJgQtzZA5PQ0cmgHiiVsnkrhYHFE5CEToNAWBwDkN0EpWtEAAFp2aGHRRizF6L1C+HhMi4eLkwCIvrpsowlxsxgjRAEUwqjDDOFRBoxKI1ygIXvj0Rqm0-LEtjAEnudo-DjQ8CNVRQJo43zWQKR0pguGC2lITEW3LWy8sqQKvwQqrAWU5vS1QrNEo6BvgS4uj8fbvkVQNZVvdVXXHaBqqo5E5oAl8GDXkvyZWL39AAvaZqiIWv5SCNVNrT4MWcHQ8iQ5mluqgDwrA3qci+q2P661wq6lh30E68pmh3CbGxGy4Zdz8aJLyZMiAsbEDxqteqg8OhajGE5OYWEA8jDQxiXmw5UAxknKeaW3InI+UJsFYGlNgRkqJVtOUJRE1WUhCAA */
id: "auth", id: "auth",
@@ -78,10 +85,17 @@ export const authMachine = setup({
errorMessage: null, errorMessage: null,
}), }),
}, },
AuthLogoutSubmitted: "loggingOut", AuthLogoutSubmitted: {
guard: "canLogoutToGuest",
target: "loggingOut",
},
// 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发 // 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发
AuthInit: "initializing", AuthInit: "initializing",
AuthGuestLoginSubmitted: {
guard: "canSubmitGuestLogin",
target: "loadingGuestLogin",
},
AuthEmailLoginSubmitted: "loadingEmailLogin", AuthEmailLoginSubmitted: "loadingEmailLogin",
AuthEmailRegisterSubmitted: "loadingEmailRegister", AuthEmailRegisterSubmitted: "loadingEmailRegister",
AuthGoogleLoginSubmitted: "loadingOAuth", AuthGoogleLoginSubmitted: "loadingOAuth",
@@ -102,18 +116,7 @@ export const authMachine = setup({
entry: assign({ errorMessage: null }), entry: assign({ errorMessage: null }),
invoke: { invoke: {
src: "checkAuthStatus", src: "checkAuthStatus",
onDone: [ onDone: {
{
guard: ({ event }) => event.output === "notLoggedIn",
target: "loadingGuestLogin",
actions: assign({
loginStatus: "notLoggedIn",
// 自动游客登录仍属于初始化流程,完成前不标记 initialized。
hasInitialized: false,
errorMessage: null,
}),
},
{
target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地) target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地)
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
@@ -121,7 +124,6 @@ export const authMachine = setup({
errorMessage: null, errorMessage: null,
}), }),
}, },
],
onError: { onError: {
target: "idle", target: "idle",
actions: assign({ actions: assign({