Commit 4af1ef77 by Finley Ge Committed by GitHub

Add local workflow draft cache and restore flow (#6923)

* Preserve workflow drafts through auth redirects

* perf: code

* fix: refine workflow local draft recovery

* fix: refine workflow node drag labels

* fix: refine workflow local draft account matching

* add tmbId local storage

* fix: refine workflow draft login restore

* chore: refresh workflow draft restore pr

* fix: await login success restore flow

* docs: clarify workflow draft restore flow

* fix: avoid PR diff API in change detection

* doc

* revert: remove workflow change detection tweak

* fix: keep workflow leave confirmation

* fix: show workflow draft auth expired notice

* fix: handle workflow auth expired draft restore

* fix: avoid stale workflow draft redirect

* perf code

* toast

* perf: lastTmbId

---------

Co-authored-by: archer <545436317@qq.com>
parent 9a500dae
...@@ -3,12 +3,9 @@ title: 'V4.15.0-beta3(进行中)' ...@@ -3,12 +3,9 @@ title: 'V4.15.0-beta3(进行中)'
description: 'FastGPT V4.15.0-beta3 更新说明' description: 'FastGPT V4.15.0-beta3 更新说明'
--- ---
## 🚀 新增内容 ## 升级指南
1. 多模态模型支持音视频输入。 ### 🔧 环境变量变更
2. 分享链接/门户页,支持语言切换,不再强制自动识别浏览器语言切换。
## 🔧 环境变量变更
Code Sandbox 新增 `SANDBOX_API_MAX_BODY_MB`、`SANDBOX_MAX_OUTPUT_MB` 等安全相关环境变量;完整默认值如下: Code Sandbox 新增 `SANDBOX_API_MAX_BODY_MB`、`SANDBOX_MAX_OUTPUT_MB` 等安全相关环境变量;完整默认值如下:
...@@ -25,15 +22,22 @@ Code Sandbox 新增 `SANDBOX_API_MAX_BODY_MB`、`SANDBOX_MAX_OUTPUT_MB` 等安 ...@@ -25,15 +22,22 @@ Code Sandbox 新增 `SANDBOX_API_MAX_BODY_MB`、`SANDBOX_MAX_OUTPUT_MB` 等安
| `SANDBOX_REQUEST_MAX_RESPONSE_MB` | `10` | 沙箱内单次网络响应体最大大小,单位 MB。 | | `SANDBOX_REQUEST_MAX_RESPONSE_MB` | `10` | 沙箱内单次网络响应体最大大小,单位 MB。 |
| `SANDBOX_REQUEST_MAX_BODY_MB` | `5` | 沙箱内单次网络请求体最大大小,单位 MB。 | | `SANDBOX_REQUEST_MAX_BODY_MB` | `5` | 沙箱内单次网络请求体最大大小,单位 MB。 |
## 🚀 新增内容
1. 多模态模型支持音视频输入。
2. 分享链接/门户页,支持语言切换,不再强制自动识别浏览器语言切换。
## ⚙️ 优化 ## ⚙️ 优化
1. Skill 模块相关弹窗样式。 1. Skill 模块相关弹窗样式。
2. Skill list 接口性能。 2. Skill list 接口性能。
3. 工作流节点名称和介绍输入。 3. 工作流节点名称和介绍输入。
4. 工作流编辑页,因登录失效,跳出后自动保存草稿用于恢复。
## 🐛 修复 ## 🐛 修复
1. TTS 语音播放适配最新 OpenAI SDK,避免报错。 1. TTS 语音播放适配最新 OpenAI SDK,避免报错。
2. 知识库数据分块,遇到代码块时,可能出现超大分块。
## 🛠️ 代码优化 ## 🛠️ 代码优化
......
...@@ -157,8 +157,8 @@ ...@@ -157,8 +157,8 @@
"content/openapi/intro.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/intro.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/share.en.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/share.en.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/share.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/share.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/config/env.en.mdx": "2026-05-23T22:47:02+08:00", "content/self-host/config/env.en.mdx": "2026-05-27T12:17:46+08:00",
"content/self-host/config/env.mdx": "2026-05-23T22:47:02+08:00", "content/self-host/config/env.mdx": "2026-05-27T12:17:46+08:00",
"content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/config/json.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/config/json.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/config/model/intro.en.mdx": "2026-05-07T15:06:40+08:00",
...@@ -280,8 +280,8 @@ ...@@ -280,8 +280,8 @@
"content/self-host/upgrading/4-15/4150.mdx": "2026-05-20T17:52:26+08:00", "content/self-host/upgrading/4-15/4150.mdx": "2026-05-20T17:52:26+08:00",
"content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-27T12:17:46+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-26T20:01:48+08:00", "content/self-host/upgrading/4-15/41503.mdx": "2026-05-27T14:50:36+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
...@@ -273,5 +273,8 @@ ...@@ -273,5 +273,8 @@
"workflow.My edit": "My Edit", "workflow.My edit": "My Edit",
"workflow.Switch_success": "Switch Successful", "workflow.Switch_success": "Switch Successful",
"workflow.Team cloud": "Team Cloud", "workflow.Team cloud": "Team Cloud",
"workflow_local_draft_auth_expired_notice": "Your login has expired. The current edits have been automatically saved locally. Do not close this page. After signing in again in this tab, your workflow edits can be restored.",
"workflow_local_draft_auth_expired_title": "Automatically saved",
"workflow_local_draft_relogin": "Sign in again",
"workflow.exit_tips": "Your changes have not been saved. 'Exit directly' will not save your edits." "workflow.exit_tips": "Your changes have not been saved. 'Exit directly' will not save your edits."
} }
...@@ -273,5 +273,8 @@ ...@@ -273,5 +273,8 @@
"workflow.My edit": "我的编辑", "workflow.My edit": "我的编辑",
"workflow.Switch_success": "切换成功", "workflow.Switch_success": "切换成功",
"workflow.Team cloud": "团队云端", "workflow.Team cloud": "团队云端",
"workflow_local_draft_auth_expired_notice": "登录已过期,当前编辑内容已自动保存至本地。请不要关闭页面,在此标签页重新登录后,即可恢复编排内容。",
"workflow_local_draft_auth_expired_title": "已自动保存",
"workflow_local_draft_relogin": "重新登录",
"workflow.exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。" "workflow.exit_tips": "您的更改尚未保存,「直接退出」将不会保存您的编辑记录。"
} }
...@@ -273,5 +273,8 @@ ...@@ -273,5 +273,8 @@
"workflow.My edit": "我的編輯", "workflow.My edit": "我的編輯",
"workflow.Switch_success": "切換成功", "workflow.Switch_success": "切換成功",
"workflow.Team cloud": "團隊雲端", "workflow.Team cloud": "團隊雲端",
"workflow_local_draft_auth_expired_notice": "登入已過期,當前編輯內容已自動儲存至本機。請不要關閉頁面,在此分頁重新登入後,即可恢復編排內容。",
"workflow_local_draft_auth_expired_title": "已自動儲存",
"workflow_local_draft_relogin": "重新登入",
"workflow.exit_tips": "您的變更尚未儲存,「直接結束」將不會儲存您的編輯紀錄。" "workflow.exit_tips": "您的變更尚未儲存,「直接結束」將不會儲存您的編輯紀錄。"
} }
Subproject commit 2b5e6b3501cbbef8f435a02f4f0a4ef4f49b4dcb Subproject commit b98839ddf585c4b91e6cd823994f89efc02c901b
...@@ -47,8 +47,8 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => { ...@@ -47,8 +47,8 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => {
const renderType = renderTypeList?.[selectedTypeIndex || 0]; const renderType = renderTypeList?.[selectedTypeIndex || 0];
return ( return (
<Flex className="nodrag" cursor={'default'} alignItems={'center'} position={'relative'}> <Box display={'flex'} alignItems={'center'} position={'relative'}>
<Flex alignItems={'center'} position={'relative'} fontWeight={'medium'}> <Flex className="nodrag" alignItems={'center'} position={'relative'} fontWeight={'medium'}>
<FormLabel required={required} color={'myGray.600'}> <FormLabel required={required} color={'myGray.600'}>
{t(label as any)} {t(label as any)}
</FormLabel> </FormLabel>
...@@ -56,12 +56,12 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => { ...@@ -56,12 +56,12 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => {
</Flex> </Flex>
{/* value type */} {/* value type */}
{[FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.fileSelect].includes(renderType) && ( {[FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.fileSelect].includes(renderType) && (
<ValueTypeLabel valueType={valueType} valueDesc={valueDesc} /> <ValueTypeLabel className="nodrag" valueType={valueType} valueDesc={valueDesc} />
)} )}
{/* input type select */} {/* input type select */}
{renderTypeList && renderTypeList.length > 1 && ( {renderTypeList && renderTypeList.length > 1 && (
<Box ml={2}> <Box ml={2} className="nodrag">
<NodeInputSelect <NodeInputSelect
renderTypeList={renderTypeList} renderTypeList={renderTypeList}
renderTypeIndex={selectedTypeIndex} renderTypeIndex={selectedTypeIndex}
...@@ -75,6 +75,7 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => { ...@@ -75,6 +75,7 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => {
<Box flex={'1'} /> <Box flex={'1'} />
<MyTooltip label={t('app:Click_to_delete_this_field')}> <MyTooltip label={t('app:Click_to_delete_this_field')}>
<Flex <Flex
className="nodrag"
px={1.5} px={1.5}
py={1} py={1}
bg={'adora.50'} bg={'adora.50'}
...@@ -107,7 +108,7 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => { ...@@ -107,7 +108,7 @@ const InputLabel = ({ nodeId, input, RightComponent }: Props) => {
{RightComponent} {RightComponent}
</> </>
)} )}
</Flex> </Box>
); );
}; };
......
...@@ -22,8 +22,6 @@ const OutputLabel = ({ nodeId, output }: { nodeId: string; output: FlowNodeOutpu ...@@ -22,8 +22,6 @@ const OutputLabel = ({ nodeId, output }: { nodeId: string; output: FlowNodeOutpu
return ( return (
<Box position={'relative'}> <Box position={'relative'}>
<Flex <Flex
className="nodrag"
cursor={'default'}
alignItems={'center'} alignItems={'center'}
fontWeight={'medium'} fontWeight={'medium'}
color={'myGray.600'} color={'myGray.600'}
...@@ -34,20 +32,22 @@ const OutputLabel = ({ nodeId, output }: { nodeId: string; output: FlowNodeOutpu ...@@ -34,20 +32,22 @@ const OutputLabel = ({ nodeId, output }: { nodeId: string; output: FlowNodeOutpu
: {})} : {})}
> >
<Box <Box
className="nodrag"
position={'relative'} position={'relative'}
mr={1} mr={1}
ml={output.type === FlowNodeOutputTypeEnum.source ? 1 : 0} ml={output.type === FlowNodeOutputTypeEnum.source ? 1 : 0}
> >
{t(label as any)} {t(label as any)}
</Box> </Box>
{description && <QuestionTip ml={1} label={t(description as any)} />} {description && <QuestionTip className="nodrag" ml={1} label={t(description as any)} />}
<ValueTypeLabel valueType={valueType} valueDesc={valueDesc} /> <ValueTypeLabel className="nodrag" valueType={valueType} valueDesc={valueDesc} />
{output.deprecated && ( {output.deprecated && (
<> <>
<Box flex={'1'} /> <Box flex={'1'} />
<MyTooltip label={t('app:Click_to_delete_this_field')}> <MyTooltip label={t('app:Click_to_delete_this_field')}>
<Flex <Flex
className="nodrag"
px={1.5} px={1.5}
py={1} py={1}
bg={'adora.50'} bg={'adora.50'}
......
...@@ -13,16 +13,23 @@ import React, { ...@@ -13,16 +13,23 @@ import React, {
useRef, useRef,
useState useState
} from 'react'; } from 'react';
import { useTranslation } from 'next-i18next';
import { createContext, useContextSelector } from 'use-context-selector'; import { createContext, useContextSelector } from 'use-context-selector';
import { useDebounceEffect, useMemoizedFn, useUnmount } from 'ahooks'; import { useDebounceEffect, useUnmount } from 'ahooks';
import { WorkflowBufferDataContext, WorkflowInitContext } from './workflowInitContext'; import { WorkflowBufferDataContext, WorkflowInitContext } from './workflowInitContext';
import { compareSnapshot } from '@/web/core/workflow/utils'; import { compareSnapshot } from '@/web/core/workflow/utils';
import { AppContext } from '@/pageComponents/app/detail/context'; import { AppContext } from '@/pageComponents/app/detail/context';
import { WorkflowSnapshotContext } from './workflowSnapshotContext'; import { WorkflowSnapshotContext } from './workflowSnapshotContext';
import { WorkflowUtilsContext } from './workflowUtilsContext'; import { WorkflowUtilsContext } from './workflowUtilsContext';
import { isProduction } from '@fastgpt/global/common/system/constants'; import {
import { useTranslation } from 'next-i18next'; removeWorkflowLocalDraftByApp,
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload'; saveWorkflowLocalDraft
} from '@/web/core/workflow/localDraft/storage';
import { useWorkflowAuthExpiredDraft } from '@/web/core/workflow/localDraft/useWorkflowAuthExpiredDraft';
import { postPublishApp } from '@/web/core/app/api/version';
import { useUserStore } from '@/web/support/user/useUserStore';
const enableWorkflowLeaveConfirm = process.env.NEXT_PUBLIC_WORKFLOW_LEAVE_CONFIRM !== 'false';
// 创建 Context // 创建 Context
type WorkflowPersistenceContextValue = { type WorkflowPersistenceContextValue = {
...@@ -46,12 +53,47 @@ export const WorkflowPersistenceProvider: React.FC<PropsWithChildren> = ({ child ...@@ -46,12 +53,47 @@ export const WorkflowPersistenceProvider: React.FC<PropsWithChildren> = ({ child
const appDetail = useContextSelector(AppContext, (v) => v.appDetail); const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes); const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
const edges = useContextSelector(WorkflowBufferDataContext, (v) => v.edges); const edges = useContextSelector(WorkflowBufferDataContext, (v) => v.edges);
const { userInfo } = useUserStore();
const { past, future } = useContextSelector(WorkflowSnapshotContext, (v) => v); const { past, future } = useContextSelector(WorkflowSnapshotContext, (v) => v);
const loginTmbId = userInfo?.team?.tmbId;
// 保存状态 // 保存状态
const [isSaved, setIsSaved] = useState(true); const [isSaved, setIsSaved] = useState(true);
// 离开保存标志 // 离开保存标志
const leaveSaveSign = useRef(true); const leaveSaveSign = useRef(true);
const flowData2StoreData = useContextSelector(WorkflowUtilsContext, (v) => v.flowData2StoreData);
const leavePageTip = t('common:core.tip.leave page');
const saveLocalDraft = useCallback(() => {
const data = flowData2StoreData();
if (!data || !loginTmbId) return false;
return saveWorkflowLocalDraft({
appId: appDetail._id,
// 团队切换会立即改写全站共享 cookie/session;草稿恢复必须和保存草稿时的 tmbId 对齐。
tmbId: loginTmbId,
data: {
...data,
chatConfig: appDetail.chatConfig
}
});
}, [appDetail._id, appDetail.chatConfig, flowData2StoreData, loginTmbId]);
const removeCurrentLocalDraft = useCallback(() => {
removeWorkflowLocalDraftByApp({
appId: appDetail._id
});
}, [appDetail._id]);
const {
authExpiredModal,
handleBeforeUnloadAuthExpired,
setBeforeUnloadAutoSaving,
shouldSkipUnmountAutoSave
} = useWorkflowAuthExpiredDraft({
leaveSaveSignRef: leaveSaveSign,
saveLocalDraft
});
/** /**
* 计算 isSaved 状态 - 防抖 500ms * 计算 isSaved 状态 - 防抖 500ms
...@@ -76,8 +118,12 @@ export const WorkflowPersistenceProvider: React.FC<PropsWithChildren> = ({ child ...@@ -76,8 +118,12 @@ export const WorkflowPersistenceProvider: React.FC<PropsWithChildren> = ({ child
} }
); );
setIsSaved(val); setIsSaved(val);
if (val) {
removeCurrentLocalDraft();
}
}, },
[future, past, nodes, edges, appDetail.chatConfig], [future, past, nodes, edges, appDetail.chatConfig, removeCurrentLocalDraft],
{ {
wait: 500 wait: 500
} }
...@@ -89,29 +135,88 @@ export const WorkflowPersistenceProvider: React.FC<PropsWithChildren> = ({ child ...@@ -89,29 +135,88 @@ export const WorkflowPersistenceProvider: React.FC<PropsWithChildren> = ({ child
* 1. 手动调用 * 1. 手动调用
* 2. 离开页面前 * 2. 离开页面前
*/ */
const flowData2StoreData = useContextSelector(WorkflowUtilsContext, (v) => v.flowData2StoreData); const autoSaveFn = useCallback(
const onSaveApp = useContextSelector(AppContext, (v) => v.onSaveApp); async ({ fromBeforeUnload = false } = {}) => {
const autoSaveFn = useCallback(async () => {
if (isSaved || !leaveSaveSign.current) return; if (isSaved || !leaveSaveSign.current) return;
console.log('Leave auto save'); console.log('Leave auto save');
const data = flowData2StoreData(); const data = flowData2StoreData();
if (!data || data.nodes.length === 0) return; if (!data || data.nodes.length === 0) return;
await onSaveApp({
if (fromBeforeUnload) {
setBeforeUnloadAutoSaving(true);
}
try {
if (!appDetail.permission.hasWritePer) {
return;
}
await postPublishApp(appDetail._id, {
...data, ...data,
isPublish: false, isPublish: false,
chatConfig: appDetail.chatConfig, chatConfig: appDetail.chatConfig,
autoSave: true autoSave: true
}); });
}, [appDetail.chatConfig, flowData2StoreData, isSaved, onSaveApp]); removeCurrentLocalDraft();
} catch (error) {
console.warn('[Workflow auto save] Failed to save workflow before leaving:', error);
} finally {
if (fromBeforeUnload) {
setBeforeUnloadAutoSaving(false);
}
}
},
[
appDetail._id,
appDetail.chatConfig,
appDetail.permission.hasWritePer,
flowData2StoreData,
isSaved,
removeCurrentLocalDraft,
setBeforeUnloadAutoSaving
]
);
// 普通刷新/关闭页面时先写本地草稿,再弹浏览器原生确认并尝试远端自动保存。
// 如果是鉴权失败触发的跳登录,弹窗只在用户取消浏览器原生确认、停留在当前页后显示。
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isSaved || !leaveSaveSign.current) return;
const { isAuthExpiredRedirecting } = handleBeforeUnloadAuthExpired();
if (!isAuthExpiredRedirecting && appDetail.permission.hasWritePer) {
saveLocalDraft();
}
if (!isAuthExpiredRedirecting) {
autoSaveFn({ fromBeforeUnload: true });
}
if (!enableWorkflowLeaveConfirm) return;
event.preventDefault();
event.returnValue = leavePageTip;
return leavePageTip;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [
appDetail.permission.hasWritePer,
autoSaveFn,
handleBeforeUnloadAuthExpired,
isSaved,
leavePageTip,
saveLocalDraft
]);
// 页面关闭前自动保存 // 页面关闭前自动保存
useUnmount(() => { useUnmount(() => {
autoSaveFn(); if (shouldSkipUnmountAutoSave()) return;
});
useBeforeunload({ autoSaveFn();
tip: t('common:core.tip.leave page'),
callback: autoSaveFn
}); });
const contextValue = useMemo(() => { const contextValue = useMemo(() => {
...@@ -125,6 +230,7 @@ export const WorkflowPersistenceProvider: React.FC<PropsWithChildren> = ({ child ...@@ -125,6 +230,7 @@ export const WorkflowPersistenceProvider: React.FC<PropsWithChildren> = ({ child
return ( return (
<WorkflowPersistenceContext.Provider value={contextValue}> <WorkflowPersistenceContext.Provider value={contextValue}>
{children} {children}
{authExpiredModal}
</WorkflowPersistenceContext.Provider> </WorkflowPersistenceContext.Provider>
); );
}; };
......
...@@ -181,6 +181,8 @@ const AppContextProvider = ({ children }: { children: ReactNode }) => { ...@@ -181,6 +181,8 @@ const AppContextProvider = ({ children }: { children: ReactNode }) => {
}, },
{ {
manual: true, manual: true,
// 保存入口通常会再包一层 useRequest 处理按钮 loading 和 toast,这里只做共享保存动作,避免失败时重复提示。
errorToast: '',
refreshDeps: [appDetail.permission.hasWritePer, appId] refreshDeps: [appDetail.permission.hasWritePer, appId]
} }
); );
......
...@@ -11,9 +11,11 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest'; ...@@ -11,9 +11,11 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { checkPasswordRule } from '@fastgpt/global/common/string/password'; import { checkPasswordRule } from '@fastgpt/global/common/string/password';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>;
interface Props { interface Props {
setPageType: Dispatch<`${LoginPageTypeEnum}`>; setPageType: Dispatch<`${LoginPageTypeEnum}`>;
loginSuccess: (e: LoginSuccessResponseType) => void; loginSuccess: LoginSuccessHandler;
} }
interface RegisterType { interface RegisterType {
...@@ -55,13 +57,12 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { ...@@ -55,13 +57,12 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
const { runAsync: onclickFindPassword, loading: requesting } = useRequest( const { runAsync: onclickFindPassword, loading: requesting } = useRequest(
async ({ username, code, password }: RegisterType) => { async ({ username, code, password }: RegisterType) => {
loginSuccess( const loginResponse = await postFindPassword({
await postFindPassword({
username, username,
code, code,
password password
}) });
); await loginSuccess(loginResponse);
toast({ toast({
status: 'success', status: 'success',
title: t('user:password.retrieved') title: t('user:password.retrieved')
......
import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import { AbsoluteCenter, Box, Flex, Grid, IconButton, GridItem, Button } from '@chakra-ui/react'; import { Box, Flex, IconButton, Button } from '@chakra-ui/react';
import { LOGO_ICON } from '@fastgpt/global/common/system/constants'; import { LOGO_ICON } from '@fastgpt/global/common/system/constants';
import { OAuthEnum } from '@fastgpt/global/support/user/constant'; import { OAuthEnum } from '@fastgpt/global/support/user/constant';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { type Dispatch, useCallback, useEffect, useMemo, useRef } from 'react'; import { type Dispatch, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import I18nLngSelector from '@/components/Select/I18nLngSelector'; import I18nLngSelector from '@/components/Select/I18nLngSelector';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useSystem } from '@fastgpt/web/hooks/useSystem';
...@@ -38,12 +38,15 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ...@@ -38,12 +38,15 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
const { setLoginStore, feConfigs } = useSystemStore(); const { setLoginStore, feConfigs } = useSystemStore();
const { isPc } = useSystem(); const { isPc } = useSystem();
const { lastRoute = '/dashboard/agent' } = router.query as { lastRoute: string }; const { lastRoute = '/dashboard/agent', lastTmbId = '' } = router.query as {
lastRoute: string;
lastTmbId?: string;
};
const computedLastRoute = useMemo(() => { const computedLastRoute = useMemo(() => {
return router.pathname === '/chat' ? router.asPath : lastRoute; return router.pathname === '/chat' ? router.asPath : lastRoute;
}, [lastRoute, router.pathname, router.asPath]); }, [lastRoute, router.pathname, router.asPath]);
const state = useRef(getNanoid(8)); const [oauthState] = useState(() => getNanoid(8));
const redirectUri = `${location.origin}/login/provider`; const redirectUri = `${location.origin}/login/provider`;
const isWecomWorkTerminal = checkIsWecomTerminal(); const isWecomWorkTerminal = checkIsWecomTerminal();
...@@ -85,7 +88,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ...@@ -85,7 +88,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
label: t('common:support.user.login.Google'), label: t('common:support.user.login.Google'),
provider: OAuthEnum.google, provider: OAuthEnum.google,
icon: 'common/googleFill', icon: 'common/googleFill',
redirectUrl: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${feConfigs?.oauth?.google}&redirect_uri=${redirectUri}&state=${state.current}&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email%20openid&include_granted_scopes=true` redirectUrl: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${feConfigs?.oauth?.google}&redirect_uri=${redirectUri}&state=${oauthState}&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email%20openid&include_granted_scopes=true`
} }
] ]
: []), : []),
...@@ -95,7 +98,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ...@@ -95,7 +98,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
label: t('common:support.user.login.Github'), label: t('common:support.user.login.Github'),
provider: OAuthEnum.github, provider: OAuthEnum.github,
icon: 'common/gitFill', icon: 'common/gitFill',
redirectUrl: `https://github.com/login/oauth/authorize?client_id=${feConfigs?.oauth?.github}&redirect_uri=${redirectUri}&state=${state.current}&scope=user:email%20read:user` redirectUrl: `https://github.com/login/oauth/authorize?client_id=${feConfigs?.oauth?.github}&redirect_uri=${redirectUri}&state=${oauthState}&scope=user:email%20read:user`
} }
] ]
: []), : []),
...@@ -107,18 +110,15 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ...@@ -107,18 +110,15 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
t('common:support.user.login.Microsoft'), t('common:support.user.login.Microsoft'),
provider: OAuthEnum.microsoft, provider: OAuthEnum.microsoft,
icon: 'common/microsoft', icon: 'common/microsoft',
redirectUrl: `https://login.microsoftonline.com/${feConfigs?.oauth?.microsoft?.tenantId || 'common'}/oauth2/v2.0/authorize?client_id=${feConfigs?.oauth?.microsoft?.clientId}&response_type=code&redirect_uri=${redirectUri}&response_mode=query&scope=https%3A%2F%2Fgraph.microsoft.com%2Fuser.read&state=${state.current}` redirectUrl: `https://login.microsoftonline.com/${feConfigs?.oauth?.microsoft?.tenantId || 'common'}/oauth2/v2.0/authorize?client_id=${feConfigs?.oauth?.microsoft?.clientId}&response_type=code&redirect_uri=${redirectUri}&response_mode=query&scope=https%3A%2F%2Fgraph.microsoft.com%2Fuser.read&state=${oauthState}`
} }
] ]
: []) : [])
], ],
[feConfigs, pageType, redirectUri, t] [feConfigs, oauthState, pageType, redirectUri, t]
); );
const show_oauth = useMemo( const show_oauth = !!(feConfigs?.sso?.url || oAuthList.length > 0);
() => !!(feConfigs?.sso?.url || oAuthList.length > 0),
[feConfigs?.sso?.url, oAuthList.length]
);
const onClickOauth = useCallback( const onClickOauth = useCallback(
async (item: OAuthItem) => { async (item: OAuthItem) => {
...@@ -130,7 +130,8 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ...@@ -130,7 +130,8 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
setLoginStore({ setLoginStore({
provider: item.provider as OAuthEnum, provider: item.provider as OAuthEnum,
lastRoute: computedLastRoute, lastRoute: computedLastRoute,
state: state.current lastTmbId,
state: oauthState
}); });
router.replace(redirectUrl, '_self'); router.replace(redirectUrl, '_self');
return; return;
...@@ -142,13 +143,14 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ...@@ -142,13 +143,14 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
{ {
redirectUri, redirectUri,
isWecomWorkTerminal, isWecomWorkTerminal,
state: state.current state: oauthState
} }
); );
setLoginStore({ setLoginStore({
provider: item.provider as OAuthEnum, provider: item.provider as OAuthEnum,
lastRoute: computedLastRoute, lastRoute: computedLastRoute,
state: state.current lastTmbId,
state: oauthState
}); });
router.replace(redirectUrl, '_self'); router.replace(redirectUrl, '_self');
return; return;
...@@ -158,13 +160,23 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ...@@ -158,13 +160,23 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
setLoginStore({ setLoginStore({
provider: item.provider as OAuthEnum, provider: item.provider as OAuthEnum,
lastRoute: computedLastRoute, lastRoute: computedLastRoute,
state: state.current lastTmbId,
state: oauthState
}); });
router.replace(item.redirectUrl, '_self'); router.replace(item.redirectUrl, '_self');
} }
item.pageType && setPageType(item.pageType); item.pageType && setPageType(item.pageType);
}, },
[computedLastRoute, isWecomWorkTerminal, redirectUri, router, setLoginStore, setPageType] [
computedLastRoute,
isWecomWorkTerminal,
lastTmbId,
oauthState,
redirectUri,
router,
setLoginStore,
setPageType
]
); );
// Auto login // Auto login
......
import React, { useEffect, type Dispatch } from 'react'; import React, { type Dispatch } from 'react';
import { FormControl, Flex, Input, Button, Box } from '@chakra-ui/react'; import { FormControl, Flex, Input, Button, Box } from '@chakra-ui/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants';
import { postLogin, getPreLogin } from '@/web/support/user/api'; import { postLogin, getPreLogin } from '@/web/support/user/api';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import FormLayout from './FormLayout'; import FormLayout from './FormLayout';
...@@ -16,9 +15,11 @@ import { useMount } from 'ahooks'; ...@@ -16,9 +15,11 @@ import { useMount } from 'ahooks';
import type { LangEnum } from '@fastgpt/global/common/i18n/type'; import type { LangEnum } from '@fastgpt/global/common/i18n/type';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>;
interface Props { interface Props {
setPageType: Dispatch<`${LoginPageTypeEnum}`>; setPageType: Dispatch<`${LoginPageTypeEnum}`>;
loginSuccess: (e: LoginSuccessResponseType) => void; loginSuccess: LoginSuccessHandler;
} }
interface LoginFormType { interface LoginFormType {
...@@ -41,14 +42,13 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => { ...@@ -41,14 +42,13 @@ const LoginForm = ({ setPageType, loginSuccess }: Props) => {
const { runAsync: onclickLogin, loading: requesting } = useRequest( const { runAsync: onclickLogin, loading: requesting } = useRequest(
async ({ username, password }: LoginFormType) => { async ({ username, password }: LoginFormType) => {
const { code } = await getPreLogin(username); const { code } = await getPreLogin(username);
loginSuccess( const loginResponse = await postLogin({
await postLogin({
username, username,
password, password,
code, code,
language: i18n.language as LangEnum language: i18n.language as LangEnum
}) });
); await loginSuccess(loginResponse);
}, },
{ {
refreshDeps: [loginSuccess], refreshDeps: [loginSuccess],
......
...@@ -17,19 +17,19 @@ import { ...@@ -17,19 +17,19 @@ import {
removeFastGPTSem, removeFastGPTSem,
getInviterId getInviterId
} from '@/web/support/marketing/utils'; } from '@/web/support/marketing/utils';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import PolicyTip from './PolicyTip'; import PolicyTip from './PolicyTip';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>;
interface Props { interface Props {
loginSuccess: (e: LoginSuccessResponseType) => void; loginSuccess: LoginSuccessHandler;
setPageType: Dispatch<`${LoginPageTypeEnum}`>; setPageType: Dispatch<`${LoginPageTypeEnum}`>;
} }
const WechatForm = ({ setPageType, loginSuccess }: Props) => { const WechatForm = ({ setPageType, loginSuccess }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const { feConfigs } = useSystemStore();
const { data: wechatInfo } = useQuery(['getWXLoginQR'], getWXLoginQR, { const { data: wechatInfo } = useQuery(['getWXLoginQR'], getWXLoginQR, {
onError(err) { onError(err) {
...@@ -54,10 +54,10 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => { ...@@ -54,10 +54,10 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => {
{ {
refetchInterval: 3 * 1000, refetchInterval: 3 * 1000,
enabled: !!wechatInfo?.code, enabled: !!wechatInfo?.code,
onSuccess(data: LoginSuccessResponseType | undefined) { async onSuccess(data: LoginSuccessResponseType | undefined) {
if (data) { if (data) {
removeFastGPTSem(); removeFastGPTSem();
loginSuccess(data); await loginSuccess(data);
} }
} }
} }
......
...@@ -19,8 +19,10 @@ import { ...@@ -19,8 +19,10 @@ import {
import { checkPasswordRule } from '@fastgpt/global/common/string/password'; import { checkPasswordRule } from '@fastgpt/global/common/string/password';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>;
interface Props { interface Props {
loginSuccess: (e: LoginSuccessResponseType) => void; loginSuccess: LoginSuccessHandler;
setPageType: Dispatch<`${LoginPageTypeEnum}`>; setPageType: Dispatch<`${LoginPageTypeEnum}`>;
} }
...@@ -51,8 +53,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { ...@@ -51,8 +53,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
const { runAsync: onclickRegister, loading: requesting } = useRequest( const { runAsync: onclickRegister, loading: requesting } = useRequest(
async ({ username, password, code }: RegisterType) => { async ({ username, password, code }: RegisterType) => {
loginSuccess( const loginResponse = await postRegister({
await postRegister({
username, username,
code, code,
password, password,
...@@ -61,8 +62,8 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { ...@@ -61,8 +62,8 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
msclkid: getMsclkid(), msclkid: getMsclkid(),
fastgpt_sem: getFastGPTSem(), fastgpt_sem: getFastGPTSem(),
sourceDomain: getSourceDomain() sourceDomain: getSourceDomain()
}) });
); await loginSuccess(loginResponse);
removeFastGPTSem(); removeFastGPTSem();
toast({ toast({
......
...@@ -6,6 +6,8 @@ import LoginForm from '@/pageComponents/login/LoginForm/LoginForm'; ...@@ -6,6 +6,8 @@ import LoginForm from '@/pageComponents/login/LoginForm/LoginForm';
import { type Dispatch, useMemo } from 'react'; import { type Dispatch, useMemo } from 'react';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>;
const RegisterForm = dynamic(() => import('@/pageComponents/login/RegisterForm')); const RegisterForm = dynamic(() => import('@/pageComponents/login/RegisterForm'));
const ForgetPasswordForm = dynamic(() => import('@/pageComponents/login/ForgetPasswordForm')); const ForgetPasswordForm = dynamic(() => import('@/pageComponents/login/ForgetPasswordForm'));
const WechatForm = dynamic(() => import('@/pageComponents/login/LoginForm/WechatForm')); const WechatForm = dynamic(() => import('@/pageComponents/login/LoginForm/WechatForm'));
...@@ -13,7 +15,7 @@ const WechatForm = dynamic(() => import('@/pageComponents/login/LoginForm/Wechat ...@@ -13,7 +15,7 @@ const WechatForm = dynamic(() => import('@/pageComponents/login/LoginForm/Wechat
type LoginFormPanelProps = { type LoginFormPanelProps = {
pageType: `${LoginPageTypeEnum}`; pageType: `${LoginPageTypeEnum}`;
setPageType: Dispatch<`${LoginPageTypeEnum}`>; setPageType: Dispatch<`${LoginPageTypeEnum}`>;
loginSuccess: (res: LoginSuccessResponseType) => void; loginSuccess: LoginSuccessHandler;
}; };
const LoginFormPanel = ({ pageType, setPageType, loginSuccess }: LoginFormPanelProps) => { const LoginFormPanel = ({ pageType, setPageType, loginSuccess }: LoginFormPanelProps) => {
......
...@@ -11,6 +11,8 @@ import CookieConsentModal from './components/CookieConsentModal'; ...@@ -11,6 +11,8 @@ import CookieConsentModal from './components/CookieConsentModal';
import LoginFormPanel from './components/LoginFormPanel'; import LoginFormPanel from './components/LoginFormPanel';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise<void>;
const CommunityModal = dynamic(() => import('@/components/CommunityModal')); const CommunityModal = dynamic(() => import('@/components/CommunityModal'));
// login container component // login container component
...@@ -19,7 +21,7 @@ export const LoginContainer = ({ ...@@ -19,7 +21,7 @@ export const LoginContainer = ({
onSuccess onSuccess
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
onSuccess: (res: LoginSuccessResponseType) => void; onSuccess: LoginSuccessHandler;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { feConfigs } = useSystemStore(); const { feConfigs } = useSystemStore();
...@@ -30,8 +32,8 @@ export const LoginContainer = ({ ...@@ -30,8 +32,8 @@ export const LoginContainer = ({
// login success handler // login success handler
const loginSuccess = useCallback( const loginSuccess = useCallback(
(res: LoginSuccessResponseType) => { async (res: LoginSuccessResponseType) => {
onSuccess?.(res); await onSuccess?.(res);
}, },
[onSuccess] [onSuccess]
); );
......
...@@ -10,29 +10,42 @@ import { getErrText } from '@fastgpt/global/common/error/utils'; ...@@ -10,29 +10,42 @@ import { getErrText } from '@fastgpt/global/common/error/utils';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { validateRedirectUrl } from '@/web/common/utils/uri'; import { validateRedirectUrl } from '@/web/common/utils/uri';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import { useLoginRedirectAfterLogin } from '@/web/support/user/loginRedirect';
const FastLogin = ({ const FastLogin = ({
code, code,
token, token,
callbackUrl callbackUrl,
lastTmbId
}: { }: {
code: string; code: string;
token: string; token: string;
callbackUrl: string; callbackUrl: string;
lastTmbId?: string;
}) => { }) => {
const { setUserInfo } = useUserStore(); const { setUserInfo } = useUserStore();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
const resolveLoginRedirect = useLoginRedirectAfterLogin();
const loginSuccess = useCallback( const loginSuccess = useCallback(
(res: LoginSuccessResponseType) => { async (res: LoginSuccessResponseType) => {
const safeCallbackUrl = validateRedirectUrl(callbackUrl);
const targetRoute = await resolveLoginRedirect({
user: res.user,
fallbackRoute: safeCallbackUrl,
lastTmbId
});
setUserInfo(res.user); setUserInfo(res.user);
if (targetRoute) {
setTimeout(() => { setTimeout(() => {
router.push(validateRedirectUrl(callbackUrl)); router.push(targetRoute);
}, 100); }, 100);
}
}, },
[setUserInfo, router, callbackUrl] [callbackUrl, lastTmbId, resolveLoginRedirect, router, setUserInfo]
); );
const authCode = useCallback( const authCode = useCallback(
...@@ -51,7 +64,7 @@ const FastLogin = ({ ...@@ -51,7 +64,7 @@ const FastLogin = ({
router.replace('/login'); router.replace('/login');
}, 1000); }, 1000);
} }
loginSuccess(res); await loginSuccess(res);
} catch (error) { } catch (error) {
toast({ toast({
status: 'warning', status: 'warning',
...@@ -81,7 +94,8 @@ export async function getServerSideProps(content: any) { ...@@ -81,7 +94,8 @@ export async function getServerSideProps(content: any) {
code: content?.query?.code || '', code: content?.query?.code || '',
token: content?.query?.token || '', token: content?.query?.token || '',
callbackUrl: content?.query?.callbackUrl || '/dashboard/agent', callbackUrl: content?.query?.callbackUrl || '/dashboard/agent',
...(await serviceSideProps(content)) lastTmbId: content?.query?.lastTmbId || '',
...(await serviceSideProps(content, ['login']))
} }
}; };
} }
......
...@@ -11,18 +11,21 @@ import { useUserStore } from '@/web/support/user/useUserStore'; ...@@ -11,18 +11,21 @@ import { useUserStore } from '@/web/support/user/useUserStore';
import { subRoute } from '@fastgpt/web/common/system/utils'; import { subRoute } from '@fastgpt/web/common/system/utils';
import { validateRedirectUrl } from '@/web/common/utils/uri'; import { validateRedirectUrl } from '@/web/common/utils/uri';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import { useLoginRedirectAfterLogin } from '@/web/support/user/loginRedirect';
const Login = () => { const Login = () => {
const router = useRouter(); const router = useRouter();
const { lastRoute = '' } = router.query as { lastRoute: string }; const { lastRoute = '', lastTmbId = '' } = router.query as {
lastRoute: string;
lastTmbId?: string;
};
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const { setUserInfo } = useUserStore(); const { setUserInfo } = useUserStore();
const resolveLoginRedirect = useLoginRedirectAfterLogin();
const loginSuccess = useCallback( const loginSuccess = useCallback(
async (res: LoginSuccessResponseType) => { async (res: LoginSuccessResponseType) => {
setUserInfo(res.user);
const decodeLastRoute = validateRedirectUrl(lastRoute); const decodeLastRoute = validateRedirectUrl(lastRoute);
const navigateTo = await (async () => { const navigateTo = await (async () => {
...@@ -45,9 +48,21 @@ const Login = () => { ...@@ -45,9 +48,21 @@ const Login = () => {
return decodeLastRoute; return decodeLastRoute;
})(); })();
navigateTo && router.replace(navigateTo); const targetRoute = navigateTo
? await resolveLoginRedirect({
user: res.user,
fallbackRoute: navigateTo,
lastTmbId
})
: undefined;
setUserInfo(res.user);
if (targetRoute) {
router.replace(targetRoute);
}
}, },
[lastRoute, router, setUserInfo, t, toast] [lastRoute, lastTmbId, resolveLoginRedirect, router, setUserInfo, t, toast]
); );
useMount(() => { useMount(() => {
......
...@@ -23,6 +23,7 @@ import { retryFn } from '@fastgpt/global/common/system/utils'; ...@@ -23,6 +23,7 @@ import { retryFn } from '@fastgpt/global/common/system/utils';
import type { LangEnum } from '@fastgpt/global/common/i18n/type'; import type { LangEnum } from '@fastgpt/global/common/i18n/type';
import { validateRedirectUrl } from '@/web/common/utils/uri'; import { validateRedirectUrl } from '@/web/common/utils/uri';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import { useLoginRedirectAfterLogin } from '@/web/support/user/loginRedirect';
let isOauthLogging = false; let isOauthLogging = false;
...@@ -33,16 +34,17 @@ const provider = () => { ...@@ -33,16 +34,17 @@ const provider = () => {
const router = useRouter(); const router = useRouter();
const { state, error, ...props } = router.query as Record<string, string>; const { state, error, ...props } = router.query as Record<string, string>;
const { toast } = useToast(); const { toast } = useToast();
const resolveLoginRedirect = useLoginRedirectAfterLogin();
const lastRoute = loginStore?.lastRoute const lastRoute = loginStore?.lastRoute
? validateRedirectUrl(loginStore.lastRoute) ? validateRedirectUrl(loginStore.lastRoute)
: '/dashboard/agent'; : '/dashboard/agent';
const lastTmbId = loginStore?.lastTmbId || '';
const errorRedirectPage = lastRoute.startsWith('/chat') ? lastRoute : '/login'; const errorRedirectPage = lastRoute.startsWith('/chat') ? lastRoute : '/login';
const loginSuccess = useCallback( const loginSuccess = useCallback(
async (res: LoginSuccessResponseType) => { async (res: LoginSuccessResponseType) => {
const decodeLastRoute = validateRedirectUrl(lastRoute); const decodeLastRoute = validateRedirectUrl(lastRoute);
setUserInfo(res.user);
const navigateTo = await (async () => { const navigateTo = await (async () => {
if (res.user.team.status !== 'active') { if (res.user.team.status !== 'active') {
...@@ -61,9 +63,21 @@ const provider = () => { ...@@ -61,9 +63,21 @@ const provider = () => {
return decodeLastRoute; return decodeLastRoute;
})(); })();
navigateTo && router.replace(navigateTo); const targetRoute = navigateTo
? await resolveLoginRedirect({
user: res.user,
fallbackRoute: navigateTo,
lastTmbId
})
: undefined;
setUserInfo(res.user);
if (targetRoute) {
router.replace(targetRoute);
}
}, },
[setUserInfo, router, lastRoute, t, toast] [lastRoute, lastTmbId, resolveLoginRedirect, router, setUserInfo, t, toast]
); );
const authProps = useCallback( const authProps = useCallback(
...@@ -92,7 +106,7 @@ const provider = () => { ...@@ -92,7 +106,7 @@ const provider = () => {
} }
removeFastGPTSem(); removeFastGPTSem();
loginSuccess(res); await loginSuccess(res);
} catch (error) { } catch (error) {
toast({ toast({
status: 'warning', status: 'warning',
...@@ -159,7 +173,7 @@ export default provider; ...@@ -159,7 +173,7 @@ export default provider;
export async function getServerSideProps(context: any) { export async function getServerSideProps(context: any) {
return { return {
props: { props: {
...(await serviceSideProps(context)) ...(await serviceSideProps(context, ['login']))
} }
}; };
} }
...@@ -6,7 +6,7 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils'; ...@@ -6,7 +6,7 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { useSystemStore } from '../system/useSystemStore'; import { useSystemStore } from '../system/useSystemStore';
import { clearToken } from '@/web/support/user/auth'; import { clearToken } from '@/web/support/user/auth';
import { safeEncodeURIComponent } from '@/web/common/utils/uri'; import { getAuthLoginRedirectPath } from '@/web/support/user/loginRedirect/url';
const responseError = (err: any) => { const responseError = (err: any) => {
console.log('error->', '请求错误', err); console.log('error->', '请求错误', err);
...@@ -34,7 +34,9 @@ const responseError = (err: any) => { ...@@ -34,7 +34,9 @@ const responseError = (err: any) => {
clearToken(); clearToken();
window.location.replace( window.location.replace(
getWebReqUrl( getWebReqUrl(
`/login?lastRoute=${safeEncodeURIComponent(location.pathname + location.search)}` getAuthLoginRedirectPath({
lastRoute: location.pathname + location.search
})
) )
); );
} }
......
...@@ -12,7 +12,7 @@ import { getWebReqUrl, subRoute } from '@fastgpt/web/common/system/utils'; ...@@ -12,7 +12,7 @@ import { getWebReqUrl, subRoute } from '@fastgpt/web/common/system/utils';
import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { safeEncodeURIComponent } from '@/web/common/utils/uri'; import { getAuthLoginRedirectPath } from '@/web/support/user/loginRedirect/url';
interface ConfigType { interface ConfigType {
headers?: { [key: string]: string }; headers?: { [key: string]: string };
...@@ -28,6 +28,13 @@ interface ResponseDataType { ...@@ -28,6 +28,13 @@ interface ResponseDataType {
data: any; data: any;
} }
export const AUTH_ERROR_EVENT_NAME = 'fastgpt:auth-error';
export type AuthErrorEventDetail = {
data: any;
skipClearToken?: boolean;
skipRedirect?: boolean;
};
const maxQuantityMap: Record< const maxQuantityMap: Record<
string, string,
| undefined | undefined
...@@ -132,11 +139,23 @@ function responseError(err: any) { ...@@ -132,11 +139,23 @@ function responseError(err: any) {
// Token error // Token error
if (data?.code in TOKEN_ERROR_CODE) { if (data?.code in TOKEN_ERROR_CODE) {
if (!isOutlinkPage && pathname !== `${subRoute}/chat`) { const authErrorEvent = new CustomEvent<AuthErrorEventDetail>(AUTH_ERROR_EVENT_NAME, {
detail: {
data
}
});
window.dispatchEvent?.(authErrorEvent);
if (!authErrorEvent.detail.skipRedirect && !isOutlinkPage && pathname !== `${subRoute}/chat`) {
if (!authErrorEvent.detail.skipClearToken) {
clearToken(); clearToken();
}
window.location.replace( window.location.replace(
getWebReqUrl( getWebReqUrl(
`/login?lastRoute=${safeEncodeURIComponent(location.pathname + location.search)}` getAuthLoginRedirectPath({
lastRoute: location.pathname + location.search
})
) )
); );
} }
......
...@@ -22,7 +22,7 @@ import { ...@@ -22,7 +22,7 @@ import {
} from '@fastgpt/global/core/ai/provider'; } from '@fastgpt/global/core/ai/provider';
import { getMyModels, getOperationalAd } from './api'; import { getMyModels, getOperationalAd } from './api';
type LoginStoreType = { provider: OAuthEnum; lastRoute: string; state: string }; type LoginStoreType = { provider: OAuthEnum; lastRoute: string; state: string; lastTmbId?: string };
export type NotSufficientModalType = export type NotSufficientModalType =
| TeamErrEnum.datasetSizeNotEnough | TeamErrEnum.datasetSizeNotEnough
......
import type { AppChatConfigType } from '@fastgpt/global/core/app/type';
import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
export const WORKFLOW_LOCAL_DRAFT_STORAGE_KEY = 'fastgpt_workflow_local_draft_v1';
const WORKFLOW_LOCAL_DRAFT_VERSION = 1;
const WORKFLOW_LOCAL_DRAFT_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000;
export type WorkflowLocalDraft = {
version: typeof WORKFLOW_LOCAL_DRAFT_VERSION;
appId: string;
tmbId: string;
savedAt: number;
data: {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
chatConfig: AppChatConfigType;
};
};
export type WorkflowLocalDraftCheckResult =
| {
status: 'matched';
draft: WorkflowLocalDraft;
route: string;
}
| {
status: 'empty' | 'expired' | 'invalid' | 'storage-unavailable';
draft?: WorkflowLocalDraft;
};
const isBrowser = () => typeof window !== 'undefined' && !!window.localStorage;
/** 生成恢复成功后的固定工作流详情页,避免继续复用保存草稿时的 tab/query。 */
export const getWorkflowLocalDraftDetailRoute = (appId: string) => {
if (!appId || appId.match(/[&=]/)) return '';
return `/app/detail?appId=${encodeURIComponent(appId)}`;
};
const isWorkflowLocalDraft = (value: any): value is WorkflowLocalDraft => {
return (
value?.version === WORKFLOW_LOCAL_DRAFT_VERSION &&
typeof value.appId === 'string' &&
typeof value.tmbId === 'string' &&
typeof value.savedAt === 'number' &&
Array.isArray(value.data?.nodes) &&
Array.isArray(value.data?.edges) &&
!!value.data?.chatConfig
);
};
export const removeWorkflowLocalDraft = () => {
if (!isBrowser()) return;
try {
window.localStorage.removeItem(WORKFLOW_LOCAL_DRAFT_STORAGE_KEY);
} catch (error) {
console.warn('[Workflow local draft] Failed to remove local draft:', error);
}
};
export const removeWorkflowLocalDraftByApp = ({ appId }: { appId: string }) => {
const draft = readWorkflowLocalDraft();
if (!draft || draft.appId !== appId) return;
removeWorkflowLocalDraft();
};
export const readWorkflowLocalDraft = (): WorkflowLocalDraft | null => {
if (!isBrowser()) return null;
try {
const rawDraft = window.localStorage.getItem(WORKFLOW_LOCAL_DRAFT_STORAGE_KEY);
if (!rawDraft) return null;
const parsedDraft = JSON.parse(rawDraft);
if (!isWorkflowLocalDraft(parsedDraft)) {
removeWorkflowLocalDraft();
return null;
}
return parsedDraft;
} catch (error) {
removeWorkflowLocalDraft();
console.warn('[Workflow local draft] Failed to read local draft:', error);
return null;
}
};
export const saveWorkflowLocalDraft = ({
appId,
tmbId,
data
}: {
appId: string;
tmbId: string;
data: WorkflowLocalDraft['data'];
}) => {
removeWorkflowLocalDraftByApp({ appId });
if (!isBrowser() || !appId || !tmbId || data.nodes.length === 0) {
return false;
}
try {
const draft: WorkflowLocalDraft = {
version: WORKFLOW_LOCAL_DRAFT_VERSION,
appId,
tmbId,
savedAt: Date.now(),
data
};
window.localStorage.setItem(WORKFLOW_LOCAL_DRAFT_STORAGE_KEY, JSON.stringify(draft));
return true;
} catch (error) {
console.warn('[Workflow local draft] Failed to save local draft:', error);
return false;
}
};
/**
* 登录恢复草稿的最小匹配规则:本地存在未过期草稿即可恢复。
* 新草稿会保存创建草稿时的 tmbId,登录恢复层需要先校验身份再补远端保存。
* 恢复成功后强制跳到草稿所属 app 的详情页,不再要求登录回跳路由也指向该 app。
*/
export const checkWorkflowLocalDraft = (): WorkflowLocalDraftCheckResult => {
if (!isBrowser()) return { status: 'storage-unavailable' };
const draft = readWorkflowLocalDraft();
if (!draft) return { status: 'empty' };
if (Date.now() - draft.savedAt > WORKFLOW_LOCAL_DRAFT_EXPIRE_TIME) {
removeWorkflowLocalDraft();
return { status: 'expired' };
}
const detailRoute = getWorkflowLocalDraftDetailRoute(draft.appId);
if (!detailRoute) {
removeWorkflowLocalDraft();
return { status: 'invalid' };
}
return {
status: 'matched',
draft,
route: detailRoute
};
};
import { Box, Button } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import { AUTH_ERROR_EVENT_NAME, type AuthErrorEventDetail } from '@/web/common/api/request';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAuthLoginRedirectPath } from '@/web/support/user/loginRedirect/url';
type UseWorkflowAuthExpiredDraftProps = {
leaveSaveSignRef: MutableRefObject<boolean>;
saveLocalDraft: () => boolean;
};
/**
* 处理工作流编辑页登录过期后的本地草稿保护流程。
*
* 这里集中维护鉴权失败相关的临时状态:
* - 首次 403 允许请求拦截器跳转登录页,从而触发一次浏览器离开确认;
* - 如果用户取消浏览器确认并停留当前页,展示应用内登录过期提示;
* - 如果 403 来自刷新前的自动保存请求,不再触发第二次登录跳转,避免系统弹窗循环;
* - 点击重新登录时主动关闭离开保护,直接进入登录页恢复草稿。
*/
export const useWorkflowAuthExpiredDraft = ({
leaveSaveSignRef,
saveLocalDraft
}: UseWorkflowAuthExpiredDraftProps) => {
const { t } = useTranslation();
const router = useRouter();
const [showAuthExpiredModal, setShowAuthExpiredModal] = useState(false);
const authExpiredRedirecting = useRef(false);
const authExpiredDraftSaved = useRef(false);
const beforeUnloadAutoSaving = useRef(false);
const authExpiredModalTimer = useRef<number>();
const getLoginRoute = useCallback(() => {
return getAuthLoginRedirectPath({
lastRoute: location.pathname + location.search
});
}, []);
const showAuthExpiredNotice = useCallback(() => {
window.clearTimeout(authExpiredModalTimer.current);
authExpiredModalTimer.current = window.setTimeout(() => {
setShowAuthExpiredModal(true);
});
}, []);
useEffect(() => {
const handleAuthError = (event: Event) => {
const detail = (event as CustomEvent<AuthErrorEventDetail>).detail;
const savedDraft = saveLocalDraft();
if (!savedDraft && !authExpiredDraftSaved.current) return;
authExpiredDraftSaved.current = savedDraft || authExpiredDraftSaved.current;
if (authExpiredRedirecting.current || beforeUnloadAutoSaving.current) {
// 已进入鉴权失败处理,或当前 403 来自 beforeunload 自动保存:不再触发登录跳转,避免系统弹窗循环。
authExpiredRedirecting.current = true;
detail.skipClearToken = true;
detail.skipRedirect = true;
showAuthExpiredNotice();
return;
}
authExpiredRedirecting.current = true;
// 首次 403 仍允许请求拦截器触发登录跳转,从而只出现一次浏览器离开确认;但跳过 logout 请求避免二次跳转。
detail.skipClearToken = true;
};
window.addEventListener(AUTH_ERROR_EVENT_NAME, handleAuthError);
return () => {
window.removeEventListener(AUTH_ERROR_EVENT_NAME, handleAuthError);
};
}, [saveLocalDraft, showAuthExpiredNotice]);
useEffect(() => {
return () => {
window.clearTimeout(authExpiredModalTimer.current);
};
}, []);
const handleBeforeUnloadAuthExpired = useCallback(() => {
const isAuthExpiredRedirecting = authExpiredRedirecting.current;
if (isAuthExpiredRedirecting && authExpiredDraftSaved.current) {
showAuthExpiredNotice();
}
return {
isAuthExpiredRedirecting
};
}, [showAuthExpiredNotice]);
const setBeforeUnloadAutoSaving = useCallback((saving: boolean) => {
beforeUnloadAutoSaving.current = saving;
}, []);
const shouldSkipUnmountAutoSave = useCallback(() => {
return authExpiredRedirecting.current;
}, []);
const handleRelogin = useCallback(() => {
leaveSaveSignRef.current = false;
authExpiredRedirecting.current = false;
beforeUnloadAutoSaving.current = false;
window.clearTimeout(authExpiredModalTimer.current);
router.replace(getLoginRoute());
}, [getLoginRoute, leaveSaveSignRef, router]);
const handleCancelRelogin = useCallback(() => {
setShowAuthExpiredModal(false);
}, []);
const authExpiredModal = useMemo(() => {
if (!showAuthExpiredModal) return null;
return (
<MyModal
isOpen
isCentered
size={'sm'}
title={t('workflow:workflow_local_draft_auth_expired_title')}
showCloseButton={false}
closeOnOverlayClick={false}
borderRadius={'10px'}
footer={
<>
<Button variant={'whiteBase'} onClick={handleCancelRelogin}>
{t('common:Cancel')}
</Button>
<Button onClick={handleRelogin}>{t('workflow:workflow_local_draft_relogin')}</Button>
</>
}
>
<Box>{t('workflow:workflow_local_draft_auth_expired_notice')}</Box>
</MyModal>
);
}, [handleCancelRelogin, handleRelogin, showAuthExpiredModal, t]);
return {
authExpiredModal,
handleBeforeUnloadAuthExpired,
setBeforeUnloadAutoSaving,
shouldSkipUnmountAutoSave
};
};
import { useCallback } from 'react';
import type { UserType } from '@fastgpt/global/support/user/type';
import { postPublishApp } from '@/web/core/app/api/version';
import {
checkWorkflowLocalDraft,
type WorkflowLocalDraft,
removeWorkflowLocalDraft
} from './storage';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
const WORKFLOW_DRAFT_RESTORE_SAVE_MAX_RETRY = 3;
export type WorkflowLocalDraftRestoreResult =
| {
status: 'restored';
route: string;
}
| {
status: 'mismatched-team' | 'not-found';
};
/**
* 把本地工作流草稿补保存到远端自动保存版本。
*
* 这里的失败只影响本地草稿恢复,不应该阻断登录后的页面跳转,也不需要给用户提示。
* 因此内部静默重试固定次数:临时网络抖动有机会恢复;重试耗尽后由调用方删除本地草稿并继续跳转。
*/
const saveWorkflowDraftWithRetry = async ({
draft,
saveDraft
}: {
draft: WorkflowLocalDraft;
saveDraft: typeof postPublishApp;
}) => {
for (let retryCount = 0; retryCount < WORKFLOW_DRAFT_RESTORE_SAVE_MAX_RETRY; retryCount++) {
try {
await saveDraft(draft.appId, {
...draft.data,
isPublish: false,
autoSave: true
});
return;
} catch {
// 失败时静默进入下一次重试;重试耗尽后由调用方继续跳转。
}
}
};
/**
* 登录成功后尝试恢复工作流本地草稿。
*
* 工作流草稿只以保存时的 tmbId 判断是否可恢复。这样可以覆盖多标签页场景:
* A 标签页正在编辑工作流,B 标签页切换团队并登出后,A 标签页本地草稿仍应以
* 保存草稿时写入的 tmbId 为准,而不是被 B 标签页更新后的全局登录状态污染。
*
* 恢复请求最多重试 3 次;仍失败时丢弃本地草稿并返回恢复成功路由。
* 普通 lastRoute fallback 和跨团队默认跳转由登录跳转协调层处理。
*/
export const restoreWorkflowLocalDraftAfterLogin = async ({
user,
saveDraft
}: {
user: UserType;
saveDraft: typeof postPublishApp;
}): Promise<WorkflowLocalDraftRestoreResult> => {
const draftResult = checkWorkflowLocalDraft();
if (draftResult.status !== 'matched') {
return { status: 'not-found' };
}
const draftTmbId = draftResult.draft.tmbId;
// 草稿以保存时的 tmbId 为准,避免其他标签页切团队后污染登录恢复。
if (draftTmbId !== user.team?.tmbId) {
removeWorkflowLocalDraft();
return { status: 'mismatched-team' };
}
await saveWorkflowDraftWithRetry({
draft: draftResult.draft,
saveDraft
});
removeWorkflowLocalDraft();
// 草稿恢复成功或重试耗尽后,都固定回到草稿所属 app 的详情页。
return {
status: 'restored',
route: draftResult.route
};
};
/**
* 登录后跳转协调层使用的工作流本地草稿恢复 hook。
*
* `postPublishApp` 通过 useRequest 包装,主要是复用全局请求链路;这里显式关闭错误 toast,
* 因为草稿恢复失败已经被 `saveWorkflowDraftWithRetry` 静默处理,不能打断登录跳转体验。
*/
export const useWorkflowLocalDraftRestore = () => {
const { runAsync: saveWorkflowLocalDraft } = useRequest(postPublishApp, {
manual: true,
errorToast: ''
});
return useCallback(
async ({ user }: { user: UserType }): Promise<WorkflowLocalDraftRestoreResult> => {
return restoreWorkflowLocalDraftAfterLogin({
user,
saveDraft: saveWorkflowLocalDraft
});
},
[saveWorkflowLocalDraft]
);
};
.react-flow { .react-flow {
overflow: visible; overflow: visible;
& .nodrag {
cursor: default;
}
} }
.react-flow__panel.react-flow__attribution { .react-flow__panel.react-flow__attribution {
z-index: 0; z-index: 0;
......
let currentAuthTmbId = '';
/**
* 记录当前标签页内存中的登录团队成员 ID。
* 该值不会跨标签页同步,用于 403 跳登录时把“当前标签页原身份”固化到 query。
*/
export const setCurrentAuthTmbId = (tmbId?: string) => {
currentAuthTmbId = tmbId || '';
};
/**
* 读取当前标签页内存中的登录团队成员 ID。
*/
export const getCurrentAuthTmbId = () => currentAuthTmbId;
import { useCallback } from 'react';
import type { UserType } from '@fastgpt/global/support/user/type';
import {
type WorkflowLocalDraftRestoreResult,
useWorkflowLocalDraftRestore
} from '@/web/core/workflow/localDraft/useWorkflowLocalDraftRestore';
const DEFAULT_LOGIN_ROUTE = '/dashboard/agent';
type RestoreWorkflowLocalDraft = (props: {
user: UserType;
}) => Promise<WorkflowLocalDraftRestoreResult>;
/**
* 计算普通登录成功后的兜底跳转地址。
*
* lastRoute 是登录前页面写入的浏览器状态,可能来自旧团队或其他标签页。
* 只有 403 跳登录时 query 携带了当前标签页的 lastTmbId,才做跨团队拦截。
*/
export const getSafeFallbackRouteAfterLogin = ({
user,
fallbackRoute,
lastTmbId
}: {
user: UserType;
fallbackRoute: string;
lastTmbId?: string;
}) => {
if (lastTmbId && lastTmbId !== user.team?.tmbId) {
return DEFAULT_LOGIN_ROUTE;
}
return fallbackRoute;
};
/**
* 登录成功后的跳转协调层。
*
* 优先恢复工作流本地草稿;如果草稿属于其他团队账号,直接回到 dashboard,
* 避免继续打开旧团队的 workflow lastRoute。没有可恢复草稿时,再执行通用的
* last tmbId 校验和 lastRoute 跳转。
*/
export const resolveLoginRedirectAfterLogin = async ({
user,
fallbackRoute,
lastTmbId,
restoreWorkflowLocalDraft
}: {
user: UserType;
fallbackRoute: string;
lastTmbId?: string;
restoreWorkflowLocalDraft: RestoreWorkflowLocalDraft;
}) => {
const draftResult = await restoreWorkflowLocalDraft({ user });
if (draftResult.status === 'restored') {
return draftResult.route;
}
if (draftResult.status === 'mismatched-team') {
return DEFAULT_LOGIN_ROUTE;
}
return getSafeFallbackRouteAfterLogin({ user, fallbackRoute, lastTmbId });
};
/**
* 登录页使用的跳转 hook。登录业务只依赖该协调层,不直接感知工作流草稿存储与恢复细节。
*/
export const useLoginRedirectAfterLogin = () => {
const restoreWorkflowLocalDraft = useWorkflowLocalDraftRestore();
return useCallback(
({
user,
fallbackRoute,
lastTmbId
}: {
user: UserType;
fallbackRoute: string;
lastTmbId?: string;
}) => {
return resolveLoginRedirectAfterLogin({
user,
fallbackRoute,
lastTmbId,
restoreWorkflowLocalDraft
});
},
[restoreWorkflowLocalDraft]
);
};
import { safeEncodeURIComponent } from '@/web/common/utils/uri';
import { getCurrentAuthTmbId } from '../currentAuthTmbId';
export const LAST_TMB_ID_QUERY_KEY = 'lastTmbId';
/**
* 构造鉴权失败后的登录页地址。
*
* lastRoute 和 lastTmbId 都必须在跳登录时写入 query:lastRoute 记录要回跳的页面,
* lastTmbId 记录当前标签页触发 403 前的团队身份。lastTmbId 不能只依赖 localStorage,
* 因为其他标签页切换团队会改写全局值,导致当前标签页登录后误判为同一团队。
*/
export const getAuthLoginRedirectPath = ({
lastRoute,
lastTmbId = getCurrentAuthTmbId()
}: {
lastRoute: string;
lastTmbId?: string;
}) => {
const query = [`lastRoute=${safeEncodeURIComponent(lastRoute)}`];
if (lastTmbId) {
query.push(`${LAST_TMB_ID_QUERY_KEY}=${safeEncodeURIComponent(lastTmbId)}`);
}
return `/login?${query.join('&')}`;
};
...@@ -7,6 +7,7 @@ import type { UserType } from '@fastgpt/global/support/user/type'; ...@@ -7,6 +7,7 @@ import type { UserType } from '@fastgpt/global/support/user/type';
import type { ClientTeamPlanStatusType } from '@fastgpt/global/support/wallet/sub/type'; import type { ClientTeamPlanStatusType } from '@fastgpt/global/support/wallet/sub/type';
import { getTeamPlanStatus } from './team/api'; import { getTeamPlanStatus } from './team/api';
import { setLangToStorage, getLangMapping } from '@fastgpt/web/i18n/utils'; import { setLangToStorage, getLangMapping } from '@fastgpt/web/i18n/utils';
import { setCurrentAuthTmbId } from './currentAuthTmbId';
type State = { type State = {
systemMsgReadId: string; systemMsgReadId: string;
...@@ -66,6 +67,7 @@ export const useUserStore = create<State>()( ...@@ -66,6 +67,7 @@ export const useUserStore = create<State>()(
} }
}, },
setUserInfo(user: UserType | null) { setUserInfo(user: UserType | null) {
setCurrentAuthTmbId(user?.team?.tmbId);
set((state) => { set((state) => {
state.userInfo = user ? user : null; state.userInfo = user ? user : null;
state.isTeamAdmin = !!user?.team?.permission?.hasManagePer; state.isTeamAdmin = !!user?.team?.permission?.hasManagePer;
......
...@@ -4,10 +4,12 @@ import { ...@@ -4,10 +4,12 @@ import {
checkMaxQuantity, checkMaxQuantity,
requestFinish, requestFinish,
checkRes, checkRes,
responseError responseError,
AUTH_ERROR_EVENT_NAME
} from '../../../../src/web/common/api/request'; } from '../../../../src/web/common/api/request';
import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { TOKEN_ERROR_CODE } from '@fastgpt/global/common/error/errorCode'; import { TOKEN_ERROR_CODE } from '@fastgpt/global/common/error/errorCode';
import { clearToken } from '@/web/support/user/auth';
// Mock all required dependencies // Mock all required dependencies
vi.mock('@fastgpt/web/common/system/utils', () => ({ vi.mock('@fastgpt/web/common/system/utils', () => ({
...@@ -33,16 +35,21 @@ const mockLocation = { ...@@ -33,16 +35,21 @@ const mockLocation = {
replace: vi.fn(), replace: vi.fn(),
search: '' search: ''
}; };
const dispatchEventMock = vi.fn();
const tokenErrorCode = Number(Object.keys(TOKEN_ERROR_CODE)[0]);
vi.stubGlobal('window', { vi.stubGlobal('window', {
location: mockLocation location: mockLocation,
dispatchEvent: dispatchEventMock
}); });
vi.stubGlobal('location', mockLocation);
describe('request utils', () => { describe('request utils', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
Object.keys(maxQuantityMap).forEach((key) => delete maxQuantityMap[key]); Object.keys(maxQuantityMap).forEach((key) => delete maxQuantityMap[key]);
mockLocation.pathname = '/test'; mockLocation.pathname = '/test';
dispatchEventMock.mockReturnValue(true);
}); });
describe('checkMaxQuantity', () => { describe('checkMaxQuantity', () => {
...@@ -60,8 +67,8 @@ describe('request utils', () => { ...@@ -60,8 +67,8 @@ describe('request utils', () => {
it('should cancel oldest request when maxQuantity exceeded', () => { it('should cancel oldest request when maxQuantity exceeded', () => {
const result1 = checkMaxQuantity({ url: 'test', maxQuantity: 2 }); const result1 = checkMaxQuantity({ url: 'test', maxQuantity: 2 });
const result2 = checkMaxQuantity({ url: 'test', maxQuantity: 2 }); checkMaxQuantity({ url: 'test', maxQuantity: 2 });
const result3 = checkMaxQuantity({ url: 'test', maxQuantity: 2 }); checkMaxQuantity({ url: 'test', maxQuantity: 2 });
expect(maxQuantityMap['test']?.length).toBe(2); expect(maxQuantityMap['test']?.length).toBe(2);
expect(maxQuantityMap['test']?.find((item) => item.id === result1.id)).toBeUndefined(); expect(maxQuantityMap['test']?.find((item) => item.id === result1.id)).toBeUndefined();
...@@ -103,13 +110,56 @@ describe('request utils', () => { ...@@ -103,13 +110,56 @@ describe('request utils', () => {
const err = { const err = {
response: { response: {
data: { data: {
code: Object.values(TOKEN_ERROR_CODE)[0] code: tokenErrorCode
} }
} }
}; };
await expect(responseError(err)).rejects.toEqual({ await expect(responseError(err)).rejects.toEqual({ message: 'common:unauth_token' });
code: Object.values(TOKEN_ERROR_CODE)[0] expect(dispatchEventMock).toHaveBeenCalledWith(expect.any(CustomEvent));
expect(dispatchEventMock.mock.calls[0]?.[0].type).toBe(AUTH_ERROR_EVENT_NAME);
expect(clearToken).toHaveBeenCalled();
expect(mockLocation.replace).toHaveBeenCalledWith('http://test.com');
});
it('should allow auth error listeners to skip clearing token', async () => {
mockLocation.pathname = '/dashboard';
dispatchEventMock.mockImplementation((event: CustomEvent) => {
event.detail.skipClearToken = true;
return true;
});
const err = {
response: {
data: {
code: tokenErrorCode
}
}
};
await expect(responseError(err)).rejects.toEqual({ message: 'common:unauth_token' });
expect(clearToken).not.toHaveBeenCalled();
expect(mockLocation.replace).toHaveBeenCalledWith('http://test.com');
}); });
it('should allow auth error listeners to skip redirect', async () => {
mockLocation.pathname = '/dashboard';
dispatchEventMock.mockImplementation((event: CustomEvent) => {
event.detail.skipClearToken = true;
event.detail.skipRedirect = true;
return true;
});
const err = {
response: {
data: {
code: tokenErrorCode
}
}
};
await expect(responseError(err)).rejects.toEqual({ message: 'common:unauth_token' });
expect(clearToken).not.toHaveBeenCalled();
expect(mockLocation.replace).not.toHaveBeenCalled();
}); });
it('should handle token error for outlink page', async () => { it('should handle token error for outlink page', async () => {
...@@ -117,13 +167,13 @@ describe('request utils', () => { ...@@ -117,13 +167,13 @@ describe('request utils', () => {
const err = { const err = {
response: { response: {
data: { data: {
code: Object.values(TOKEN_ERROR_CODE)[0] code: tokenErrorCode
} }
} }
}; };
await expect(responseError(err)).rejects.toEqual({ await expect(responseError(err)).rejects.toEqual({ message: 'common:unauth_token' });
code: Object.values(TOKEN_ERROR_CODE)[0] expect(clearToken).not.toHaveBeenCalled();
}); expect(mockLocation.replace).not.toHaveBeenCalled();
}); });
it('should handle team error', async () => { it('should handle team error', async () => {
......
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
checkWorkflowLocalDraft,
getWorkflowLocalDraftDetailRoute,
readWorkflowLocalDraft,
removeWorkflowLocalDraft,
saveWorkflowLocalDraft,
WORKFLOW_LOCAL_DRAFT_STORAGE_KEY
} from '../../../../src/web/core/workflow/localDraft/storage';
const storageMap = new Map<string, string>();
const localStorageMock = {
getItem: vi.fn((key: string) => storageMap.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
storageMap.set(key, value);
}),
removeItem: vi.fn((key: string) => {
storageMap.delete(key);
})
};
const draftData = {
nodes: [{ nodeId: 'node-1' }] as any,
edges: [] as any,
chatConfig: { welcomeText: 'hello' } as any
};
describe('workflow local draft', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
storageMap.clear();
vi.stubGlobal('window', {
localStorage: localStorageMock,
location: {
pathname: '/app/detail',
search: '?appId=app-1&currentTab=appEdit'
}
});
vi.setSystemTime(new Date('2026-05-11T00:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should build canonical app detail route from draft appId', () => {
expect(getWorkflowLocalDraftDetailRoute('app-1')).toBe('/app/detail?appId=app-1');
expect(getWorkflowLocalDraftDetailRoute('')).toBe('');
expect(getWorkflowLocalDraftDetailRoute('app-1&currentTab=logs')).toBe('');
});
it('should save and match draft with tmbId', () => {
const saved = saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: draftData
});
expect(saved).toBe(true);
const result = checkWorkflowLocalDraft();
expect(result.status).toBe('matched');
if (result.status === 'matched') {
expect(result.draft.appId).toBe('app-1');
expect(result.draft.tmbId).toBe('tmb-a');
expect(result.draft).not.toHaveProperty('username');
expect(result.draft).not.toHaveProperty('teamId');
expect(result.draft).not.toHaveProperty('route');
expect(result.route).toBe('/app/detail?appId=app-1');
}
});
it('should clear stored draft without tmbId', () => {
saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: draftData
});
const savedDraft = JSON.parse(storageMap.get(WORKFLOW_LOCAL_DRAFT_STORAGE_KEY)!);
delete savedDraft.tmbId;
storageMap.set(WORKFLOW_LOCAL_DRAFT_STORAGE_KEY, JSON.stringify(savedDraft));
const draft = readWorkflowLocalDraft();
expect(draft).toBeNull();
expect(storageMap.has(WORKFLOW_LOCAL_DRAFT_STORAGE_KEY)).toBe(false);
});
it('should match draft without checking the login fallback route', () => {
saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: draftData
});
const matchedResult = checkWorkflowLocalDraft();
expect(matchedResult.status).toBe('matched');
});
it('should replace stale same-app draft with the latest write', () => {
saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: draftData
});
const saved = saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: {
...draftData,
nodes: [{ nodeId: 'node-latest' }] as any
}
});
expect(saved).toBe(true);
expect(readWorkflowLocalDraft()?.data.nodes[0]?.nodeId).toBe('node-latest');
});
it('should clear stale same-app draft when the latest write is unavailable', () => {
saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: draftData
});
const saved = saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: {
...draftData,
nodes: []
}
});
expect(saved).toBe(false);
expect(readWorkflowLocalDraft()).toBeNull();
});
it('should keep another app draft when an invalid write belongs to a different app', () => {
saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: draftData
});
const saved = saveWorkflowLocalDraft({
appId: 'app-2',
tmbId: 'tmb-a',
data: {
...draftData,
nodes: []
}
});
expect(saved).toBe(false);
expect(readWorkflowLocalDraft()?.appId).toBe('app-1');
});
it('should clear malformed or expired drafts', () => {
storageMap.set(WORKFLOW_LOCAL_DRAFT_STORAGE_KEY, '{bad-json');
expect(readWorkflowLocalDraft()).toBeNull();
expect(storageMap.has(WORKFLOW_LOCAL_DRAFT_STORAGE_KEY)).toBe(false);
saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: draftData
});
vi.setSystemTime(new Date('2026-05-19T00:00:01.000Z'));
const result = checkWorkflowLocalDraft();
expect(result.status).toBe('expired');
expect(readWorkflowLocalDraft()).toBeNull();
});
it('should remove current draft explicitly', () => {
saveWorkflowLocalDraft({
appId: 'app-1',
tmbId: 'tmb-a',
data: draftData
});
removeWorkflowLocalDraft();
expect(readWorkflowLocalDraft()).toBeNull();
});
});
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveLoginRedirectAfterLogin } from '../../../../src/web/support/user/loginRedirect';
import {
readWorkflowLocalDraft,
saveWorkflowLocalDraft
} from '../../../../src/web/core/workflow/localDraft/storage';
import { restoreWorkflowLocalDraftAfterLogin } from '../../../../src/web/core/workflow/localDraft/useWorkflowLocalDraftRestore';
import { setCurrentAuthTmbId } from '../../../../src/web/support/user/currentAuthTmbId';
import { getAuthLoginRedirectPath } from '../../../../src/web/support/user/loginRedirect/url';
import type { UserType } from '@fastgpt/global/support/user/type';
vi.mock('@/web/core/app/api/version', () => ({
postPublishApp: vi.fn()
}));
vi.mock('next-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key
})
}));
vi.mock('@fastgpt/web/hooks/useToast', () => ({
useToast: () => ({
toast: vi.fn()
})
}));
const storageMap = new Map<string, string>();
const sessionStorageMap = new Map<string, string>();
const localStorageMock = {
getItem: vi.fn((key: string) => storageMap.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
storageMap.set(key, value);
}),
removeItem: vi.fn((key: string) => {
storageMap.delete(key);
})
};
const sessionStorageMock = {
getItem: vi.fn((key: string) => sessionStorageMap.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
sessionStorageMap.set(key, value);
}),
removeItem: vi.fn((key: string) => {
sessionStorageMap.delete(key);
})
};
const user = {
_id: 'user-a',
username: 'user-a',
team: {
teamId: 'team-a',
tmbId: 'tmb-a'
}
} as UserType;
const saveDraftToStorage = ({
tmbId = 'tmb-a'
}: {
tmbId?: string;
} = {}) =>
saveWorkflowLocalDraft({
appId: 'app-1',
tmbId,
data: {
nodes: [{ nodeId: 'node-1' }] as any,
edges: [] as any,
chatConfig: { welcomeText: 'hello' } as any
}
});
const resolveLoginRoute = ({
loginUser = user,
fallbackRoute = '/app/detail?appId=app-1&currentTab=appEdit',
lastTmbId,
saveDraft = vi.fn()
}: {
loginUser?: UserType;
fallbackRoute?: string;
lastTmbId?: string;
saveDraft?: ReturnType<typeof vi.fn>;
} = {}) =>
resolveLoginRedirectAfterLogin({
user: loginUser,
fallbackRoute,
lastTmbId,
restoreWorkflowLocalDraft: ({ user }) =>
restoreWorkflowLocalDraftAfterLogin({
user,
saveDraft: saveDraft as any
})
});
describe('login redirect helpers', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
storageMap.clear();
sessionStorageMap.clear();
setCurrentAuthTmbId();
vi.stubGlobal('window', {
localStorage: localStorageMock,
sessionStorage: sessionStorageMock,
location: {
pathname: '/login',
search: ''
}
});
vi.setSystemTime(new Date('2026-05-11T00:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should auto-save matched draft, clear cache and return canonical app detail route', async () => {
saveDraftToStorage();
const saveDraft = vi.fn().mockResolvedValue(undefined);
const route = await resolveLoginRoute({
fallbackRoute: '/app/detail?appId=app-1&currentTab=appEdit',
saveDraft: saveDraft as any
});
expect(saveDraft).toHaveBeenCalledWith(
'app-1',
expect.objectContaining({
autoSave: true,
isPublish: false
})
);
expect(readWorkflowLocalDraft()).toBeNull();
expect(route).toBe('/app/detail?appId=app-1');
});
it('should restore draft even when login fallback route is not the workflow detail page', async () => {
saveDraftToStorage();
const saveDraft = vi.fn().mockResolvedValue(undefined);
const route = await resolveLoginRoute({
fallbackRoute: '/dashboard/agent',
saveDraft: saveDraft as any
});
expect(saveDraft).toHaveBeenCalledWith(
'app-1',
expect.objectContaining({
autoSave: true,
isPublish: false
})
);
expect(readWorkflowLocalDraft()).toBeNull();
expect(route).toBe('/app/detail?appId=app-1');
});
it('should restore without using the encoded login lastRoute as redirect target', async () => {
saveDraftToStorage();
const saveDraft = vi.fn().mockResolvedValue(undefined);
const route = await resolveLoginRoute({
fallbackRoute: '%2Fapp%2Fdetail%3FappId%3Dapp-1%26currentTab%3DappEdit',
saveDraft: saveDraft as any
});
expect(saveDraft).toHaveBeenCalledWith(
'app-1',
expect.objectContaining({
autoSave: true,
isPublish: false
})
);
expect(readWorkflowLocalDraft()).toBeNull();
expect(route).toBe('/app/detail?appId=app-1');
});
it('should retry 3 times and continue redirect when auto-save keeps failing', async () => {
saveDraftToStorage();
const restoreError = new Error('network error');
const saveDraft = vi.fn().mockRejectedValue(restoreError);
const route = await resolveLoginRoute({
fallbackRoute: '/app/detail?appId=app-1',
saveDraft: saveDraft as any
});
expect(saveDraft).toHaveBeenCalledTimes(3);
expect(readWorkflowLocalDraft()).toBeNull();
expect(route).toBe('/app/detail?appId=app-1');
});
it('should stop retrying when auto-save succeeds after transient failures', async () => {
saveDraftToStorage();
const saveDraft = vi
.fn()
.mockRejectedValueOnce(new Error('network error 1'))
.mockRejectedValueOnce(new Error('network error 2'))
.mockResolvedValueOnce(undefined);
const route = await resolveLoginRoute({
fallbackRoute: '/app/detail?appId=app-1',
saveDraft: saveDraft as any
});
expect(saveDraft).toHaveBeenCalledTimes(3);
expect(readWorkflowLocalDraft()).toBeNull();
expect(route).toBe('/app/detail?appId=app-1');
});
it('should restore draft when draft tmbId matches even if query lastTmbId differs', async () => {
saveDraftToStorage();
const saveDraft = vi.fn().mockResolvedValue(undefined);
const route = await resolveLoginRoute({
lastTmbId: 'tmb-b',
fallbackRoute: '/app/detail?appId=app-1&currentTab=appEdit',
saveDraft: saveDraft as any
});
expect(saveDraft).toHaveBeenCalledWith(
'app-1',
expect.objectContaining({
autoSave: true,
isPublish: false
})
);
expect(readWorkflowLocalDraft()).toBeNull();
expect(route).toBe('/app/detail?appId=app-1');
});
it('should discard draft and skip fallback route when login tmbId differs from draft tmbId', async () => {
saveDraftToStorage();
const saveDraft = vi.fn();
const route = await resolveLoginRoute({
loginUser: {
...user,
team: {
...user.team,
teamId: 'team-b',
tmbId: 'tmb-b'
}
},
fallbackRoute: '/app/detail?appId=app-1&currentTab=appEdit',
saveDraft: saveDraft as any
});
expect(saveDraft).not.toHaveBeenCalled();
expect(readWorkflowLocalDraft()).toBeNull();
expect(route).toBe('/dashboard/agent');
});
it('should discard draft and skip fallback route when draft and query lastTmbId both mismatch', async () => {
saveDraftToStorage();
const saveDraft = vi.fn();
const route = await resolveLoginRoute({
loginUser: {
...user,
team: {
...user.team,
teamId: 'team-b',
tmbId: 'tmb-b'
}
},
lastTmbId: 'tmb-a',
fallbackRoute: '/app/detail?appId=app-1&currentTab=appEdit',
saveDraft: saveDraft as any
});
expect(saveDraft).not.toHaveBeenCalled();
expect(readWorkflowLocalDraft()).toBeNull();
expect(route).toBe('/dashboard/agent');
});
it('should use fallback route without draft when login tmbId matches query lastTmbId', async () => {
const saveDraft = vi.fn();
const route = await resolveLoginRoute({
lastTmbId: 'tmb-a',
fallbackRoute: '/app/detail?appId=app-1&currentTab=appEdit',
saveDraft: saveDraft as any
});
expect(saveDraft).not.toHaveBeenCalled();
expect(route).toBe('/app/detail?appId=app-1&currentTab=appEdit');
});
it('should use fallback route without draft when query lastTmbId is missing', async () => {
const saveDraft = vi.fn();
const route = await resolveLoginRoute({
fallbackRoute: '/app/detail?appId=app-1&currentTab=appEdit',
saveDraft: saveDraft as any
});
expect(saveDraft).not.toHaveBeenCalled();
expect(route).toBe('/app/detail?appId=app-1&currentTab=appEdit');
});
it('should skip fallback route without draft when login tmbId differs from query lastTmbId', async () => {
const saveDraft = vi.fn();
const route = await resolveLoginRoute({
loginUser: {
...user,
team: {
...user.team,
teamId: 'team-b',
tmbId: 'tmb-b'
}
},
lastTmbId: 'tmb-a',
fallbackRoute: '/app/detail?appId=app-1&currentTab=appEdit',
saveDraft: saveDraft as any
});
expect(saveDraft).not.toHaveBeenCalled();
expect(route).toBe('/dashboard/agent');
});
it('should build auth login redirect path with current tab tmbId', () => {
setCurrentAuthTmbId('tmb-a');
expect(getAuthLoginRedirectPath({ lastRoute: '/app/detail?appId=app-1' })).toBe(
'/login?lastRoute=%2Fapp%2Fdetail%3FappId%3Dapp-1&lastTmbId=tmb-a'
);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment