fix(auth): defer guest login until chat entry
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { useUserState } from "@/stores/user/user-context";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
@@ -42,6 +42,7 @@ export function ChatScreen() {
|
||||
const state = useChatState();
|
||||
const chatDispatch = useChatDispatch();
|
||||
const authState = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
const userState = useUserState();
|
||||
const navigator = useAppNavigator();
|
||||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
||||
@@ -70,6 +71,25 @@ export function ChatScreen() {
|
||||
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
|
||||
|
||||
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(() => {
|
||||
if (
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface SplashButtonProps {
|
||||
|
||||
export function SplashButton({ onStartChat }: SplashButtonProps) {
|
||||
const state = useAuthState();
|
||||
const isLoading = state.isLoading;
|
||||
const isLoading = !state.hasInitialized || state.isLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
|
||||
@@ -33,12 +33,21 @@ describe("navigation resolver", () => {
|
||||
).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(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "notLoggedIn",
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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>> = {
|
||||
[ROUTES.root]: "public",
|
||||
[ROUTES.splash]: "authOnly",
|
||||
[ROUTES.auth]: "authOnly",
|
||||
[ROUTES.chat]: "session",
|
||||
[ROUTES.chat]: "guestEntry",
|
||||
[ROUTES.sidebar]: "session",
|
||||
[ROUTES.subscription]: "realUser",
|
||||
[ROUTES.coinsRules]: "public",
|
||||
|
||||
@@ -95,13 +95,31 @@ describe("authMachine", () => {
|
||||
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 actor = createActor(createTestAuthMachine({ guestLoginSpy })).start();
|
||||
|
||||
actor.send({ type: "AuthInit" });
|
||||
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;
|
||||
expect(context.loginStatus).toBe("guest");
|
||||
expect(context.hasInitialized).toBe(true);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
username: state.context.username,
|
||||
confirmPassword: state.context.confirmPassword,
|
||||
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/
|
||||
// OAuth 回调后端 sync / 自动游客登录
|
||||
// OAuth 回调后端 sync / 显式游客登录
|
||||
isLoading:
|
||||
state.matches("loadingEmailLogin") ||
|
||||
state.matches("loadingEmailRegister") ||
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AuthEvent =
|
||||
/** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */
|
||||
| { type: "AuthInit" }
|
||||
// 业务事件(提交)
|
||||
| { type: "AuthGuestLoginSubmitted" }
|
||||
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
|
||||
| {
|
||||
type: "AuthEmailRegisterSubmitted";
|
||||
|
||||
@@ -32,7 +32,7 @@ const toAuthErrorMessage = (error: unknown): string =>
|
||||
//
|
||||
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
|
||||
//
|
||||
// 未找到任何登录态时,初始化流程会自动进入 guest login,补齐游客会话。
|
||||
// 初始化只恢复已有登录态;游客登录必须由用户明确操作触发。
|
||||
// ============================================================
|
||||
export const authMachine = setup({
|
||||
types: {
|
||||
@@ -49,6 +49,13 @@ export const authMachine = setup({
|
||||
checkAuthStatus: checkAuthStatusActor,
|
||||
logout: logoutActor,
|
||||
},
|
||||
guards: {
|
||||
canSubmitGuestLogin: ({ context }) =>
|
||||
context.loginStatus === "notLoggedIn",
|
||||
canLogoutToGuest: ({ context }) =>
|
||||
context.loginStatus !== "notLoggedIn" &&
|
||||
context.loginStatus !== "guest",
|
||||
},
|
||||
}).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 */
|
||||
id: "auth",
|
||||
@@ -78,10 +85,17 @@ export const authMachine = setup({
|
||||
errorMessage: null,
|
||||
}),
|
||||
},
|
||||
AuthLogoutSubmitted: "loggingOut",
|
||||
AuthLogoutSubmitted: {
|
||||
guard: "canLogoutToGuest",
|
||||
target: "loggingOut",
|
||||
},
|
||||
// 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发
|
||||
AuthInit: "initializing",
|
||||
|
||||
AuthGuestLoginSubmitted: {
|
||||
guard: "canSubmitGuestLogin",
|
||||
target: "loadingGuestLogin",
|
||||
},
|
||||
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||
AuthGoogleLoginSubmitted: "loadingOAuth",
|
||||
@@ -102,26 +116,14 @@ export const authMachine = setup({
|
||||
entry: assign({ errorMessage: null }),
|
||||
invoke: {
|
||||
src: "checkAuthStatus",
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ event }) => event.output === "notLoggedIn",
|
||||
target: "loadingGuestLogin",
|
||||
actions: assign({
|
||||
loginStatus: "notLoggedIn",
|
||||
// 自动游客登录仍属于初始化流程,完成前不标记 initialized。
|
||||
hasInitialized: false,
|
||||
errorMessage: null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地)
|
||||
actions: assign({
|
||||
loginStatus: ({ event }) => event.output,
|
||||
hasInitialized: true,
|
||||
errorMessage: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
onDone: {
|
||||
target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地)
|
||||
actions: assign({
|
||||
loginStatus: ({ event }) => event.output,
|
||||
hasInitialized: true,
|
||||
errorMessage: null,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: assign({
|
||||
|
||||
Reference in New Issue
Block a user