fix(auth): return to guest session on logout
This commit is contained in:
@@ -44,6 +44,7 @@ export function SidebarScreen() {
|
||||
userDispatch({ type: "UserClearLocal" });
|
||||
authDispatch({ type: "AuthLogoutSubmitted" });
|
||||
void signOut({ redirect: false });
|
||||
navigator.openChat({ replace: true });
|
||||
};
|
||||
|
||||
// 状态派生:未登录 / 已登录未 VIP / 已登录 VIP
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AuthStorage 完整实现(基于 unstorage 抽象)
|
||||
*
|
||||
* SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。
|
||||
* 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。
|
||||
*
|
||||
* 设计说明:
|
||||
* - 内部使用 `SpAsyncUtil` 静态类(@/utils/storage)
|
||||
* - 浏览器 → `localStorage` driver;SSR → memory driver(unstorage 自动)
|
||||
* - 单例挂在 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -235,6 +235,10 @@ export const chatMachine = setup({
|
||||
},
|
||||
],
|
||||
on: {
|
||||
ChatGuestLogin: {
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatLogout: {
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
|
||||
Reference in New Issue
Block a user