feat(embed): iframe 嵌入模式(去 chrome + 主题隔离 / hideJump / 登录用户隔离)

外部系统用 iframe 套用深链单智能体问答/多智能体会商时,三个按标签页(sessionStorage)
开关,由深链 URL 参数开启、跨登录跳转/刷新/页内导航存活,绝不跨标签污染正常系统页:

- embed=1(可带 theme=):去全局导航栏 + 侧边栏;按外部主题色强制明/暗,走
  next-themes forcedTheme(不写 localStorage),嵌入侧不调 setTheme/persistSidebarEnabled
- hideJump=1:隐藏深链底部「快捷跳转」栏 TaskJumpBar(底部「下一步」按钮 + 右侧悬浮菜单)
- 登录隔离:嵌入态鉴权/身份键(deerflow.auth/userInfo/login.*/tools_token 等)改走
  sessionStorage(core/auth/scoped-storage.ts 的 authStorage),iframe 登录用户与正常
  登录用户完全隔离、互不覆盖;token 读取统一过 getStoredAuth,改中心函数即全体生效;
  跨标签账号变更监听 AuthSyncWatcher 嵌入态禁用

嵌入会话在 main.tsx 渲染前同步启用(早于任何鉴权写入)。正常标签页 isXxx() 恒 false,
行为零变化。新增 core/embed/embed-session.ts、core/auth/scoped-storage.ts。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-19 12:02:36 +08:00
1 parent 2b58fe6c8b
commit 31f9838095
16 files changed
+337 -58

No files matched your search

