refactor(auth): simplify splash guest entry
This commit is contained in:
@@ -7,7 +7,8 @@
|
|||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
|
import { isAuthenticatedUser } from "@/router/navigation-resolver";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
|
|
||||||
@@ -19,7 +20,6 @@ const log = new Logger("AppAuthAuthScreen");
|
|||||||
|
|
||||||
export function AuthScreen() {
|
export function AuthScreen() {
|
||||||
const state = useAuthState();
|
const state = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const redirectTo = useSyncExternalStore(
|
const redirectTo = useSyncExternalStore(
|
||||||
subscribeLocationSnapshot,
|
subscribeLocationSnapshot,
|
||||||
@@ -28,28 +28,23 @@ export function AuthScreen() {
|
|||||||
);
|
);
|
||||||
const safeRedirectTo = getSafeRedirectPath(redirectTo) ?? ROUTES.chat;
|
const safeRedirectTo = getSafeRedirectPath(redirectTo) ?? ROUTES.chat;
|
||||||
|
|
||||||
// 邮箱登录成功 → 跳转;User hydrate 由根级 <UserAuthSync /> 监听 loginStatus 变化处理。
|
// 真实用户登录成功 → 跳转;User hydrate 由根级 <UserAuthSync /> 监听 loginStatus 变化处理。
|
||||||
// 用 pendingRedirect flag(不是 useRef transition detection)——
|
// 游客态不触发跳转,避免自动游客登录打断 auth 页面。
|
||||||
// re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
log.debug("[auth-screen] useEffect (loginStatus + pendingRedirect)", {
|
const shouldRedirect = isAuthenticatedUser(state.loginStatus);
|
||||||
pending: state.pendingRedirect,
|
log.debug("[auth-screen] useEffect (loginStatus)", {
|
||||||
loginStatus: state.loginStatus,
|
loginStatus: state.loginStatus,
|
||||||
willRedirect:
|
willRedirect: shouldRedirect,
|
||||||
state.pendingRedirect && state.loginStatus !== "notLoggedIn",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") {
|
if (shouldRedirect) {
|
||||||
log.debug("[auth-screen] useEffect → navigator.replace [via pendingRedirect flag]", {
|
log.debug("[auth-screen] useEffect → navigator.replace", {
|
||||||
redirectTo: safeRedirectTo,
|
redirectTo: safeRedirectTo,
|
||||||
});
|
});
|
||||||
authDispatch({ type: "AuthClearPendingRedirect" });
|
|
||||||
navigator.replace(safeRedirectTo);
|
navigator.replace(safeRedirectTo);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
state.loginStatus,
|
state.loginStatus,
|
||||||
state.pendingRedirect,
|
|
||||||
authDispatch,
|
|
||||||
navigator,
|
navigator,
|
||||||
safeRedirectTo,
|
safeRedirectTo,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,31 +5,13 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px));
|
gap: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 0 clamp(var(--spacing-sm, 8px), 2.963vw, var(--spacing-lg, 16px));
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip {
|
|
||||||
font-size: var(--responsive-section-title, var(--font-size-xxl));
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: italic;
|
|
||||||
color: #ffffff;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
width: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px));
|
|
||||||
height: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.facebookButton {
|
.facebookButton {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
min-height: var(--responsive-control-height, 48px);
|
min-height: var(--responsive-control-height, 48px);
|
||||||
padding: 0 clamp(var(--spacing-lg, 16px), 5.556vw, 30px);
|
padding: 0 clamp(var(--spacing-lg, 16px), 5.556vw, 30px);
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -47,7 +29,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 0 0 auto;
|
flex: 1 1 auto;
|
||||||
box-shadow: 0 0 10px rgba(248, 89, 168, 0.3);
|
box-shadow: 0 0 10px rgba(248, 89, 168, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,55 +2,30 @@
|
|||||||
/**
|
/**
|
||||||
* Splash 底部按钮组
|
* Splash 底部按钮组
|
||||||
*/
|
*/
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
||||||
import styles from "./splash-button.module.css";
|
import styles from "./splash-button.module.css";
|
||||||
|
|
||||||
export interface SplashButtonProps {
|
export interface SplashButtonProps {
|
||||||
onSkip: () => void;
|
onStartChat: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SplashButton({ onSkip }: SplashButtonProps) {
|
export function SplashButton({ onStartChat }: SplashButtonProps) {
|
||||||
const state = useAuthState();
|
const state = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
|
||||||
const isLoading = state.isLoading;
|
const isLoading = state.isLoading;
|
||||||
|
|
||||||
const handleFacebookLogin = () => {
|
|
||||||
authDispatch({
|
|
||||||
type: "AuthFacebookLoginSubmitted",
|
|
||||||
provider: "facebook",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
{/* Skip 按钮 = 显式游客登录入口(派发事件 + 立即跳 /chat) */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSkip}
|
onClick={onStartChat}
|
||||||
disabled={isLoading}
|
|
||||||
className={styles.skip}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingIndicator color="#ffffff" />
|
|
||||||
) : (
|
|
||||||
"Skip"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className={styles.separator} aria-hidden="true" />
|
|
||||||
|
|
||||||
{/* 社交登录按钮(NextAuth Facebook OAuth) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleFacebookLogin}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={styles.facebookButton}
|
className={styles.facebookButton}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingIndicator color="#ffffff" />
|
<LoadingIndicator color="#ffffff" />
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.facebookLabel}>Login with Facebook</span>
|
<span className={styles.facebookLabel}>Start Chatting</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
import { hasBusinessLoginToken } from "@/lib/auth/auth_session";
|
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { pwaUtil, Logger } from "@/utils";
|
import { pwaUtil } from "@/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SplashBackground,
|
SplashBackground,
|
||||||
@@ -16,7 +15,7 @@ import {
|
|||||||
} from "./components";
|
} from "./components";
|
||||||
import styles from "./components/splash-screen.module.css";
|
import styles from "./components/splash-screen.module.css";
|
||||||
|
|
||||||
const log = new Logger("AppSplashSplashScreen");
|
const AUTO_OPEN_CHAT_DELAY_MS = 1800;
|
||||||
|
|
||||||
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
|
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -26,10 +25,8 @@ if (typeof window !== "undefined") {
|
|||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const state = useAuthState();
|
const state = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleStartChat = () => {
|
||||||
authDispatch({ type: "AuthGuestLoginSubmitted" });
|
|
||||||
navigator.openChat({ replace: true });
|
navigator.openChat({ replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,51 +34,14 @@ export function SplashScreen() {
|
|||||||
pwaUtil.prepareInstallPrompt();
|
pwaUtil.prepareInstallPrompt();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// useEffect ① 启动时检查 localStorage 里的 loginToken:
|
|
||||||
// - loginToken(OAuth/email sync 写下的)→ 视为"已登录"跳 /chat
|
|
||||||
// - 不查 guestToken —— 游客用户停留 splash(按你要求)
|
|
||||||
// - 都没有 → 留 splash
|
|
||||||
// proxy 读不到 localStorage(Edge runtime),所以这条 fallback 必须在 Client 侧。
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const timer = window.setTimeout(() => {
|
||||||
void (async () => {
|
|
||||||
if (!cancelled && (await hasBusinessLoginToken())) {
|
|
||||||
navigator.openChat({ replace: true });
|
navigator.openChat({ replace: true });
|
||||||
}
|
}, AUTO_OPEN_CHAT_DELAY_MS);
|
||||||
})();
|
|
||||||
return () => {
|
return () => window.clearTimeout(timer);
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [navigator]);
|
}, [navigator]);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// useEffect ② 按钮刚派了事件(pendingRedirect=true) +
|
|
||||||
// loginStatus 是非 notLoggedIn → 跳 /chat
|
|
||||||
//
|
|
||||||
// 关键:`pendingRedirect` 是 auth context 里的持久状态(不是 ref)——
|
|
||||||
// re-mount 时不重置,区分"刚按了"和"历史登录"准确无误。
|
|
||||||
// 跳完调 `AuthClearPendingRedirect` 置 false(一次性信号)。
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
// 调试日志(保留,不删 —— 用于排查"re-mount 误跳 /chat"问题)
|
|
||||||
log.debug("[splash] useEffect ② (loginStatus + pendingRedirect)", {
|
|
||||||
pending: state.pendingRedirect,
|
|
||||||
loginStatus: state.loginStatus,
|
|
||||||
willRedirect:
|
|
||||||
state.pendingRedirect && state.loginStatus !== "notLoggedIn",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") {
|
|
||||||
log.debug(
|
|
||||||
"[splash] useEffect ② → navigator.openChat [via pendingRedirect flag]",
|
|
||||||
);
|
|
||||||
authDispatch({ type: "AuthClearPendingRedirect" });
|
|
||||||
navigator.openChat({ replace: true });
|
|
||||||
}
|
|
||||||
}, [state.loginStatus, state.pendingRedirect, authDispatch, navigator]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell background="var(--color-sidebar-background)">
|
<MobileShell background="var(--color-sidebar-background)">
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
@@ -93,28 +53,8 @@ export function SplashScreen() {
|
|||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
<SplashContent />
|
<SplashContent />
|
||||||
<div className={styles.buttonArea}>
|
<div className={styles.buttonArea}>
|
||||||
<SplashButton onSkip={handleSkip} />
|
<SplashButton onStartChat={handleStartChat} />
|
||||||
</div>
|
</div>
|
||||||
{/*
|
|
||||||
* 登录错误提示 —— auth state machine 的 onError 会设 errorMessage
|
|
||||||
* (如 Facebook 同步后端失败 / 后端返回数据 schema 不匹配等)。
|
|
||||||
* 之前没显示,导致用户卡在 splash 看不到原因。
|
|
||||||
*/}
|
|
||||||
{state.errorMessage ? (
|
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
color: "#c0392b",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
marginTop: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: "320px",
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{state.errorMessage}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className={styles.bottom}>
|
<p className={styles.bottom}>
|
||||||
Elio Silvestri, Your exclusive AI boyfriend
|
Elio Silvestri, Your exclusive AI boyfriend
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ function createTestAuthMachine(
|
|||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
checkAuthStatus: LoginStatus;
|
checkAuthStatus: LoginStatus;
|
||||||
guestLogin: LoginStatus;
|
guestLogin: LoginStatus;
|
||||||
|
guestLoginSpy: () => void;
|
||||||
emailLogin: LoginStatus;
|
emailLogin: LoginStatus;
|
||||||
logoutSpy: () => void;
|
logoutSpy: () => void;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
|
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
|
||||||
|
const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>();
|
||||||
|
|
||||||
return authMachine.provide({
|
return authMachine.provide({
|
||||||
actors: {
|
actors: {
|
||||||
@@ -21,7 +23,10 @@ function createTestAuthMachine(
|
|||||||
async () => overrides.checkAuthStatus ?? "notLoggedIn",
|
async () => overrides.checkAuthStatus ?? "notLoggedIn",
|
||||||
),
|
),
|
||||||
guestLogin: fromPromise<LoginStatus, void>(
|
guestLogin: fromPromise<LoginStatus, void>(
|
||||||
async () => overrides.guestLogin ?? "guest",
|
async () => {
|
||||||
|
guestLoginSpy();
|
||||||
|
return overrides.guestLogin ?? "guest";
|
||||||
|
},
|
||||||
),
|
),
|
||||||
emailLogin: fromPromise<
|
emailLogin: fromPromise<
|
||||||
LoginStatus,
|
LoginStatus,
|
||||||
@@ -72,9 +77,10 @@ describe("authMachine", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("initializes from storage without triggering a pending redirect", async () => {
|
it("initializes from storage without triggering guest login", async () => {
|
||||||
|
const guestLoginSpy = vi.fn<() => void>();
|
||||||
const actor = createActor(
|
const actor = createActor(
|
||||||
createTestAuthMachine({ checkAuthStatus: "facebook" }),
|
createTestAuthMachine({ checkAuthStatus: "facebook", guestLoginSpy }),
|
||||||
).start();
|
).start();
|
||||||
|
|
||||||
actor.send({ type: "AuthInit" });
|
actor.send({ type: "AuthInit" });
|
||||||
@@ -83,26 +89,27 @@ describe("authMachine", () => {
|
|||||||
const context = actor.getSnapshot().context;
|
const context = actor.getSnapshot().context;
|
||||||
expect(context.loginStatus).toBe("facebook");
|
expect(context.loginStatus).toBe("facebook");
|
||||||
expect(context.hasInitialized).toBe(true);
|
expect(context.hasInitialized).toBe(true);
|
||||||
expect(context.pendingRedirect).toBe(false);
|
expect(guestLoginSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("guest login marks initialized but does not set pending redirect", async () => {
|
it("automatically guest logs in when initialization finds no login state", async () => {
|
||||||
const actor = createActor(createTestAuthMachine()).start();
|
const guestLoginSpy = vi.fn<() => void>();
|
||||||
|
const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start();
|
||||||
|
|
||||||
actor.send({ type: "AuthGuestLoginSubmitted" });
|
actor.send({ type: "AuthInit" });
|
||||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
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);
|
||||||
expect(context.pendingRedirect).toBe(false);
|
expect(guestLoginSpy).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("email login sets pending redirect until it is cleared", async () => {
|
it("email login updates login status", async () => {
|
||||||
const actor = createActor(createTestAuthMachine()).start();
|
const actor = createActor(createTestAuthMachine()).start();
|
||||||
|
|
||||||
actor.send({
|
actor.send({
|
||||||
@@ -113,10 +120,7 @@ describe("authMachine", () => {
|
|||||||
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
|
||||||
|
|
||||||
expect(actor.getSnapshot().context.loginStatus).toBe("email");
|
expect(actor.getSnapshot().context.loginStatus).toBe("email");
|
||||||
expect(actor.getSnapshot().context.pendingRedirect).toBe(true);
|
expect(actor.getSnapshot().context.hasInitialized).toBe(true);
|
||||||
|
|
||||||
actor.send({ type: "AuthClearPendingRedirect" });
|
|
||||||
expect(actor.getSnapshot().context.pendingRedirect).toBe(false);
|
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
@@ -138,7 +142,6 @@ describe("authMachine", () => {
|
|||||||
const context = actor.getSnapshot().context;
|
const context = actor.getSnapshot().context;
|
||||||
expect(logoutSpy).toHaveBeenCalledOnce();
|
expect(logoutSpy).toHaveBeenCalledOnce();
|
||||||
expect(context.loginStatus).toBe("notLoggedIn");
|
expect(context.loginStatus).toBe("notLoggedIn");
|
||||||
expect(context.pendingRedirect).toBe(false);
|
|
||||||
expect(context.hasInitialized).toBe(true);
|
expect(context.hasInitialized).toBe(true);
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
|
|||||||
@@ -193,13 +193,13 @@ async function persistFacebookProfile(accessToken: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 显式游客登录 actor(opt-in) ==========
|
// ========== 自动游客登录 actor ==========
|
||||||
// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑。
|
// AuthInit 检查不到任何本地登录态时触发,用于自动补齐游客会话。
|
||||||
|
|
||||||
export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
|
export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
|
||||||
log.debug("[auth-machine] guestLoginActor ENTRY");
|
log.debug("[auth-machine] guestLoginActor ENTRY");
|
||||||
|
|
||||||
// 1. 先查本地 guestToken —— 已有就直接进 idle(带 pendingRedirect)
|
// 1. 先查本地 guestToken —— 已有就直接进入游客态
|
||||||
const hasTokenR = await AuthStorage.getInstance().hasGuestToken();
|
const hasTokenR = await AuthStorage.getInstance().hasGuestToken();
|
||||||
log.debug("[auth-machine] guestLoginActor hasGuestToken", {
|
log.debug("[auth-machine] guestLoginActor hasGuestToken", {
|
||||||
success: hasTokenR.success,
|
success: hasTokenR.success,
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ interface AuthState {
|
|||||||
loginStatus: MachineContext["loginStatus"];
|
loginStatus: MachineContext["loginStatus"];
|
||||||
/** 启动时的本地登录态恢复是否已经完成 */
|
/** 启动时的本地登录态恢复是否已经完成 */
|
||||||
hasInitialized: MachineContext["hasInitialized"];
|
hasInitialized: MachineContext["hasInitialized"];
|
||||||
/**
|
|
||||||
* 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮派完业务事件后置 true,
|
|
||||||
* splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"`
|
|
||||||
* 触发跳转 + 调 `AuthClearPendingRedirect` 置 false。
|
|
||||||
*/
|
|
||||||
pendingRedirect: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthStateCtx = createContext<AuthState | null>(null);
|
const AuthStateCtx = createContext<AuthState | null>(null);
|
||||||
@@ -64,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") ||
|
||||||
@@ -77,7 +71,6 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
errorMessage: state.context.errorMessage,
|
errorMessage: state.context.errorMessage,
|
||||||
loginStatus: state.context.loginStatus,
|
loginStatus: state.context.loginStatus,
|
||||||
hasInitialized: state.context.hasInitialized,
|
hasInitialized: state.context.hasInitialized,
|
||||||
pendingRedirect: state.context.pendingRedirect,
|
|
||||||
}),
|
}),
|
||||||
[state],
|
[state],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ export type AuthEvent =
|
|||||||
| { type: "AuthLogoutSubmitted" }
|
| { type: "AuthLogoutSubmitted" }
|
||||||
/** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */
|
/** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */
|
||||||
| { type: "AuthInit" }
|
| { type: "AuthInit" }
|
||||||
/** 显式游客登录 —— splash 上点"游客模式"按钮触发(不自动) */
|
|
||||||
| { type: "AuthGuestLoginSubmitted" }
|
|
||||||
// 业务事件(提交)
|
// 业务事件(提交)
|
||||||
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
|
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
|
||||||
| {
|
| {
|
||||||
@@ -32,9 +30,4 @@ export type AuthEvent =
|
|||||||
// OAuth 回调后:把 OAuth provider token 转交后端换业务 token
|
// OAuth 回调后:把 OAuth provider token 转交后端换业务 token
|
||||||
// 由 <OAuthSessionSync /> 监听 useSession() 派发
|
// 由 <OAuthSessionSync /> 监听 useSession() 派发
|
||||||
| { type: "AuthGoogleSyncSubmitted"; idToken: string }
|
| { type: "AuthGoogleSyncSubmitted"; idToken: string }
|
||||||
| { type: "AuthFacebookSyncSubmitted"; accessToken: string }
|
| { type: "AuthFacebookSyncSubmitted"; accessToken: string };
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
|
||||||
// splash / auth screen 跳完 /chat 后清"刚按了"信号(一次性):
|
|
||||||
// `pendingRedirect` 在 login onDone action 里自动置 true(不是按钮派的)
|
|
||||||
// —— 因此只需要 `AuthClearPendingRedirect` 一个事件
|
|
||||||
| { type: "AuthClearPendingRedirect" };
|
|
||||||
|
|||||||
@@ -32,11 +32,7 @@ const toAuthErrorMessage = (error: unknown): string =>
|
|||||||
//
|
//
|
||||||
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
|
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
|
||||||
//
|
//
|
||||||
// `pendingRedirect: boolean`("用户显式触发了登录"信号)——
|
// 未找到任何登录态时,初始化流程会自动进入 guest login,补齐游客会话。
|
||||||
// 在登录成功的 onDone 里自动置 true:
|
|
||||||
// splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat
|
|
||||||
// 跳完派 `AuthClearPendingRedirect` 置 false
|
|
||||||
// `initializing` 不设 flag —— 启动 init 是被动读 token,不算"用户意图"。
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
export const authMachine = setup({
|
export const authMachine = setup({
|
||||||
types: {
|
types: {
|
||||||
@@ -86,7 +82,6 @@ export const authMachine = setup({
|
|||||||
// 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发
|
// 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发
|
||||||
AuthInit: "initializing",
|
AuthInit: "initializing",
|
||||||
|
|
||||||
AuthGuestLoginSubmitted: "loadingGuestLogin",
|
|
||||||
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
||||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||||
AuthGoogleLoginSubmitted: "loadingOAuth",
|
AuthGoogleLoginSubmitted: "loadingOAuth",
|
||||||
@@ -100,10 +95,6 @@ export const authMachine = setup({
|
|||||||
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
|
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
|
||||||
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
|
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
|
||||||
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
|
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
|
||||||
// splash / auth screen 跳完 /chat 后清flag
|
|
||||||
AuthClearPendingRedirect: {
|
|
||||||
actions: assign({ pendingRedirect: false }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -111,15 +102,26 @@ 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,
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
// 不置 pendingRedirect —— 状态查询是被动读 token,不算"用户意图"
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
@@ -139,8 +141,6 @@ export const authMachine = setup({
|
|||||||
// 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型
|
// 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型
|
||||||
actions: assign({
|
actions: assign({
|
||||||
loginStatus: ({ event }) => event.output,
|
loginStatus: ({ event }) => event.output,
|
||||||
// Skip 点击后已在 splash 里立即跳 /chat;guest login 不再依赖 pendingRedirect。
|
|
||||||
pendingRedirect: false,
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
@@ -168,7 +168,6 @@ export const authMachine = setup({
|
|||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
loginStatus: ({ event }) => event.output,
|
loginStatus: ({ event }) => event.output,
|
||||||
pendingRedirect: true, // ← 邮箱登录成功 → 跳
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
@@ -201,7 +200,6 @@ export const authMachine = setup({
|
|||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
loginStatus: ({ event }) => event.output,
|
loginStatus: ({ event }) => event.output,
|
||||||
pendingRedirect: true, // ← 注册成功 → 跳
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
@@ -238,7 +236,7 @@ export const authMachine = setup({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loggingOut: {
|
loggingOut: {
|
||||||
entry: assign({ errorMessage: null, pendingRedirect: false }),
|
entry: assign({ errorMessage: null }),
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "logout",
|
src: "logout",
|
||||||
onDone: {
|
onDone: {
|
||||||
@@ -271,7 +269,6 @@ export const authMachine = setup({
|
|||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
loginStatus: ({ event }) => event.output,
|
loginStatus: ({ event }) => event.output,
|
||||||
pendingRedirect: true, // ← OAuth sync 成功 → 跳
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
@@ -298,7 +295,6 @@ export const authMachine = setup({
|
|||||||
target: "idle",
|
target: "idle",
|
||||||
actions: assign({
|
actions: assign({
|
||||||
loginStatus: ({ event }) => event.output,
|
loginStatus: ({ event }) => event.output,
|
||||||
pendingRedirect: true, // ← OAuth sync 成功 → 跳
|
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
hasInitialized: true,
|
hasInitialized: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,15 +17,6 @@ export interface AuthState {
|
|||||||
loginStatus: LoginStatus;
|
loginStatus: LoginStatus;
|
||||||
/** 启动时的本地登录态恢复是否已经完成 */
|
/** 启动时的本地登录态恢复是否已经完成 */
|
||||||
hasInitialized: boolean;
|
hasInitialized: boolean;
|
||||||
/**
|
|
||||||
* 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮刚派事件 → 置 true
|
|
||||||
* → splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 触发跳转
|
|
||||||
* → 跳完置 false
|
|
||||||
*
|
|
||||||
* 历史:之前用 useRef 做 transition detection,但 re-mount 时 ref 重置,bug 复发。
|
|
||||||
* 现在 auth state 里持久(machine 不重置)—— re-mount 时也能区分"刚按"和"历史"。
|
|
||||||
*/
|
|
||||||
pendingRedirect: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: AuthState = {
|
export const initialState: AuthState = {
|
||||||
@@ -38,5 +29,4 @@ export const initialState: AuthState = {
|
|||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
loginStatus: "notLoggedIn",
|
loginStatus: "notLoggedIn",
|
||||||
hasInitialized: false,
|
hasInitialized: false,
|
||||||
pendingRedirect: false,
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user