fix(auth): return to guest session on logout

This commit is contained in:
2026-07-03 16:14:04 +08:00
parent b22f23bcc4
commit 8f17ea9c9f
11 changed files with 97 additions and 26 deletions
+1
View File
@@ -44,6 +44,7 @@ export function SidebarScreen() {
userDispatch({ type: "UserClearLocal" }); userDispatch({ type: "UserClearLocal" });
authDispatch({ type: "AuthLogoutSubmitted" }); authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false }); void signOut({ redirect: false });
navigator.openChat({ replace: true });
}; };
// 状态派生:未登录 / 已登录未 VIP / 已登录 VIP // 状态派生:未登录 / 已登录未 VIP / 已登录 VIP
+40
View File
@@ -116,6 +116,46 @@ export class AuthRepository implements IAuthRepository {
} }
} }
/**
* 退出真实用户登录并恢复游客态。
*
* 与 `logout()` 不同,这里不会删除 guestToken / deviceId。退出后会尝试
* 重新跑游客登录,以便恢复游客 userId;如果游客登录接口失败但本地仍有
* guestToken,则降级回本地游客态,避免用户从聊天页被打回 splash。
*/
async logoutToGuest(deviceId: string): Promise<Result<void>> {
try {
await this.api.logout();
} catch (e) {
log.warn("[AuthRepository] logout API failed, restoring guest anyway", e);
}
try {
await this.storage.clearBusinessAuthData();
await this.userStorage.clearUserData();
} catch (e) {
return Result.err(e);
}
const guestResult = await this.guestLogin(deviceId);
if (Result.isOk(guestResult)) return Result.ok(undefined);
const hasGuestToken = await this.storage.hasGuestToken();
if (hasGuestToken.success && hasGuestToken.data) {
const providerResult = await this.storage.setLoginProvider(
LoginStatus.Guest,
);
if (Result.isErr(providerResult)) return providerResult;
log.warn(
"[AuthRepository] guest login failed after logout, falling back to local guest token",
guestResult.error,
);
return Result.ok(undefined);
}
return Result.err(guestResult.error);
}
/** /**
* 游客登录:使用 deviceId 换取 guest token。 * 游客登录:使用 deviceId 换取 guest token。
* 成功后写 guest token + deviceId + userId。 * 成功后写 guest token + deviceId + userId。
@@ -46,6 +46,11 @@ export interface IAuthRepository {
*/ */
logout(): Promise<Result<void>>; logout(): Promise<Result<void>>;
/**
* 退出真实用户登录并恢复游客态。
*/
logoutToGuest(deviceId: string): Promise<Result<void>>;
/** 游客登录:使用 deviceId 换取 guest token。 */ /** 游客登录:使用 deviceId 换取 guest token。 */
guestLogin(deviceId: string): Promise<Result<GuestLoginResponse>>; guestLogin(deviceId: string): Promise<Result<GuestLoginResponse>>;
+10 -14
View File
@@ -1,19 +1,5 @@
"use client"; "use client";
/**
* AuthStorage 完整实现(基于 unstorage 抽象)
*
* SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。
* 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。
*
* 设计说明:
* - 内部使用 `SpAsyncUtil` 静态类(@/utils/storage
* - 浏览器 → `localStorage` driverSSR → memory driverunstorage 自动)
* - 单例挂在 class 静态字段,规避 HMR 复用污染
* - `hasXxx` = `token != null && token.length > 0`,对齐 Dart 端 `isNotEmpty` 语义
* - `clearAuthData` 顺序清理:loginToken → loginProvider → guestToken → refreshToken,任一失败立即返回
*/
import { LoginStatus, type LoginStatus as LoginStatusT } from "@/data/dto/auth"; import { LoginStatus, type LoginStatus as LoginStatusT } from "@/data/dto/auth";
import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils"; import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils";
import { StorageKeys } from "../storage_keys"; import { StorageKeys } from "../storage_keys";
@@ -117,6 +103,16 @@ export class AuthStorage implements IAuthStorage {
// ---- bulk clear ---- // ---- bulk clear ----
async clearBusinessAuthData(): Promise<ResultT<void>> {
const r1 = await this.clearLoginToken();
if (!r1.success) return r1;
const r2 = await this.clearRefreshToken();
if (!r2.success) return r2;
const r3 = await this.clearFacebookId();
if (!r3.success) return r3;
return this.clearLoginProvider();
}
async clearAuthData(): Promise<ResultT<void>> { async clearAuthData(): Promise<ResultT<void>> {
const r1 = await this.clearLoginToken(); const r1 = await this.clearLoginToken();
if (!r1.success) return r1; if (!r1.success) return r1;
+2
View File
@@ -6,6 +6,7 @@
* 对齐 Dart 端 `IAuthStorage`lib/data/services/storage/iauth_storage.dart): * 对齐 Dart 端 `IAuthStorage`lib/data/services/storage/iauth_storage.dart):
* - loginToken / loginProvider / guestToken / deviceId / refreshToken / facebookId 字段的 CRUD * - loginToken / loginProvider / guestToken / deviceId / refreshToken / facebookId 字段的 CRUD
* - `clearAuthData()` 批量清空登录态 * - `clearAuthData()` 批量清空登录态
* - `clearBusinessAuthData()` 仅清空真实用户登录态,保留 guest token / deviceId
* - 所有方法返回 `Promise<Result<T>>`,与 Dart `Future<Result<T>>` 对齐 * - 所有方法返回 `Promise<Result<T>>`,与 Dart `Future<Result<T>>` 对齐
*/ */
@@ -38,5 +39,6 @@ export interface IAuthStorage {
setFacebookId(id: string): Promise<Result<void>>; setFacebookId(id: string): Promise<Result<void>>;
clearFacebookId(): Promise<Result<void>>; clearFacebookId(): Promise<Result<void>>;
clearBusinessAuthData(): Promise<Result<void>>;
clearAuthData(): Promise<Result<void>>; clearAuthData(): Promise<Result<void>>;
} }
@@ -35,6 +35,7 @@ function makeAuthStorageMock(input: {
getFacebookId: vi.fn(async () => Result.ok(null)), getFacebookId: vi.fn(async () => Result.ok(null)),
setFacebookId: vi.fn(async () => Result.ok(undefined)), setFacebookId: vi.fn(async () => Result.ok(undefined)),
clearFacebookId: vi.fn(async () => Result.ok(undefined)), clearFacebookId: vi.fn(async () => Result.ok(undefined)),
clearBusinessAuthData: vi.fn(async () => Result.ok(undefined)),
clearAuthData: vi.fn(async () => Result.ok(undefined)), clearAuthData: vi.fn(async () => Result.ok(undefined)),
}; };
} }
@@ -49,8 +49,9 @@ function createTestAuthMachine(
LoginStatus, LoginStatus,
{ accessToken: string } { accessToken: string }
>(async () => "facebook"), >(async () => "facebook"),
logout: fromPromise<void>(async () => { logout: fromPromise<LoginStatus>(async () => {
logoutSpy(); logoutSpy();
return "guest";
}), }),
}, },
}); });
@@ -125,7 +126,7 @@ describe("authMachine", () => {
actor.stop(); actor.stop();
}); });
it("logout resets auth state and keeps initialization completed", async () => { it("logout transitions authenticated users back to guest", async () => {
const logoutSpy = vi.fn<() => void>(); const logoutSpy = vi.fn<() => void>();
const actor = createActor(createTestAuthMachine({ logoutSpy })).start(); const actor = createActor(createTestAuthMachine({ logoutSpy })).start();
@@ -141,7 +142,7 @@ 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("guest");
expect(context.hasInitialized).toBe(true); expect(context.hasInitialized).toBe(true);
actor.stop(); actor.stop();
+4 -2
View File
@@ -94,10 +94,12 @@ export const oauthSignInActor = fromPromise<void, AuthProvider>(async ({ input }
} }
}); });
export const logoutActor = fromPromise<void>(async () => { export const logoutActor = fromPromise<LoginStatus>(async () => {
const authRepo = getAuthRepository(); const authRepo = getAuthRepository();
const result = await authRepo.logout(); const deviceId = await deviceIdentifier.getDeviceId();
const result = await authRepo.logoutToGuest(deviceId);
if (Result.isErr(result)) throw result.error; if (Result.isErr(result)) throw result.error;
return "guest" as LoginStatus;
}); });
// ========== OAuth token → 后端业务 token sync actors ========== // ========== OAuth token → 后端业务 token sync actors ==========
+5 -4
View File
@@ -241,15 +241,16 @@ export const authMachine = setup({
src: "logout", src: "logout",
onDone: { onDone: {
target: "idle", target: "idle",
actions: assign(() => ({ actions: assign({
...initialState, loginStatus: ({ event }) => event.output,
hasInitialized: true, hasInitialized: true,
})), errorMessage: null,
}),
}, },
onError: { onError: {
target: "idle", target: "idle",
actions: assign(({ event }) => ({ actions: assign(({ event }) => ({
...initialState, loginStatus: "guest",
hasInitialized: true, hasInitialized: true,
errorMessage: toAuthErrorMessage(event.error), errorMessage: toAuthErrorMessage(event.error),
})), })),
@@ -164,7 +164,7 @@ function createLoadHistoryCallback(
} }
describe("chatMachine transitions", () => { describe("chatMachine transitions", () => {
it("keeps guest logout disabled while allowing upgrade to user login", async () => { it("keeps guest logout disabled and supports user to guest transition", async () => {
const actor = createActor(createTestChatMachine()).start(); const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatGuestLogin" }); actor.send({ type: "ChatGuestLogin" });
@@ -180,6 +180,22 @@ describe("chatMachine transitions", () => {
snapshot.matches({ userSession: "ready" }), snapshot.matches({ userSession: "ready" }),
); );
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.stop();
});
it("allows explicit logout from user session", async () => {
const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
actor.send({ type: "ChatLogout" }); actor.send({ type: "ChatLogout" });
expect(actor.getSnapshot().matches("idle")).toBe(true); expect(actor.getSnapshot().matches("idle")).toBe(true);
@@ -270,7 +286,7 @@ describe("chatMachine transitions", () => {
actor.stop(); actor.stop();
}); });
it("ignores guest login while an authenticated user session is active", async () => { it("switches to guest session when guest login arrives during user session", async () => {
const actor = createActor(createTestChatMachine()).start(); const actor = createActor(createTestChatMachine()).start();
actor.send({ type: "ChatUserLogin", token: "token" }); actor.send({ type: "ChatUserLogin", token: "token" });
@@ -279,7 +295,9 @@ describe("chatMachine transitions", () => {
); );
actor.send({ type: "ChatGuestLogin" }); actor.send({ type: "ChatGuestLogin" });
expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true); await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.stop(); actor.stop();
}); });
+4
View File
@@ -235,6 +235,10 @@ export const chatMachine = setup({
}, },
], ],
on: { on: {
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatLogout: { ChatLogout: {
target: "#chat.idle", target: "#chat.idle",
actions: "clearChatSession", actions: "clearChatSession",