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:
1 parent
2b58fe6c8b
commit
31f9838095
16 files changed
+337
-58
No files matched your search
@@ -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) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user