diff --git a/packages-answers/utils/src/extractAllCredentials.ts b/packages-answers/utils/src/extractAllCredentials.ts index adb48488d3c..b53142fb31c 100644 --- a/packages-answers/utils/src/extractAllCredentials.ts +++ b/packages-answers/utils/src/extractAllCredentials.ts @@ -1,4 +1,4 @@ -import { getCredentialCategory } from './getCredentialCategory' +import { processFlowCredentials } from './processFlowCredentials' /** * Extract all credentials (assigned and unassigned) from flow data for QuickSetup mode @@ -6,75 +6,10 @@ import { getCredentialCategory } from './getCredentialCategory' * @returns {object} Object containing all credentials info */ export const extractAllCredentials = (flowData: string | any) => { - try { - // Parse flow data if it's a string - const flow = typeof flowData === 'string' ? JSON.parse(flowData) : flowData + const { credentials, hasCredentials } = processFlowCredentials(flowData) - if (!flow.nodes || !Array.isArray(flow.nodes)) { - return { allCredentials: [], hasCredentials: false } - } - - const allCredentials: any[] = [] - const credentialTypes = new Set() - - // Iterate through all nodes - flow.nodes.forEach((node: any) => { - if (node.data && node.data.inputParams) { - // Find all credential input parameters - const credentialParams = node.data.inputParams.filter((param: any) => { - return param.type === 'credential' - }) - - credentialParams.forEach((credentialParam: any) => { - // Check if credential is assigned - const hasCredential = - node.data.credential || - (node.data.inputs && node.data.inputs[credentialParam.name]) || - (node.data.inputs && node.data.inputs['FLOWISE_CREDENTIAL_ID']) - - // Extract credential names - const credentialNames = credentialParam.credentialNames || [] - - credentialNames.forEach((credentialName: string) => { - credentialTypes.add(credentialName) - - // Get category information to determine if credential is core - const category = getCredentialCategory(node.data.category, credentialName) - - // Credential is required if: - // 1. Category is core (Chat Models, Agents, etc), OR - // 2. Parameter explicitly marked as NOT optional (optional: false) - // Default to optional if not explicitly marked - const isRequired = category.isCore || (credentialParam.optional === false) - - const credInfo = { - nodeId: node.id, - nodeName: node.data.name || 'Unknown Node', - nodeCategory: node.data.category || 'Unknown', - nodeType: node.data.type || node.type || 'Unknown', - credentialType: credentialName, - parameterName: credentialParam.name, - label: credentialParam.label || credentialParam.name, - isRequired: isRequired, - isCore: category.isCore, - categoryType: category.type, - categoryDisplayName: category.displayName, - isAssigned: !!hasCredential, - assignedCredentialId: hasCredential || null - } - - allCredentials.push(credInfo) - }) - }) - } - }) - - return { - allCredentials, - hasCredentials: credentialTypes.size > 0 - } - } catch (error) { - console.error('Error in extractAllCredentials:', error) - return { allCredentials: [], hasCredentials: false } + return { + allCredentials: credentials, + hasCredentials } } diff --git a/packages-answers/utils/src/extractMissingCredentials.ts b/packages-answers/utils/src/extractMissingCredentials.ts index 2a9be8ff0e8..9e2773be94c 100644 --- a/packages-answers/utils/src/extractMissingCredentials.ts +++ b/packages-answers/utils/src/extractMissingCredentials.ts @@ -2,7 +2,7 @@ * Utility functions for detecting and managing missing credentials in flows */ -import { getCredentialCategory } from './getCredentialCategory' +import { processFlowCredentials } from './processFlowCredentials' /** * Extract required credentials from flow data and identify missing ones @@ -10,75 +10,14 @@ import { getCredentialCategory } from './getCredentialCategory' * @returns {object} Object containing missing credentials info */ export const extractMissingCredentials = (flowData: string | any) => { - try { - // Parse flow data if it's a string - const flow = typeof flowData === 'string' ? JSON.parse(flowData) : flowData + const { credentials, hasCredentials } = processFlowCredentials(flowData) - if (!flow.nodes || !Array.isArray(flow.nodes)) { - return { missingCredentials: [], hasCredentials: false } - } + const missingCredentials = credentials + .filter((credential) => !credential.isAssigned) + .map(({ isAssigned, assignedCredentialId, ...rest }) => rest) - const missingCredentials: any[] = [] - const credentialTypes = new Set() - - // Iterate through all nodes - flow.nodes.forEach((node: any) => { - if (node.data && node.data.inputParams) { - // Find all credential input parameters - const credentialParams = node.data.inputParams.filter((param: any) => { - return param.type === 'credential' - }) - - credentialParams.forEach((credentialParam: any) => { - // Check if credential is assigned - const hasCredential = - node.data.credential || - (node.data.inputs && node.data.inputs[credentialParam.name]) || - (node.data.inputs && node.data.inputs['FLOWISE_CREDENTIAL_ID']) - - if (!hasCredential) { - // Extract credential names - const credentialNames = credentialParam.credentialNames || [] - - credentialNames.forEach((credentialName: string) => { - credentialTypes.add(credentialName) - - // Get category information to determine if credential is core - const category = getCredentialCategory(node.data.category, credentialName) - - // Credential is required if: - // 1. Category is core (Chat Models, Agents, etc), OR - // 2. Parameter explicitly marked as NOT optional (optional: false) - // Default to optional if not explicitly marked - const isRequired = category.isCore || (credentialParam.optional === false) - - const missingCred = { - nodeId: node.id, - nodeName: node.data.name || 'Unknown Node', - nodeCategory: node.data.category || 'Unknown', - nodeType: node.data.type || node.type || 'Unknown', - credentialType: credentialName, - parameterName: credentialParam.name, - label: credentialParam.label || credentialParam.name, - isRequired: isRequired, - isCore: category.isCore, - categoryType: category.type, - categoryDisplayName: category.displayName - } - - missingCredentials.push(missingCred) - }) - } - }) - } - }) - - return { - missingCredentials, - hasCredentials: credentialTypes.size > 0 - } - } catch (error) { - console.error('Error in extractMissingCredentials:', error) - return { missingCredentials: [], hasCredentials: false } + return { + missingCredentials, + hasCredentials } } diff --git a/packages-answers/utils/src/processFlowCredentials.ts b/packages-answers/utils/src/processFlowCredentials.ts new file mode 100644 index 00000000000..a5bbcf445b3 --- /dev/null +++ b/packages-answers/utils/src/processFlowCredentials.ts @@ -0,0 +1,82 @@ +import { getCredentialCategory } from './getCredentialCategory' + +type FlowData = string | Record + +export type FlowCredentialRecord = { + nodeId: string + nodeName: string + nodeCategory: string + nodeType: string + credentialType: string + parameterName: string + label: string + isRequired: boolean + isCore: boolean + categoryType: string + categoryDisplayName: string + isAssigned: boolean + assignedCredentialId: string | null +} + +type ProcessResult = { + credentials: FlowCredentialRecord[] + hasCredentials: boolean +} + +export const processFlowCredentials = (flowData: FlowData): ProcessResult => { + try { + const flow = typeof flowData === 'string' ? JSON.parse(flowData) : flowData + + if (!flow || !Array.isArray(flow.nodes)) { + return { credentials: [], hasCredentials: false } + } + + const credentials: FlowCredentialRecord[] = [] + const credentialTypes = new Set() + + flow.nodes.forEach((node: any) => { + const inputParams = node?.data?.inputParams + if (!Array.isArray(inputParams)) return + + const credentialParams = inputParams.filter((param: any) => param?.type === 'credential') + + credentialParams.forEach((credentialParam: any) => { + const inputs = node?.data?.inputs + const hasCredential = + node?.data?.credential || (inputs && (inputs[credentialParam.name] || inputs['FLOWISE_CREDENTIAL_ID'])) + + const credentialNames: string[] = credentialParam?.credentialNames || [] + + credentialNames.forEach((credentialName: string) => { + credentialTypes.add(credentialName) + + const category = getCredentialCategory(node?.data?.category, credentialName) + const isRequired = category.isCore || credentialParam?.optional === false + + credentials.push({ + nodeId: node?.id, + nodeName: node?.data?.name || 'Unknown Node', + nodeCategory: node?.data?.category || 'Unknown', + nodeType: node?.data?.type || node?.type || 'Unknown', + credentialType: credentialName, + parameterName: credentialParam?.name, + label: credentialParam?.label || credentialParam?.name, + isRequired, + isCore: category.isCore, + categoryType: category.type, + categoryDisplayName: category.displayName, + isAssigned: !!hasCredential, + assignedCredentialId: hasCredential || null + }) + }) + }) + }) + + return { + credentials, + hasCredentials: credentialTypes.size > 0 + } + } catch (_error) { + return { credentials: [], hasCredentials: false } + } +} diff --git a/packages/ui/src/hooks/useFlowCredentials.ts b/packages/ui/src/hooks/useFlowCredentials.ts new file mode 100644 index 00000000000..d038a261938 --- /dev/null +++ b/packages/ui/src/hooks/useFlowCredentials.ts @@ -0,0 +1,157 @@ +import { useCallback, useRef, useState } from 'react' +import nodesApi from '@/api/nodes' +import { collectFlowCredentials, updateFlowDataWithCredentials } from '@/utils/flowCredentialsHelper' +import { getCredentialModalDismissed, setCredentialModalDismissed } from '@/utils/credentialModalPreference' + +const nodeDefinitionCache = new Map() + +const fetchNodeDefinition = async (nodeName: string) => { + if (!nodeName) return null + if (nodeDefinitionCache.has(nodeName)) return nodeDefinitionCache.get(nodeName) + try { + const response = await nodesApi.getSpecificNode(nodeName) + const nodeDefinition = response?.data || null + if (nodeDefinition) { + nodeDefinitionCache.set(nodeName, nodeDefinition) + } + return nodeDefinition + } catch (_error) { + return null + } +} + +const resetState = (setShowModal: any, setCredentialList: any, setAllCredentialsState: any, setInitialDontShowAgain: any) => { + setShowModal(false) + setCredentialList([]) + setAllCredentialsState([]) + setInitialDontShowAgain(false) +} + +const buildCallbackPayload = (flowState: any, evaluation: any, mode: string) => ({ + flowData: flowState?.raw ?? null, + parsedFlow: flowState?.parsed ?? null, + allCredentials: evaluation?.allCredentials ?? [], + missingCredentials: evaluation?.missingCredentials ?? [], + mode +}) + +export const useFlowCredentials = () => { + const [showCredentialModal, setShowCredentialModal] = useState(false) + const [credentialList, setCredentialList] = useState([]) + const [allCredentialsState, setAllCredentialsState] = useState([]) + const [initialDontShowAgain, setInitialDontShowAgain] = useState(false) + + const preferenceScopeRef = useRef(null) + const callbacksRef = useRef({ onAssign: null, onSkip: null, onCancel: null }) + const flowRef = useRef({ raw: null, parsed: null, format: 'object' }) + const evaluationRef = useRef({ allCredentials: [], missingCredentials: [] }) + const modeRef = useRef('missing') + + const openCredentialModal = useCallback(async (flowData: any, options: any = {}) => { + const { preferenceScope = null, onAssign, onSkip, onCancel, forceShow = false, mode = 'missing' } = options + + const evaluation = await collectFlowCredentials(flowData, { fetchNodeDefinition }) + evaluationRef.current = evaluation + + const listForModal = mode === 'all' ? evaluation.allCredentials : evaluation.missingCredentials + const hasItems = listForModal.length > 0 + const preferenceDismissed = preferenceScope ? getCredentialModalDismissed(preferenceScope) : false + + if (!forceShow && (!hasItems || preferenceDismissed)) { + return { shown: false, evaluation } + } + + const isStringInput = typeof flowData === 'string' + const parsedFlow = isStringInput ? JSON.parse(flowData) : { ...flowData } + + flowRef.current = { + raw: isStringInput ? flowData : parsedFlow, + parsed: parsedFlow, + format: isStringInput ? 'string' : 'object' + } + + callbacksRef.current = { onAssign, onSkip, onCancel } + preferenceScopeRef.current = preferenceScope + modeRef.current = mode + + setCredentialList(listForModal) + setAllCredentialsState(evaluation.allCredentials) + setInitialDontShowAgain(preferenceScope ? preferenceDismissed : false) + setShowCredentialModal(true) + + return { shown: true, evaluation } + }, []) + + const applyPreference = (options: any) => { + const scope = preferenceScopeRef.current + if (!scope) return + if (options?.dontShowDirty) { + setCredentialModalDismissed(scope, !!options.dontShowAgain) + } + } + + const handleAssign = useCallback((credentialAssignments: any, options: any = {}) => { + applyPreference(options) + + const currentFlow = flowRef.current + const format = currentFlow.format + const source = format === 'string' ? currentFlow.raw : currentFlow.parsed + + let nextRaw = currentFlow.raw + let nextParsed = currentFlow.parsed + + if (credentialAssignments && Object.keys(credentialAssignments).length > 0) { + const updatedFlow = updateFlowDataWithCredentials(source, credentialAssignments) + nextParsed = updatedFlow + nextRaw = format === 'string' ? JSON.stringify(updatedFlow) : updatedFlow + } + + flowRef.current = { + raw: nextRaw, + parsed: nextParsed, + format + } + + callbacksRef.current.onAssign?.( + nextRaw, + credentialAssignments, + buildCallbackPayload(flowRef.current, evaluationRef.current, modeRef.current) + ) + + resetState(setShowCredentialModal, setCredentialList, setAllCredentialsState, setInitialDontShowAgain) + preferenceScopeRef.current = null + callbacksRef.current = { onAssign: null, onSkip: null, onCancel: null } + }, []) + + const handleSkip = useCallback((options: any = {}) => { + applyPreference(options) + callbacksRef.current.onSkip?.(flowRef.current.raw, buildCallbackPayload(flowRef.current, evaluationRef.current, modeRef.current)) + + resetState(setShowCredentialModal, setCredentialList, setAllCredentialsState, setInitialDontShowAgain) + preferenceScopeRef.current = null + callbacksRef.current = { onAssign: null, onSkip: null, onCancel: null } + }, []) + + const handleCancel = useCallback((options: any = {}) => { + applyPreference(options) + callbacksRef.current.onCancel?.(flowRef.current.raw, buildCallbackPayload(flowRef.current, evaluationRef.current, modeRef.current)) + + resetState(setShowCredentialModal, setCredentialList, setAllCredentialsState, setInitialDontShowAgain) + preferenceScopeRef.current = null + callbacksRef.current = { onAssign: null, onSkip: null, onCancel: null } + }, []) + + return { + showCredentialModal, + missingCredentials: credentialList, + allCredentials: allCredentialsState, + initialDontShowAgain, + modalMode: modeRef.current, + openCredentialModal, + handleAssign, + handleSkip, + handleCancel + } +} + +export default useFlowCredentials diff --git a/packages/ui/src/utils/flowCredentialsHelper.js b/packages/ui/src/utils/flowCredentialsHelper.js index 4078a39f3ad..eeb9fac4e0e 100644 --- a/packages/ui/src/utils/flowCredentialsHelper.js +++ b/packages/ui/src/utils/flowCredentialsHelper.js @@ -5,10 +5,36 @@ import { extractMissingCredentials } from '@utils/extractMissingCredentials' import { extractAllCredentials } from '@utils/extractAllCredentials' import { getCredentialCategory } from '@utils/getCredentialCategory' +import { processFlowCredentials } from '@utils/processFlowCredentials' // Re-export for convenience export { extractMissingCredentials, extractAllCredentials, getCredentialCategory } +/** + * Collect all credential information from flow data (both missing and all credentials) + * This is an async wrapper that combines extractMissingCredentials and extractAllCredentials + * @param {string|object} flowData - Flow data as JSON string or object + * @returns {Promise} Object containing missingCredentials and allCredentials arrays + */ +export const collectFlowCredentials = async (flowData) => { + try { + const { credentials } = processFlowCredentials(flowData) + const missingCredentials = credentials + .filter((credential) => !credential.isAssigned) + .map(({ isAssigned, assignedCredentialId, ...rest }) => rest) + + return { + missingCredentials, + allCredentials: credentials + } + } catch (_error) { + return { + missingCredentials: [], + allCredentials: [] + } + } +} + /** * Convert camelCase or PascalCase string to sentence case * @param {string} str - String in camelCase or PascalCase @@ -270,18 +296,18 @@ export const isValidCredentialAssignment = (node, credentialId, availableCredent */ export const organizeCredentialsByPriority = (groupedCredentials) => { const sections = { - required: [], // Unconnected required credentials - optional: [], // Unconnected optional credentials - connected: [] // All connected credentials + required: [], // Unconnected required credentials + optional: [], // Unconnected optional credentials + connected: [] // All connected credentials } Object.entries(groupedCredentials).forEach(([groupKey, group]) => { const isConnected = group.isAssigned || false - + // Check if ANY node in this group is marked as required // isRequired is calculated as: category.isCore || !credentialParam.optional const isRequired = group.nodes?.some((node) => node.isRequired === true) || false - + // Also track isCore for sorting connected credentials const isCore = group.nodes?.some((node) => node.isCore === true) || false @@ -308,7 +334,7 @@ export const organizeCredentialsByPriority = (groupedCredentials) => { // Sort functions const sortByLabel = (a, b) => (a.label || '').localeCompare(b.label || '') - + // Sort connected section: required first, then alphabetically within each group const sortConnected = (a, b) => { // Required credentials before optional diff --git a/packages/ui/src/views/agentflowsv2/Canvas.jsx b/packages/ui/src/views/agentflowsv2/Canvas.jsx index 3203dbe0316..873e858b955 100644 --- a/packages/ui/src/views/agentflowsv2/Canvas.jsx +++ b/packages/ui/src/views/agentflowsv2/Canvas.jsx @@ -32,6 +32,8 @@ import EditNodeDialog from '@/views/agentflowsv2/EditNodeDialog' import ChatPopUp from '@/views/chatmessage/ChatPopUp' import ValidationPopUp from '@/views/chatmessage/ValidationPopUp' import { flowContext } from '@/store/context/ReactFlowContext' +import useFlowCredentials from '@/hooks/useFlowCredentials' +import UnifiedCredentialsModal from '@/ui-component/dialog/UnifiedCredentialsModal' // API import nodesApi from '@/api/nodes' @@ -101,6 +103,19 @@ const AgentflowCanvas = () => { const [editNodeDialogOpen, setEditNodeDialogOpen] = useState(false) const [editNodeDialogProps, setEditNodeDialogProps] = useState({}) + const { + showCredentialModal, + missingCredentials, + allCredentials, + modalMode, + initialDontShowAgain, + openCredentialModal, + handleAssign, + handleSkip, + handleCancel + } = useFlowCredentials() + const hasPromptedCredentialsRef = useRef(false) + const reactFlowWrapper = useRef(null) // ==============================|| Chatflow API ||============================== // @@ -163,8 +178,11 @@ const AgentflowCanvas = () => { setNodes(nodes) setEdges(flowData.edges || []) setTimeout(() => setDirty(), 0) + + // Credential modal will be triggered automatically after save via useEffect + // watching canvasDataStore.chatflow.flowData (lines 585-593) } catch (e) { - console.error(e) + errorFailed('Failed to load flow data. Please verify the file content.') } } @@ -528,6 +546,10 @@ const AgentflowCanvas = () => { const initialFlow = chatflow.flowData ? JSON.parse(chatflow.flowData) : [] setNodes(initialFlow.nodes || []) setEdges(initialFlow.edges || []) + // Allow modal to check for missing credentials when loading existing flow + // Modal will show immediately if credentials are missing (unless user dismissed it) + // MUST set this BEFORE dispatch to allow modal trigger + hasPromptedCredentialsRef.current = false dispatch({ type: SET_CHATFLOW, chatflow }) } else if (getSpecificChatflowApi.error) { errorFailed(`Failed to retrieve ${canvasTitle}: ${getSpecificChatflowApi.error.response.data.message}`) @@ -540,6 +562,9 @@ const AgentflowCanvas = () => { useEffect(() => { if (createNewChatflowApi.data) { const chatflow = createNewChatflowApi.data + // Reset to allow modal to show after first save + // MUST set this BEFORE dispatch to allow modal trigger + hasPromptedCredentialsRef.current = false dispatch({ type: SET_CHATFLOW, chatflow }) saveChatflowSuccess() window.history.replaceState(state, null, `/v2/agentcanvas/${chatflow.id}`) @@ -553,6 +578,9 @@ const AgentflowCanvas = () => { // Update chatflow successful useEffect(() => { if (updateChatflowApi.data) { + // Reset to allow modal to check for missing credentials after update + // MUST set this BEFORE dispatch to allow modal trigger + hasPromptedCredentialsRef.current = false dispatch({ type: SET_CHATFLOW, chatflow: updateChatflowApi.data }) saveChatflowSuccess() } else if (updateChatflowApi.error) { @@ -572,8 +600,83 @@ const AgentflowCanvas = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [canvasDataStore.chatflow]) + // Only show credential modal for flows that have been saved to database (have an ID) + // This prevents modal from showing during template load (before save) + useEffect(() => { + const currentChatflow = canvasDataStore.chatflow + if (!reactFlowInstance) return + if (!currentChatflow?.flowData || !currentChatflow?.id || hasPromptedCredentialsRef.current) return + const canManageFlow = + currentChatflow.canEdit === undefined && currentChatflow.isOwner === undefined + ? true + : currentChatflow.canEdit ?? currentChatflow.isOwner ?? false + if (!canManageFlow) return + + hasPromptedCredentialsRef.current = true + const preferenceScope = `flow:${currentChatflow.id}` + openCredentialModal(currentChatflow.flowData, { preferenceScope }).catch((error) => { + if (process.env.NODE_ENV === 'development') { + console.error('[Canvas] Failed to open credential modal:', error) + } + }) + }, [ + canvasDataStore.chatflow?.flowData, + canvasDataStore.chatflow?.id, + canvasDataStore.chatflow?.canEdit, + canvasDataStore.chatflow?.isOwner, + openCredentialModal, + reactFlowInstance + ]) + + // Handle QuickSetup URL parameter - opens modal in "manage credentials" mode + useEffect(() => { + const checkQuickSetupParam = () => { + const urlParams = new URLSearchParams(window.location.search) + const isQuickSetup = urlParams.get('QuickSetup') === 'true' + + const canManageFlow = + canvasDataStore.chatflow?.canEdit === undefined && canvasDataStore.chatflow?.isOwner === undefined + ? true + : canvasDataStore.chatflow?.canEdit ?? canvasDataStore.chatflow?.isOwner ?? false + + if (isQuickSetup && reactFlowInstance && canvasDataStore.chatflow?.flowData && canvasDataStore.chatflow?.id && canManageFlow) { + // Remove QuickSetup parameter from URL + urlParams.delete('QuickSetup') + const newUrl = `${window.location.pathname}${urlParams.toString() ? '?' + urlParams.toString() : ''}` + window.history.replaceState({}, '', newUrl) + + // Open modal in "all credentials" mode with forceShow + const preferenceScope = `flow:${canvasDataStore.chatflow.id}` + openCredentialModal(canvasDataStore.chatflow.flowData, { + preferenceScope, + mode: 'all', + forceShow: true + }).catch((error) => { + if (process.env.NODE_ENV === 'development') { + console.error('[Canvas] Failed to open credential modal (QuickSetup):', error) + } + }) + } + } + + checkQuickSetupParam() + + // Listen for popstate events (triggered by the QuickSetup button) + window.addEventListener('popstate', checkQuickSetupParam) + return () => window.removeEventListener('popstate', checkQuickSetupParam) + }, [ + canvasDataStore.chatflow?.flowData, + canvasDataStore.chatflow?.id, + canvasDataStore.chatflow?.canEdit, + canvasDataStore.chatflow?.isOwner, + openCredentialModal, + reactFlowInstance + ]) + // Initialization useEffect(() => { + // DO NOT reset hasPromptedCredentialsRef here - it prevents duplicate modals + // The ref is managed by the credential modal useEffect and save handlers setIsSyncNodesButtonEnabled(false) if (chatflowId) { getSpecificChatflowApi.request(chatflowId) @@ -585,6 +688,10 @@ const AgentflowCanvas = () => { setNodes([]) setEdges([]) } + // Block credential modal until user saves + // Modal should only show AFTER user clicks save + // MUST set this BEFORE dispatch to prevent modal trigger + hasPromptedCredentialsRef.current = true dispatch({ type: SET_CHATFLOW, chatflow: { @@ -613,6 +720,9 @@ const AgentflowCanvas = () => { //TODO: prevent paste event when input focused, temporary fix: catch chatflow syntax if (pasteData.includes('{"nodes":[') && pasteData.includes('],"edges":[')) { handleLoadFlow(pasteData) + // Block credential modal until user saves + // Modal should only show AFTER user clicks save + hasPromptedCredentialsRef.current = true } } @@ -628,6 +738,9 @@ const AgentflowCanvas = () => { useEffect(() => { if (templateFlowData && templateFlowData.includes('"nodes":[') && templateFlowData.includes('],"edges":[')) { handleLoadFlow(templateFlowData) + // Block credential modal until user saves + // Modal should only show AFTER user clicks save + hasPromptedCredentialsRef.current = true } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -779,6 +892,17 @@ const AgentflowCanvas = () => { + + ) } diff --git a/packages/ui/src/views/canvas/index.jsx b/packages/ui/src/views/canvas/index.jsx index 12c377559f3..299f19ef8ab 100644 --- a/packages/ui/src/views/canvas/index.jsx +++ b/packages/ui/src/views/canvas/index.jsx @@ -58,7 +58,7 @@ import { usePrompt } from '@/utils/usePrompt' import { FLOWISE_CREDENTIAL_ID } from '@/store/constant' // credential checking -import { useCredentialChecker } from '@/hooks/useCredentialChecker' +import useFlowCredentials from '@/hooks/useFlowCredentials' import UnifiedCredentialsModal from '@/ui-component/dialog/UnifiedCredentialsModal' const nodeTypes = { customNode: CanvasNode, stickyNote: StickyNote } const edgeTypes = { buttonedge: ButtonEdge } @@ -113,12 +113,15 @@ const Canvas = ({ chatflowid: chatflowId }) => { const { showCredentialModal, missingCredentials, - checkCredentials, + allCredentials, + modalMode, initialDontShowAgain, + openCredentialModal, handleAssign, handleSkip, handleCancel - } = useCredentialChecker() + } = useFlowCredentials() + const hasPromptedCredentialsRef = useRef(false) // ==============================|| Chatflow API ||============================== // @@ -233,6 +236,11 @@ const Canvas = ({ chatflowid: chatflowId }) => { flowData: JSON.stringify({ nodes, edges }) } + // Block credential modal from showing until user saves + // Modal should only show AFTER user clicks save + // MUST set this BEFORE dispatch to prevent modal trigger + hasPromptedCredentialsRef.current = true + dispatch({ type: SET_CHATFLOW, chatflow: newChatflow }) setChatflow(newChatflow) setNodes(nodes) @@ -560,6 +568,10 @@ const Canvas = ({ chatflowid: chatflowId }) => { const initialFlow = chatflow.flowData ? JSON.parse(chatflow.flowData) : [] setNodes(initialFlow.nodes || []) setEdges(initialFlow.edges || []) + // Allow modal to check for missing credentials when loading existing flow + // Modal will show immediately if credentials are missing (unless user dismissed it) + // MUST set this BEFORE dispatch to allow modal trigger + hasPromptedCredentialsRef.current = false dispatch({ type: SET_CHATFLOW, chatflow }) } else if (getSpecificChatflowApi?.error?.response?.data?.message) { errorFailed(`Failed to save ${canvasTitle}: ${getSpecificChatflowApi.error.response.data.message}`) @@ -575,6 +587,9 @@ const Canvas = ({ chatflowid: chatflowId }) => { if (createNewChatflowApi?.data) { const chatflow = createNewChatflowApi.data // console.log('✅ Create new chatflow successful, navigating to:', `/${isAgentCanvas ? 'agentcanvas' : 'canvas'}/${chatflow.id}`) + // Reset to allow modal to show after first save + // MUST set this BEFORE dispatch to allow modal trigger + hasPromptedCredentialsRef.current = false dispatch({ type: SET_CHATFLOW, chatflow }) saveChatflowSuccess() navigate(`/${isAgentCanvas ? 'agentcanvas' : 'canvas'}/${chatflow.id}`, { @@ -593,6 +608,9 @@ const Canvas = ({ chatflowid: chatflowId }) => { useEffect(() => { if (updateChatflowApi?.data) { // console.log('✅ Update chatflow successful, NOT navigating (should we?)') + // Reset to allow modal to check for missing credentials after update + // MUST set this BEFORE dispatch to allow modal trigger + hasPromptedCredentialsRef.current = false dispatch({ type: SET_CHATFLOW, chatflow: updateChatflowApi.data }) saveChatflowSuccess() } else if (updateChatflowApi?.error?.response?.data?.message) { @@ -615,10 +633,59 @@ const Canvas = ({ chatflowid: chatflowId }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [canvasDataStore.chatflow]) + // Only show credential modal for flows that have been saved to database (have an ID) + // This prevents modal from showing during template load (before save) + useEffect(() => { + if (canvasDataStore.chatflow?.flowData && canvasDataStore.chatflow?.id && !hasPromptedCredentialsRef.current) { + hasPromptedCredentialsRef.current = true + const preferenceScope = canvasDataStore.chatflow?.id ? `flow:${canvasDataStore.chatflow.id}` : null + openCredentialModal(canvasDataStore.chatflow.flowData, { preferenceScope }).catch((error) => { + if (process.env.NODE_ENV === 'development') { + console.error('[Canvas] Failed to open credential modal:', error) + } + }) + } + }, [canvasDataStore.chatflow?.flowData, canvasDataStore.chatflow?.id, openCredentialModal]) + + // Handle QuickSetup URL parameter - opens modal in "manage credentials" mode + useEffect(() => { + const checkQuickSetupParam = () => { + const urlParams = new URLSearchParams(window.location.search) + const isQuickSetup = urlParams.get('QuickSetup') === 'true' + + if (isQuickSetup && canvasDataStore.chatflow?.flowData && canvasDataStore.chatflow?.id) { + // Remove QuickSetup parameter from URL + urlParams.delete('QuickSetup') + const newUrl = `${window.location.pathname}${urlParams.toString() ? '?' + urlParams.toString() : ''}` + window.history.replaceState({}, '', newUrl) + + // Open modal in "all credentials" mode with forceShow + const preferenceScope = `flow:${canvasDataStore.chatflow.id}` + openCredentialModal(canvasDataStore.chatflow.flowData, { + preferenceScope, + mode: 'all', + forceShow: true + }).catch((error) => { + if (process.env.NODE_ENV === 'development') { + console.error('[Canvas] Failed to open credential modal (QuickSetup):', error) + } + }) + } + } + + checkQuickSetupParam() + + // Listen for popstate events (triggered by the QuickSetup button) + window.addEventListener('popstate', checkQuickSetupParam) + return () => window.removeEventListener('popstate', checkQuickSetupParam) + }, [canvasDataStore.chatflow?.flowData, canvasDataStore.chatflow?.id, openCredentialModal]) + // Initialization useEffect(() => { // console.log('🎨 Canvas initialization effect running:', { chatflowId }) + // DO NOT reset hasPromptedCredentialsRef here - it prevents duplicate modals + // The ref is managed by the credential modal useEffect and save handlers setIsSyncNodesButtonEnabled(false) setIsUpsertButtonEnabled(false) if (chatflowId) { @@ -648,6 +715,12 @@ const Canvas = ({ chatflowid: chatflowId }) => { deployed: false, isPublic: false } + + // Block credential modal from showing until user saves + // Modal should only show AFTER user clicks save + // MUST set this BEFORE dispatch to prevent modal trigger + hasPromptedCredentialsRef.current = true + setChatflow(newChatflow) dispatch({ type: SET_CHATFLOW, chatflow: newChatflow }) @@ -664,6 +737,9 @@ const Canvas = ({ chatflowid: chatflowId }) => { // console.log('🎨 No duplicated flow data, creating blank canvas') setNodes([]) setEdges([]) + // Block credential modal for blank canvas until user saves + // MUST set this BEFORE dispatch to prevent modal trigger + hasPromptedCredentialsRef.current = true setChatflow({ name: templateName ? `Copy of ${templateName}` : `Untitled ${canvasTitle}` }) @@ -816,10 +892,11 @@ const Canvas = ({ chatflowid: chatflowId }) => { diff --git a/packages/ui/src/views/marketplaces/MarketplaceCanvas.jsx b/packages/ui/src/views/marketplaces/MarketplaceCanvas.jsx index 0ec1b4562de..92595ddf550 100644 --- a/packages/ui/src/views/marketplaces/MarketplaceCanvas.jsx +++ b/packages/ui/src/views/marketplaces/MarketplaceCanvas.jsx @@ -15,10 +15,6 @@ import MarketplaceCanvasHeader from './MarketplaceCanvasHeader' import StickyNote from '../canvas/StickyNote' import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' -// credential checking -import { useCredentialChecker } from '@/hooks/useCredentialChecker' -import UnifiedCredentialsModal from '@/ui-component/dialog/UnifiedCredentialsModal' - const nodeTypes = { customNode: MarketplaceCanvasNode, stickyNote: StickyNote } const edgeTypes = { buttonedge: '' } @@ -38,17 +34,6 @@ const MarketplaceCanvas = () => { const reactFlowWrapper = useRef(null) - // Credential checking hook - const { - showCredentialModal, - missingCredentials, - checkCredentials, - initialDontShowAgain, - handleAssign, - handleSkip, - handleCancel - } = useCredentialChecker() - // ==============================|| useEffect ||============================== // useEffect(() => { @@ -57,8 +42,6 @@ const MarketplaceCanvas = () => { setNodes(initialFlow.nodes || []) setEdges(initialFlow.edges || []) } - - // eslint-disable-next-line react-hooks/exhaustive-deps }, [flowData]) const onChatflowCopy = (stateData) => { @@ -138,17 +121,6 @@ const MarketplaceCanvas = () => { {/* Confirm Dialog */} - - {/* Unified Credentials Modal */} - ) }