fix(auth): return to guest session on logout
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
"use client";
|
"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 { 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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 ==========
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user