feat: add fixed questions and improve authentication
This commit is contained in:
1 parent
8af4fed89d
commit
4c9f75f31e
42 files changed
+4008
-200
No files matched your search
@@ -49,6 +49,17 @@ function normalizeIdentityPart(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function identityFromLoginName(value: unknown): string | null {
|
||||
const normalized = normalizeIdentityPart(value);
|
||||
if (!normalized) return null;
|
||||
// Explicit email login is domain-specific. Bare username login is the
|
||||
// stable business principal even if the backend's synthetic email domain or
|
||||
// internal UUID changes.
|
||||
return normalized.includes("@")
|
||||
? `email:${normalized}`
|
||||
: `account:${normalized}`;
|
||||
}
|
||||
|
||||
function readUserInfoIdentity(): string | null {
|
||||
let info: Record<string, unknown> | null = null;
|
||||
try {
|
||||
@@ -81,20 +92,24 @@ function readUserInfoIdentity(): string | null {
|
||||
/**
|
||||
* A stable cross-tab identity key for "did the account really change?" checks.
|
||||
*
|
||||
* Some external login paths can re-issue a backend ``user_id`` while resolving
|
||||
* to the same business account. Prefer business-facing identifiers
|
||||
* (email/username/userInfo) and keep ``user_id`` only as the final fallback.
|
||||
* Some external login paths can re-issue a backend ``user_id`` or use a
|
||||
* different synthetic email domain while resolving to the same business
|
||||
* account. Prefer the raw login username returned by the backend, then the
|
||||
* email local-part, and keep ``user_id`` only as the final fallback.
|
||||
*/
|
||||
export function getAccountIdentityKey(
|
||||
auth: LoginByUsernameResponse | null = getStoredAuth(),
|
||||
): string | null {
|
||||
if (!auth) return null;
|
||||
|
||||
const email = normalizeIdentityPart(auth.email);
|
||||
if (email) return `email:${email}`;
|
||||
const loginIdentity = identityFromLoginName(auth.username);
|
||||
if (loginIdentity) return loginIdentity;
|
||||
|
||||
const username = normalizeIdentityPart(auth.username);
|
||||
if (username) return `username:${username}`;
|
||||
const email = normalizeIdentityPart(auth.email);
|
||||
if (email) {
|
||||
const localPart = email.split("@", 1)[0];
|
||||
return localPart ? `account:${localPart}` : `email:${email}`;
|
||||
}
|
||||
|
||||
const userInfoIdentity = readUserInfoIdentity();
|
||||
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAccountIdentityKey } from "@/core/auth";
|
||||
import {
|
||||
AUTH_STATE_CHANGED_EVENT,
|
||||
getAccountDisplayName,
|
||||
getAccountIdentityKey,
|
||||
} from "@/core/auth";
|
||||
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
@@ -14,14 +20,37 @@ function readIdentityKey(): string | null {
|
||||
return getAccountIdentityKey();
|
||||
}
|
||||
|
||||
function freshRouteForAccount(pathname: string): string {
|
||||
// A thread URL belongs to the old account. Keep its workspace/agent context
|
||||
// but move to a valid new conversation before the user can submit into the
|
||||
// stale thread. Other pages can stay in place and are remounted below.
|
||||
const chatMatch = pathname.match(/^(.*\/chats)\/[^/]+\/?$/);
|
||||
return chatMatch ? `${chatMatch[1]}/new` : pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches localStorage auth changes from other
|
||||
* tabs and lets the user decide when to refresh on account switches.
|
||||
* Cross-tab auth boundary for normal pages.
|
||||
*
|
||||
* Same-account token re-issuance is ignored. A genuine account change clears
|
||||
* account-scoped query state, remounts the page, and moves an open chat to its
|
||||
* fresh-chat route without a full browser reload. Embedded/isolated tabs keep
|
||||
* their sessionStorage identity and never participate in this synchronization.
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
export function AuthSyncWatcher({
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const lastIdentityRef = useRef<string | null>(readIdentityKey());
|
||||
const [revision, setRevision] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
|
||||
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
|
||||
if (isEmbedActive() || isIsolatedSession()) return;
|
||||
@@ -36,18 +65,27 @@ export function AuthSyncWatcher() {
|
||||
|
||||
lastIdentityRef.current = nextIdentity;
|
||||
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
|
||||
setRevision((value) => value + 1);
|
||||
|
||||
if (nextIdentity === null) {
|
||||
toast.info("账号已在其它页面退出", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前页面已同步退出状态。",
|
||||
});
|
||||
navigate("/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning("检测到账号在其它标签页发生变化", {
|
||||
const freshRoute = freshRouteForAccount(location.pathname);
|
||||
if (freshRoute !== location.pathname) {
|
||||
navigate(freshRoute, { replace: true });
|
||||
}
|
||||
toast.info("账号已在页面间自动同步", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "刷新同步",
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,14 +106,24 @@ export function AuthSyncWatcher() {
|
||||
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
||||
}
|
||||
|
||||
function handleSameTabAuthChange() {
|
||||
// `storage` never fires in the tab that called setStoredAuth/clearStoredAuth.
|
||||
// Keep the comparison baseline current so a later same-account token
|
||||
// renewal from another normal page is not compared with the pre-login
|
||||
// `null` identity and falsely classified as an account switch.
|
||||
lastIdentityRef.current = readIdentityKey();
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [disabled, location.pathname, navigate, queryClient]);
|
||||
|
||||
return null;
|
||||
return children ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getAccountIdentityKey,
|
||||
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";
|
||||
|
||||
@@ -65,7 +65,9 @@ export function useLiveAccount(): LiveAccount {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
if (!isEmbedActive()) {
|
||||
// Normal pages share localStorage identity across tabs. Embedded and
|
||||
// isolated pages deliberately keep their per-tab sessionStorage identity.
|
||||
if (!isEmbedActive() && !isIsolatedSession()) {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
|
||||
Reference in new issue
Block a user