+16 -21
View File
@@ -9,6 +9,8 @@
* theme 不存(直接走 ThemeContext,避免双源)。
*/
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
export const EXTERNAL_LOGIN_STORAGE_KEYS = {
ticket: "login.ticket",
userId: "login.userId",
@@ -27,32 +29,25 @@ export type ExternalLoginParams = {
/** 把外部跳转带来的三个参数写到 localStorage;隐私模式 / quota 异常时静默失败 */
export function persistExternalLoginParams(params: ExternalLoginParams): void {
try {
if (params.ticket) {
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.ticket, params.ticket);
}
if (params.userId) {
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, params.userId);
}
if (params.env) {
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.env, params.env);
}
} catch {
// localStorage 不可用时不阻断登录流程
// 嵌入(iframe)态走 sessionStorage(按标签页隔离),正常态走 localStorage —— 见 scoped-storage.ts。
if (params.ticket) {
authStorageSet(EXTERNAL_LOGIN_STORAGE_KEYS.ticket, params.ticket);
}
if (params.userId) {
authStorageSet(EXTERNAL_LOGIN_STORAGE_KEYS.userId, params.userId);
}
if (params.env) {
authStorageSet(EXTERNAL_LOGIN_STORAGE_KEYS.env, params.env);
}
}
/** 读取持久化的外部登录参数;不可用时返回空字符串 */
export function readExternalLoginParams(): Required<ExternalLoginParams> {
try {
return {
ticket: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.ticket) ?? "",
userId: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId) ?? "",
env: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.env) ?? "",
};
} catch {
return { ticket: "", userId: "", env: "" };
}
return {
ticket: authStorageGet(EXTERNAL_LOGIN_STORAGE_KEYS.ticket) ?? "",
userId: authStorageGet(EXTERNAL_LOGIN_STORAGE_KEYS.userId) ?? "",
env: authStorageGet(EXTERNAL_LOGIN_STORAGE_KEYS.env) ?? "",
};
}
/**
+12 -5
View File
@@ -9,6 +9,12 @@ export interface LoginByUsernameResponse {
created: boolean;
}
import {
authStorageGet,
authStorageRemove,
authStorageSet,
} from "@/core/auth/scoped-storage";
const AUTH_STORAGE_KEY = "deerflow.auth";
function isBrowser(): boolean {
@@ -19,7 +25,8 @@ export function getStoredAuth(): LoginByUsernameResponse | null {
if (!isBrowser()) {
return null;
}
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY);
// 嵌入(iframe)态走 sessionStorage、正常态走 localStorage —— 隔离 iframe 登录用户与正常登录用户。
const raw = authStorageGet(AUTH_STORAGE_KEY);
if (!raw) {
return null;
}
@@ -34,14 +41,14 @@ export function setStoredAuth(auth: LoginByUsernameResponse) {
if (!isBrowser()) {
return;
}
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(auth));
authStorageSet(AUTH_STORAGE_KEY, JSON.stringify(auth));
}
export function clearStoredAuth() {
if (!isBrowser()) {
return;
}
window.localStorage.removeItem(AUTH_STORAGE_KEY);
authStorageRemove(AUTH_STORAGE_KEY);
}
/** 与设置 → 个人信息页「用户名」一致:邮箱 @ 前缀,否则 user_id。 */
@@ -67,7 +74,7 @@ export function setStoredUserName(name: string | null | undefined) {
}
let info: Record<string, unknown> = {};
try {
const raw = window.localStorage.getItem("userInfo");
const raw = authStorageGet("userInfo");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
@@ -81,7 +88,7 @@ export function setStoredUserName(name: string | null | undefined) {
return;
}
info.userName = trimmed;
window.localStorage.setItem("userInfo", JSON.stringify(info));
authStorageSet("userInfo", JSON.stringify(info));
}
export function getAccessToken(): string | undefined {
@@ -0,0 +1,56 @@
/**
* 鉴权 / 身份存储的「按登录态隔离」选择器。
*
* 诉求:外部系统用 iframe 套用咱们的深链问答时,**iframe 里登录的用户**要和**同浏览器正常登录
* 的用户**完全隔离——不然 iframe 里随便登一个用户,会覆盖正常标签页的登录态(甚至触发跨标签
* 账号变更提示)。
*
* 根因:同源下 `localStorage` 跨所有标签页共享,鉴权 token(`deerflow.auth`)/ 身份(`userInfo`)/
* 外部登录参数(`login.*` / `tools_token`)都存在那里,iframe 一写就污染正常页。
*
* 方案:嵌入态(`isEmbedActive()`,见 [[embed-session]])下,这些键改走 **sessionStorage**——它按
* **标签页/浏览上下文**隔离,iframe(即便同源)有自己独立的一份,写它绝不动正常标签页的
* localStorage,也不会触发正常页的 `storage` 事件(见 sync.tsx 的跨标签账号监听)。正常态仍走
* localStorage,行为零变化。
*
* 把所有受保护键的读写都收口到 `authStorage()`,保证同一标签页内读写落到同一处(嵌入态恒为
* sessionStorage、正常态恒为 localStorage;嵌入标志一进程内不翻转,故一致)。
*/
import { isEmbedActive } from "@/core/embed/embed-session";
/**
* 返回当前应使用的鉴权存储:嵌入态 → sessionStorage(按标签页隔离);否则 → localStorage。
* 非浏览器环境返回 null(调用方各自有 SSR 守卫)。
*/
export function authStorage(): Storage | null {
if (typeof window === "undefined") return null;
return isEmbedActive() ? window.sessionStorage : window.localStorage;
}
/** 读受保护键(嵌入态 sessionStorage / 正常态 localStorage)。 */
export function authStorageGet(key: string): string | null {
try {
return authStorage()?.getItem(key) ?? null;
} catch {
return null;
}
}
/** 写受保护键。隐私模式 / quota 异常静默失败。 */
export function authStorageSet(key: string, value: string): void {
try {
authStorage()?.setItem(key, value);
} catch {
/* noop */
}
}
/** 删受保护键。 */
export function authStorageRemove(key: string): void {
try {
authStorage()?.removeItem(key);
} catch {
/* noop */
}
}
+5
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { isEmbedActive } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth";
const SETTLE_DELAY_MS = 1200;
@@ -21,6 +22,10 @@ export function AuthSyncWatcher() {
const lastUserIdRef = useRef<string | null>(readUserId());
useEffect(() => {
// 嵌入(iframe)态:鉴权走按标签页的 sessionStorage,本就不与其它标签页共享,且不应被正常
// 标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
if (isEmbedActive()) return;
let settleTimer: ReturnType<typeof setTimeout> | null = null;
function evaluateSettledIdentity() {
@@ -4,6 +4,7 @@ import {
EXTERNAL_LOGIN_STORAGE_KEYS,
persistExternalLoginParams,
} from "./external-login";
import { authStorageSet } from "@/core/auth/scoped-storage";
const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace(
/\/$/,
@@ -49,21 +50,14 @@ type UserDetailTokenResponse = {
};
function persistMaxkeyOnlineToken(token: string): void {
try {
localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
} catch {
// 静默失败,不阻断登录
}
// 嵌入(iframe)态走 sessionStorage(按标签页隔离),正常态走 localStorage。
authStorageSet(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
}
function persistUserDetailToken(data: UserDetailTokenPayload): void {
try {
localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
if (data.user_id) {
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
}
} catch {
// 静默失败
authStorageSet(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
if (data.user_id) {
authStorageSet(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
}
}
+6 -5
View File
@@ -1,4 +1,5 @@
import { requestCrudService } from "@/service/request-axios-service";
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || "";
@@ -23,7 +24,7 @@ export async function loginByYUserId(
token?: string | null,
): Promise<void> {
if (token) {
localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token);
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
}
const res = (await requestCrudService.get(
@@ -37,14 +38,14 @@ export async function loginByYUserId(
// 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的
// access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。
const result = res.data ?? {};
localStorage.setItem(
authStorageSet(
EXTERNAL_USER_INFO_STORAGE_KEY,
JSON.stringify({ ...result, yUserId }),
);
}
export function persistToolsToken(token: string): void {
localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token);
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
}
/**
@@ -59,7 +60,7 @@ export function persistAccessToken(token: string): void {
if (!trimmed) return;
let info: Record<string, unknown> = {};
try {
const raw = localStorage.getItem(EXTERNAL_USER_INFO_STORAGE_KEY);
const raw = authStorageGet(EXTERNAL_USER_INFO_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
@@ -70,5 +71,5 @@ export function persistAccessToken(token: string): void {
info = {};
}
info.accessToken = trimmed;
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
}