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" });
authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false });
navigator.openChat({ replace: true });
};
// 状态派生:未登录 / 已登录未 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。
* 成功后写 guest token + deviceId + userId。
@@ -46,6 +46,11 @@ export interface IAuthRepository {
*/
logout(): Promise<Result<void>>;
/**
* 退出真实用户登录并恢复游客态。
*/
logoutToGuest(deviceId: string): Promise<Result<void>>;
/** 游客登录:使用 deviceId 换取 guest token。 */
guestLogin(deviceId: string): Promise<Result<GuestLoginResponse>>;
+10 -14
View File
@@ -1,19 +1,5 @@
"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 { Result, type Result as ResultT, SpAsyncUtil } from "@/utils";
import { StorageKeys } from "../storage_keys";
@@ -117,6 +103,16 @@ export class AuthStorage implements IAuthStorage {
// ---- 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>> {
const r1 = await this.clearLoginToken();
if (!r1.success) return r1;
+2
View File
@@ -6,6 +6,7 @@
* 对齐 Dart 端 `IAuthStorage`lib/data/services/storage/iauth_storage.dart):
* - loginToken / loginProvider / guestToken / deviceId / refreshToken / facebookId 字段的 CRUD
* - `clearAuthData()` 批量清空登录态
* - `clearBusinessAuthData()` 仅清空真实用户登录态,保留 guest token / deviceId
* - 所有方法返回 `Promise<Result<T>>`,与 Dart `Future<Result<T>>` 对齐
*/
@@ -38,5 +39,6 @@ export interface IAuthStorage {
setFacebookId(id: string): Promise<Result<void>>;
clearFacebookId(): Promise<Result<void>>;
clearBusinessAuthData(): Promise<Result<void>>;
clearAuthData(): Promise<Result<void>>;
}
@@ -35,6 +35,7 @@ function makeAuthStorageMock(input: {
getFacebookId: vi.fn(async () => Result.ok(null)),
setFacebookId: 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)),
};
}
@@ -49,8 +49,9 @@ function createTestAuthMachine(
LoginStatus,
{ accessToken: string }
>(async () => "facebook"),
logout: fromPromise<void>(async () => {
logout: fromPromise<LoginStatus>(async () => {
logoutSpy();
return "guest";
}),
},
});
@@ -125,7 +126,7 @@ describe("authMachine", () => {
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 actor = createActor(createTestAuthMachine({ logoutSpy })).start();
@@ -141,7 +142,7 @@ describe("authMachine", () => {
const context = actor.getSnapshot().context;
expect(logoutSpy).toHaveBeenCalledOnce();
expect(context.loginStatus).toBe("notLoggedIn");
expect(context.loginStatus).toBe("guest");
expect(context.hasInitialized).toBe(true);
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 result = await authRepo.logout();
const deviceId = await deviceIdentifier.getDeviceId();
const result = await authRepo.logoutToGuest(deviceId);
if (Result.isErr(result)) throw result.error;
return "guest" as LoginStatus;
});
// ========== OAuth token → 后端业务 token sync actors ==========
+5 -4
View File
@@ -241,15 +241,16 @@ export const authMachine = setup({
src: "logout",
onDone: {
target: "idle",
actions: assign(() => ({
...initialState,
actions: assign({
loginStatus: ({ event }) => event.output,
hasInitialized: true,
})),
errorMessage: null,
}),
},
onError: {
target: "idle",
actions: assign(({ event }) => ({
...initialState,
loginStatus: "guest",
hasInitialized: true,
errorMessage: toAuthErrorMessage(event.error),
})),
@@ -164,7 +164,7 @@ function createLoadHistoryCallback(
}
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();
actor.send({ type: "ChatGuestLogin" });
@@ -180,6 +180,22 @@ describe("chatMachine transitions", () => {
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" });
expect(actor.getSnapshot().matches("idle")).toBe(true);
@@ -270,7 +286,7 @@ describe("chatMachine transitions", () => {
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();
actor.send({ type: "ChatUserLogin", token: "token" });
@@ -279,7 +295,9 @@ describe("chatMachine transitions", () => {
);
actor.send({ type: "ChatGuestLogin" });
expect(actor.getSnapshot().matches({ userSession: "ready" })).toBe(true);
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
actor.stop();
});
+4
View File
@@ -235,6 +235,10 @@ export const chatMachine = setup({
},
],
on: {
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatLogout: {
target: "#chat.idle",
actions: "clearChatSession",