feat(auth): 独立登录会话开关(设置-外观)—— 同浏览器多账号互不覆盖

复用 iframe 嵌入的按标签页隔离机制:新增 ?isolate=1 标志,authStorage()
从「仅嵌入」放宽为 isEmbedActive() || isIsolatedSession(),独立态走
sessionStorage 故本标签页登录态不被其它标签页覆盖。设置→外观→「登录会话」
加 Switch,开启时先 migrateAuthToSession() 把 localStorage 受保护键快照搬进
sessionStorage 再翻标志,无感不登出;AuthSyncWatcher 跨标签监听对隔离态实时
忽略,运行时开关即刻生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-19 17:51:08 +08:00
1 parent a9f01bec79
commit a85d2480b4
5 files changed
+146 -15

No files matched your search

+42 -4
View File
@@ -17,15 +17,17 @@
* sessionStorage、正常态恒为 localStorage;嵌入标志一进程内不翻转,故一致)。
*/
import { isEmbedActive } from "@/core/embed/embed-session";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
/**
* 返回当前应使用的鉴权存储:嵌入态 → sessionStorage(按标签页隔离);否则 → localStorage。
* 非浏览器环境返回 null(调用方各自有 SSR 守卫)。
* 返回当前应使用的鉴权存储:嵌入态(iframe)**或**独立会话态(`?isolate=1`)→ sessionStorage
* (按标签页隔离);否则 → localStorage(跨标签共享、持久)。非浏览器环境返回 null(调用方各自
* 有 SSR 守卫)。两种隔离态等价于「本标签页鉴权独立、绝不污染其它标签页登录态」,区别仅在于嵌入态
* 还会去 chrome / 强制主题,独立会话态只切换存储。
*/
export function authStorage(): Storage | null {
if (typeof window === "undefined") return null;
return isEmbedActive() ? window.sessionStorage : window.localStorage;
return isEmbedActive() || isIsolatedSession() ? window.sessionStorage : window.localStorage;
}
/** 读受保护键(嵌入态 sessionStorage / 正常态 localStorage)。 */
@@ -54,3 +56,39 @@ export function authStorageRemove(key: string): void {
/* noop */
}
}
/**
* 受 `authStorage()` 收口管理的鉴权 / 身份键:四个精确键 + `login.` 前缀(外部登录参数族
* ticket/userId/env/maxkeyOnlineToken/userDetailToken)。用于「开启独立会话」时把当前登录态从
* localStorage 快照搬进 sessionStorage,避免切换存储后被登出。
*/
const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token"];
const PROTECTED_AUTH_PREFIX = "login.";
function isProtectedAuthKey(key: string): boolean {
return PROTECTED_AUTH_EXACT_KEYS.includes(key) || key.startsWith(PROTECTED_AUTH_PREFIX);
}
/**
* 把 localStorage 里的鉴权 / 身份键**快照复制**进 sessionStorage(覆盖式)。
*
* 「外观」设置开启独立会话时调用:此后 `authStorage()` 改读 sessionStorage,若不先搬一份当前账号
* 过去就会瞬间「未登录」。每次开启都重新快照(覆盖旧值),确保拿到的是当前 localStorage 的最新账号。
* 仅搬受保护键,不动 sessionStorage 里既有的本标签页专属态(embed.session / 任务上下文等)。
*/
export function migrateAuthToSession(): void {
if (typeof window === "undefined") return;
try {
const ls = window.localStorage;
const ss = window.sessionStorage;
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i);
if (key && isProtectedAuthKey(key)) {
const value = ls.getItem(key);
if (value !== null) ss.setItem(key, value);
}
}
} catch {
/* noop */
}
}
+9 -4
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { isEmbedActive } from "@/core/embed/embed-session";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth";
const SETTLE_DELAY_MS = 1200;
@@ -22,9 +22,9 @@ export function AuthSyncWatcher() {
const lastUserIdRef = useRef<string | null>(readUserId());
useEffect(() => {
// 嵌入(iframe)态:鉴权走按标签页的 sessionStorage,本就不与其它标签页共享,且不应被正常
// 标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
if (isEmbedActive()) return;
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
if (isEmbedActive() || isIsolatedSession()) return;
let settleTimer: ReturnType<typeof setTimeout> | null = null;
@@ -52,6 +52,11 @@ export function AuthSyncWatcher() {
}
function handleStorage(event: StorageEvent) {
// 运行时若已切到独立会话(「外观」开关在挂载后才开启):鉴权改读本标签页 sessionStorage,
// 不应再被其它标签页的 localStorage 账号变更打扰 —— 即时忽略,无需刷新页面。
if (isEmbedActive() || isIsolatedSession()) {
return;
}
// Ignore unrelated keys and localStorage.clear(). Backend session loss is
// handled centrally through 401 responses.
if (event.key !== AUTH_STORAGE_KEY) {