diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts index af3f76d84f3..4d9e5bc9aa9 100644 --- a/packages/server/src/controllers/chatflows/index.ts +++ b/packages/server/src/controllers/chatflows/index.ts @@ -143,6 +143,25 @@ const getChatflowById = async (req: Request, res: Response, next: NextFunction) } } +// Resolve the owning workspace of a flow for the requesting user IFF they are a member of it (else an +// identical 404 — no cross-workspace disclosure). Used by the UI to auto-switch the active workspace when a +// user opens a flow URL that lives in a workspace they belong to but isn't active. UI-only: API-key callers +// have no req.user.id, so the service treats them as non-members (404). +const getChatflowWorkspace = async (req: Request, res: Response, next: NextFunction) => { + try { + if (typeof req.params === 'undefined' || !req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + `Error: chatflowsController.getChatflowWorkspace - id not provided!` + ) + } + const apiResponse = await chatflowsService.resolveChatflowWorkspace(req.params.id, req.user?.id) + return res.json(apiResponse) + } catch (error) { + next(error) + } +} + const saveChatflow = async (req: Request, res: Response, next: NextFunction) => { try { if (!req.body) { @@ -444,6 +463,7 @@ export default { getAllChatflows, getChatflowByApiKey, getChatflowById, + getChatflowWorkspace, saveChatflow, updateChatflow, getSinglePublicChatflow, diff --git a/packages/server/src/routes/chatflows/index.ts b/packages/server/src/routes/chatflows/index.ts index 2dfdfaab64f..0ceef7741c9 100644 --- a/packages/server/src/routes/chatflows/index.ts +++ b/packages/server/src/routes/chatflows/index.ts @@ -21,6 +21,14 @@ router.get( checkAnyPermission('chatflows:view,chatflows:update,chatflows:delete,agentflows:view,agentflows:update,agentflows:delete'), chatflowsController.getChatflowById ) +// Resolve the owning workspace of a flow for the current user (membership-gated; identical 404 otherwise). +// Lets the UI auto-switch the active workspace when opening a flow URL from another (member) workspace. +// Two-segment path so it never collides with the single-segment '/:id' matcher above. +router.get( + '/resolve-workspace/:id', + checkAnyPermission('chatflows:view,chatflows:update,chatflows:delete,agentflows:view,agentflows:update,agentflows:delete'), + chatflowsController.getChatflowWorkspace +) router.get(['/apikey/', '/apikey/:apikey'], chatflowsController.getChatflowByApiKey) // UPDATE diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 140cc9d31a6..58d9f99c5f6 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -12,6 +12,7 @@ import { ChatMessageFeedback } from '../../database/entities/ChatMessageFeedback import { ScheduleTriggerType } from '../../database/entities/ScheduleRecord' import { UpsertHistory } from '../../database/entities/UpsertHistory' import { Workspace } from '../../enterprise/database/entities/workspace.entity' +import { WorkspaceUser, WorkspaceUserStatus } from '../../enterprise/database/entities/workspace-user.entity' import { getWorkspaceSearchOptions } from '../../enterprise/utils/ControllerServiceUtils' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' @@ -304,6 +305,52 @@ const getChatflowById = async (chatflowId: string, workspaceId?: string): Promis } } +// Resolve which workspace owns a flow, but ONLY for a caller who is a member of that workspace. A chatflow +// id is a globally-unique UUID, so it maps to exactly one owning workspace; the UI uses this to auto-switch +// the active workspace when a user opens a flow URL that lives in a workspace they belong to but isn't +// currently active. SECURITY: every failure mode returns the IDENTICAL 404 as getChatflowById, so this never +// discloses that a flow exists in a workspace the caller cannot access. API-key callers (no userId) -> 404. +const resolveChatflowWorkspace = async (chatflowId: string, userId?: string): Promise<{ workspaceId: string }> => { + const notFound = () => new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found in the database!`) + try { + if (!isValidUUID(chatflowId)) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, ChatflowErrorMessage.INVALID_CHATFLOW_ID) + } + if (!userId) { + throw notFound() + } + const appServer = getRunningExpressApp() + // Look up the flow across ALL workspaces (no workspace filter), then gate strictly on membership. + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: { id: chatflowId }, + select: ['id', 'workspaceId'] + }) + if (!chatflow || !chatflow.workspaceId) { + throw notFound() + } + // Direct membership check so the no-leak guarantee holds: identical response whether the flow is + // missing or the user simply isn't an active member of its workspace. Require ACTIVE status so a + // user who is only INVITED (not yet accepted) or DISABLEd cannot resolve or switch into it. + const membership = await appServer.AppDataSource.getRepository(WorkspaceUser).findOneBy({ + workspaceId: chatflow.workspaceId, + userId, + status: WorkspaceUserStatus.ACTIVE + }) + if (!membership) { + throw notFound() + } + return { workspaceId: chatflow.workspaceId } + } catch (error) { + if (error instanceof InternalFlowiseError) { + throw error + } + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: chatflowsService.resolveChatflowWorkspace - ${getErrorMessage(error)}` + ) + } +} + /** Resolves a chatflow only if it belongs to the given workspace; rejects when workspaceId is missing (prevents unscoped lookup). */ const getChatflowByIdForWorkspace = async (chatflowId: string, workspaceId: string | undefined): Promise => { if (!workspaceId) { @@ -702,6 +749,7 @@ export default { getChatflowByApiKey, getChatflowById, getChatflowByIdForWorkspace, + resolveChatflowWorkspace, saveChatflow, updateChatflow, getSinglePublicChatbotConfig, diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js index ba56a5ff0e0..78e7d7c314b 100644 --- a/packages/ui/src/api/chatflows.js +++ b/packages/ui/src/api/chatflows.js @@ -8,6 +8,10 @@ const getSpecificChatflow = (id) => client.get(`/chatflows/${id}`) const getSpecificChatflowFromPublicEndpoint = (id) => client.get(`/public-chatflows/${id}`) +// Resolve which workspace owns a flow, but only if the current user is a member (404 otherwise). Used to +// auto-switch the active workspace when opening a flow URL that lives in another (member) workspace. +const getChatflowWorkspace = (id) => client.get(`/chatflows/resolve-workspace/${id}`) + const createNewChatflow = (body) => client.post(`/chatflows`, body) const updateChatflow = (id, body) => client.put(`/chatflows/${id}`, body) @@ -39,6 +43,7 @@ export default { getAllAgentflows, getSpecificChatflow, getSpecificChatflowFromPublicEndpoint, + getChatflowWorkspace, createNewChatflow, updateChatflow, deleteChatflow, diff --git a/packages/ui/src/hooks/useFlowWorkspaceAutoSwitch.jsx b/packages/ui/src/hooks/useFlowWorkspaceAutoSwitch.jsx new file mode 100644 index 00000000000..f23bdb1eafc --- /dev/null +++ b/packages/ui/src/hooks/useFlowWorkspaceAutoSwitch.jsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import chatflowsApi from '@/api/chatflows' +import workspaceApi from '@/api/workspace' +import { workspaceSwitchSuccess } from '@/store/reducers/authSlice' + +// When a flow canvas fails to load with a 404 because the flow lives in a workspace the user belongs to but +// isn't their ACTIVE workspace, resolve the flow's owning workspace (membership-gated server-side) and switch +// to it, then let the caller re-fetch — instead of dead-ending on "Chatflow not found in the database!". +// +// Returns an async `tryAutoSwitch(chatflowId, error, onSwitched)`: +// - returns true => it switched workspaces and invoked onSwitched() (caller should suppress its error) +// - returns false => not applicable / not a member; caller should fall back to its normal error handling +// +// Security: the resolver only returns a workspaceId when the user is a member, and we additionally verify the +// id is in the user's assignedWorkspaces before switching — so we never switch into an unauthorized workspace. +export const useFlowWorkspaceAutoSwitch = () => { + const dispatch = useDispatch() + const currentUser = useSelector((state) => state.auth.user) + // Keep the latest user in a ref so tryAutoSwitch can read it without being listed as a useCallback dep. + // This keeps the callback identity stable (callers omit it from their useEffect deps) while still reading + // the freshest user — avoiding a stale closure if the user loads/changes after the callback was created. + const currentUserRef = useRef(currentUser) + useEffect(() => { + currentUserRef.current = currentUser + }, [currentUser]) + + const attemptedRef = useRef(new Set()) + + const tryAutoSwitch = useCallback( + async (chatflowId, error, onSwitched) => { + const status = error?.response?.status + if (status !== 404 || !chatflowId) return false + + // Bail before marking attempted if the user isn't loaded yet, so a later (loaded) call can retry. + const user = currentUserRef.current + if (!user) return false + + // Only attempt once per flow id to avoid any retry loop if something goes sideways. + if (attemptedRef.current.has(chatflowId)) return false + attemptedRef.current.add(chatflowId) + + try { + const res = await chatflowsApi.getChatflowWorkspace(chatflowId) + const workspaceId = res?.data?.workspaceId + if (!workspaceId) return false + if (workspaceId === user.activeWorkspaceId) return false + const assigned = user.assignedWorkspaces || [] + if (!assigned.some((w) => w?.id === workspaceId)) return false + + const switchRes = await workspaceApi.switchWorkspace(workspaceId) + dispatch(workspaceSwitchSuccess(switchRes?.data)) + if (typeof onSwitched === 'function') onSwitched() + return true + } catch (e) { + // A 404 is definitive (not a member / missing flow) — leave it marked so we don't retry. + // For transient failures (network error, 5xx, etc.) clear the mark so a later re-fetch can retry. + if (e?.response?.status !== 404) { + attemptedRef.current.delete(chatflowId) + } + // Fall back to the caller's normal error handling. + return false + } + }, + [dispatch] + ) + + return tryAutoSwitch +} diff --git a/packages/ui/src/views/agentflowsv2/Canvas.jsx b/packages/ui/src/views/agentflowsv2/Canvas.jsx index 962562c28c2..ab60006a541 100644 --- a/packages/ui/src/views/agentflowsv2/Canvas.jsx +++ b/packages/ui/src/views/agentflowsv2/Canvas.jsx @@ -41,6 +41,7 @@ import chatflowsApi from '@/api/chatflows' // Hooks import useApi from '@/hooks/useApi' +import { useFlowWorkspaceAutoSwitch } from '@/hooks/useFlowWorkspaceAutoSwitch' import useConfirm from '@/hooks/useConfirm' // icons @@ -125,6 +126,7 @@ const AgentflowCanvas = () => { const createNewChatflowApi = useApi(chatflowsApi.createNewChatflow) const updateChatflowApi = useApi(chatflowsApi.updateChatflow) const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow) + const tryWorkspaceAutoSwitch = useFlowWorkspaceAutoSwitch() // ==============================|| Events & Actions ||============================== // @@ -546,7 +548,15 @@ const AgentflowCanvas = () => { setEdges(initialFlow.edges || []) dispatch({ type: SET_CHATFLOW, chatflow }) } else if (getSpecificChatflowApi.error) { - errorFailed(`Failed to retrieve ${canvasTitle}: ${getSpecificChatflowApi.error.response.data.message}`) + const error = getSpecificChatflowApi.error + // If the flow lives in another workspace the user is a member of, auto-switch + re-fetch instead + // of dead-ending on "not found". Falls back to the normal error if it's a genuine 404 / non-member. + tryWorkspaceAutoSwitch(chatflowId, error, () => getSpecificChatflowApi.request(chatflowId)).then((handled) => { + if (!handled) { + const errorMsg = error?.response?.data?.message || error?.message || 'Unknown error' + errorFailed(`Failed to retrieve ${canvasTitle}: ${errorMsg}`) + } + }) } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/ui/src/views/canvas/index.jsx b/packages/ui/src/views/canvas/index.jsx index 8835706ecf3..d9c2f807106 100644 --- a/packages/ui/src/views/canvas/index.jsx +++ b/packages/ui/src/views/canvas/index.jsx @@ -34,6 +34,7 @@ import chatflowsApi from '@/api/chatflows' // Hooks import useApi from '@/hooks/useApi' +import { useFlowWorkspaceAutoSwitch } from '@/hooks/useFlowWorkspaceAutoSwitch' import useConfirm from '@/hooks/useConfirm' import { useAuth } from '@/hooks/useAuth' @@ -112,6 +113,7 @@ const Canvas = () => { const createNewChatflowApi = useApi(chatflowsApi.createNewChatflow) const updateChatflowApi = useApi(chatflowsApi.updateChatflow) const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow) + const tryWorkspaceAutoSwitch = useFlowWorkspaceAutoSwitch() const getHasChatflowChangedApi = useApi(chatflowsApi.getHasChatflowChanged) // ==============================|| Events & Actions ||============================== // @@ -420,7 +422,15 @@ const Canvas = () => { setEdges(initialFlow.edges || []) dispatch({ type: SET_CHATFLOW, chatflow }) } else if (getSpecificChatflowApi.error) { - errorFailed(`Failed to retrieve ${canvasTitle}: ${getSpecificChatflowApi.error.response.data.message}`) + const error = getSpecificChatflowApi.error + // If the flow lives in another workspace the user is a member of, auto-switch + re-fetch instead + // of dead-ending on "not found". Falls back to the normal error if it's a genuine 404 / non-member. + tryWorkspaceAutoSwitch(chatflowId, error, () => getSpecificChatflowApi.request(chatflowId)).then((handled) => { + if (!handled) { + const errorMsg = error?.response?.data?.message || error?.message || 'Unknown error' + errorFailed(`Failed to retrieve ${canvasTitle}: ${errorMsg}`) + } + }) } // eslint-disable-next-line react-hooks/exhaustive-deps