From 413d654493df40924332666e6dd8a55587beb17c Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 15 Jul 2023 14:25:52 +0100 Subject: [PATCH] add credentials --- .../AzureChatOpenAI/AzureChatOpenAI.ts | 48 +-- .../AzureOpenAIApi.credential.ts | 45 +++ .../ChatAnthropic/AnthropicApi.credential.ts | 21 ++ .../chatmodels/ChatAnthropic/ChatAnthropic.ts | 22 +- .../nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts | 22 +- .../ChatOpenAI/OpenAIApi.credential.ts | 21 ++ .../components/nodes/llms/OpenAI/OpenAI.ts | 22 +- .../memory/MotorheadMemory/MotorheadMemory.ts | 33 +-- .../MotorheadMemoryApi.credential.ts | 29 ++ .../nodes/memory/ZepMemory/ZepMemory.ts | 23 +- .../ZepMemory/ZepMemoryApi.credential.ts | 24 ++ packages/components/src/Interface.ts | 10 + packages/components/src/utils.ts | 86 ++++++ packages/server/package.json | 2 + packages/server/src/ChildProcess.ts | 3 +- packages/server/src/DataSource.ts | 3 +- packages/server/src/Interface.ts | 27 ++ packages/server/src/NodesPool.ts | 48 ++- packages/server/src/entity/Credential.ts | 24 ++ packages/server/src/index.ts | 155 +++++++++- packages/server/src/utils/index.ts | 156 +++++++++- packages/server/src/utils/logger.ts | 3 +- packages/ui/src/api/credentials.js | 28 ++ .../ui/src/assets/images/credential_empty.svg | 1 + packages/ui/src/menu-items/dashboard.js | 12 +- packages/ui/src/routes/MainRoutes.js | 9 +- packages/ui/src/store/constant.js | 1 + .../ui-component/dropdown/AsyncDropdown.js | 35 ++- packages/ui/src/ui-component/input/Input.js | 2 +- packages/ui/src/utils/genericHelper.js | 19 +- .../views/canvas/CredentialInputHandler.js | 149 ++++++++++ .../ui/src/views/canvas/NodeInputHandler.js | 16 + packages/ui/src/views/canvas/index.js | 26 +- .../credentials/AddEditCredentialDialog.js | 276 ++++++++++++++++++ .../credentials/CredentialInputHandler.js | 137 +++++++++ .../views/credentials/CredentialListDialog.js | 172 +++++++++++ packages/ui/src/views/credentials/index.js | 274 +++++++++++++++++ 37 files changed, 1858 insertions(+), 126 deletions(-) create mode 100644 packages/components/nodes/chatmodels/AzureChatOpenAI/AzureOpenAIApi.credential.ts create mode 100644 packages/components/nodes/chatmodels/ChatAnthropic/AnthropicApi.credential.ts create mode 100644 packages/components/nodes/chatmodels/ChatOpenAI/OpenAIApi.credential.ts create mode 100644 packages/components/nodes/memory/MotorheadMemory/MotorheadMemoryApi.credential.ts create mode 100644 packages/components/nodes/memory/ZepMemory/ZepMemoryApi.credential.ts create mode 100644 packages/server/src/entity/Credential.ts create mode 100644 packages/ui/src/api/credentials.js create mode 100644 packages/ui/src/assets/images/credential_empty.svg create mode 100644 packages/ui/src/views/canvas/CredentialInputHandler.js create mode 100644 packages/ui/src/views/credentials/AddEditCredentialDialog.js create mode 100644 packages/ui/src/views/credentials/CredentialInputHandler.js create mode 100644 packages/ui/src/views/credentials/CredentialListDialog.js create mode 100644 packages/ui/src/views/credentials/index.js diff --git a/packages/components/nodes/chatmodels/AzureChatOpenAI/AzureChatOpenAI.ts b/packages/components/nodes/chatmodels/AzureChatOpenAI/AzureChatOpenAI.ts index 2cdb505d6..0bff883fc 100644 --- a/packages/components/nodes/chatmodels/AzureChatOpenAI/AzureChatOpenAI.ts +++ b/packages/components/nodes/chatmodels/AzureChatOpenAI/AzureChatOpenAI.ts @@ -1,6 +1,6 @@ import { OpenAIBaseInput } from 'langchain/dist/types/openai-types' -import { INode, INodeData, INodeParams } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' +import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' import { AzureOpenAIInput, ChatOpenAI } from 'langchain/chat_models/openai' class AzureChatOpenAI_ChatModels implements INode { @@ -11,6 +11,7 @@ class AzureChatOpenAI_ChatModels implements INode { category: string description: string baseClasses: string[] + credential: INodeParams inputs: INodeParams[] constructor() { @@ -21,12 +22,13 @@ class AzureChatOpenAI_ChatModels implements INode { this.category = 'Chat Models' this.description = 'Wrapper around Azure OpenAI large language models that use the Chat endpoint' this.baseClasses = [this.type, ...getBaseClasses(ChatOpenAI)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['azureOpenAIApi'] + } this.inputs = [ - { - label: 'Azure OpenAI Api Key', - name: 'azureOpenAIApiKey', - type: 'password' - }, { label: 'Model Name', name: 'modelName', @@ -59,26 +61,6 @@ class AzureChatOpenAI_ChatModels implements INode { default: 0.9, optional: true }, - { - label: 'Azure OpenAI Api Instance Name', - name: 'azureOpenAIApiInstanceName', - type: 'string', - placeholder: 'YOUR-INSTANCE-NAME' - }, - { - label: 'Azure OpenAI Api Deployment Name', - name: 'azureOpenAIApiDeploymentName', - type: 'string', - placeholder: 'YOUR-DEPLOYMENT-NAME' - }, - { - label: 'Azure OpenAI Api Version', - name: 'azureOpenAIApiVersion', - type: 'string', - placeholder: '2023-06-01-preview', - description: - 'Description of Supported API Versions. Please refer examples' - }, { label: 'Max Tokens', name: 'maxTokens', @@ -110,19 +92,21 @@ class AzureChatOpenAI_ChatModels implements INode { ] } - async init(nodeData: INodeData): Promise { - const azureOpenAIApiKey = nodeData.inputs?.azureOpenAIApiKey as string + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const modelName = nodeData.inputs?.modelName as string const temperature = nodeData.inputs?.temperature as string - const azureOpenAIApiInstanceName = nodeData.inputs?.azureOpenAIApiInstanceName as string - const azureOpenAIApiDeploymentName = nodeData.inputs?.azureOpenAIApiDeploymentName as string - const azureOpenAIApiVersion = nodeData.inputs?.azureOpenAIApiVersion as string const maxTokens = nodeData.inputs?.maxTokens as string const frequencyPenalty = nodeData.inputs?.frequencyPenalty as string const presencePenalty = nodeData.inputs?.presencePenalty as string const timeout = nodeData.inputs?.timeout as string const streaming = nodeData.inputs?.streaming as boolean + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const azureOpenAIApiKey = getCredentialParam('azureOpenAIApiKey', credentialData, nodeData) + const azureOpenAIApiInstanceName = getCredentialParam('azureOpenAIApiInstanceName', credentialData, nodeData) + const azureOpenAIApiDeploymentName = getCredentialParam('azureOpenAIApiDeploymentName', credentialData, nodeData) + const azureOpenAIApiVersion = getCredentialParam('azureOpenAIApiVersion', credentialData, nodeData) + const obj: Partial & Partial = { temperature: parseFloat(temperature), modelName, diff --git a/packages/components/nodes/chatmodels/AzureChatOpenAI/AzureOpenAIApi.credential.ts b/packages/components/nodes/chatmodels/AzureChatOpenAI/AzureOpenAIApi.credential.ts new file mode 100644 index 000000000..d48e0c889 --- /dev/null +++ b/packages/components/nodes/chatmodels/AzureChatOpenAI/AzureOpenAIApi.credential.ts @@ -0,0 +1,45 @@ +import { INodeParams, INodeCredential } from '../../../src/Interface' + +class AzureOpenAIApi implements INodeCredential { + label: string + name: string + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'Azure OpenAI API' + this.name = 'azureOpenAIApi' + this.description = + 'Refer to official guide of how to use Azure OpenAI service' + this.inputs = [ + { + label: 'Azure OpenAI Api Key', + name: 'azureOpenAIApiKey', + type: 'password', + description: `Refer to official guide on how to create API key on Azure OpenAI` + }, + { + label: 'Azure OpenAI Api Instance Name', + name: 'azureOpenAIApiInstanceName', + type: 'string', + placeholder: 'YOUR-INSTANCE-NAME' + }, + { + label: 'Azure OpenAI Api Deployment Name', + name: 'azureOpenAIApiDeploymentName', + type: 'string', + placeholder: 'YOUR-DEPLOYMENT-NAME' + }, + { + label: 'Azure OpenAI Api Version', + name: 'azureOpenAIApiVersion', + type: 'string', + placeholder: '2023-06-01-preview', + description: + 'Description of Supported API Versions. Please refer examples' + } + ] + } +} + +module.exports = { credClass: AzureOpenAIApi } diff --git a/packages/components/nodes/chatmodels/ChatAnthropic/AnthropicApi.credential.ts b/packages/components/nodes/chatmodels/ChatAnthropic/AnthropicApi.credential.ts new file mode 100644 index 000000000..607fa625b --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatAnthropic/AnthropicApi.credential.ts @@ -0,0 +1,21 @@ +import { INodeParams, INodeCredential } from '../../../src/Interface' + +class AnthropicApi implements INodeCredential { + label: string + name: string + inputs: INodeParams[] + + constructor() { + this.label = 'Anthropic API' + this.name = 'anthropicApi' + this.inputs = [ + { + label: 'Anthropic Api Key', + name: 'anthropicApiKey', + type: 'password' + } + ] + } +} + +module.exports = { credClass: AnthropicApi } diff --git a/packages/components/nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts b/packages/components/nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts index 3d861d24e..6581475e1 100644 --- a/packages/components/nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts +++ b/packages/components/nodes/chatmodels/ChatAnthropic/ChatAnthropic.ts @@ -1,5 +1,5 @@ -import { INode, INodeData, INodeParams } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' +import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' import { AnthropicInput, ChatAnthropic } from 'langchain/chat_models/anthropic' class ChatAnthropic_ChatModels implements INode { @@ -10,6 +10,7 @@ class ChatAnthropic_ChatModels implements INode { category: string description: string baseClasses: string[] + credential: INodeParams inputs: INodeParams[] constructor() { @@ -20,12 +21,13 @@ class ChatAnthropic_ChatModels implements INode { this.category = 'Chat Models' this.description = 'Wrapper around ChatAnthropic large language models that use the Chat endpoint' this.baseClasses = [this.type, ...getBaseClasses(ChatAnthropic)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['anthropicApi'] + } this.inputs = [ - { - label: 'ChatAnthropic Api Key', - name: 'anthropicApiKey', - type: 'password' - }, { label: 'Model Name', name: 'modelName', @@ -110,15 +112,17 @@ class ChatAnthropic_ChatModels implements INode { ] } - async init(nodeData: INodeData): Promise { + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const temperature = nodeData.inputs?.temperature as string const modelName = nodeData.inputs?.modelName as string - const anthropicApiKey = nodeData.inputs?.anthropicApiKey as string const maxTokensToSample = nodeData.inputs?.maxTokensToSample as string const topP = nodeData.inputs?.topP as string const topK = nodeData.inputs?.topK as string const streaming = nodeData.inputs?.streaming as boolean + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const anthropicApiKey = getCredentialParam('anthropicApiKey', credentialData, nodeData) + const obj: Partial & { anthropicApiKey?: string } = { temperature: parseFloat(temperature), modelName, diff --git a/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts b/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts index 955563ffe..1339d1feb 100644 --- a/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts +++ b/packages/components/nodes/chatmodels/ChatOpenAI/ChatOpenAI.ts @@ -1,5 +1,5 @@ -import { INode, INodeData, INodeParams } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' +import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' import { ChatOpenAI, OpenAIChatInput } from 'langchain/chat_models/openai' class ChatOpenAI_ChatModels implements INode { @@ -10,6 +10,7 @@ class ChatOpenAI_ChatModels implements INode { category: string description: string baseClasses: string[] + credential: INodeParams inputs: INodeParams[] constructor() { @@ -20,12 +21,13 @@ class ChatOpenAI_ChatModels implements INode { this.category = 'Chat Models' this.description = 'Wrapper around OpenAI large language models that use the Chat endpoint' this.baseClasses = [this.type, ...getBaseClasses(ChatOpenAI)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['openAIApi'] + } this.inputs = [ - { - label: 'OpenAI Api Key', - name: 'openAIApiKey', - type: 'password' - }, { label: 'Model Name', name: 'modelName', @@ -119,10 +121,9 @@ class ChatOpenAI_ChatModels implements INode { ] } - async init(nodeData: INodeData): Promise { + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const temperature = nodeData.inputs?.temperature as string const modelName = nodeData.inputs?.modelName as string - const openAIApiKey = nodeData.inputs?.openAIApiKey as string const maxTokens = nodeData.inputs?.maxTokens as string const topP = nodeData.inputs?.topP as string const frequencyPenalty = nodeData.inputs?.frequencyPenalty as string @@ -131,6 +132,9 @@ class ChatOpenAI_ChatModels implements INode { const streaming = nodeData.inputs?.streaming as boolean const basePath = nodeData.inputs?.basepath as string + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const openAIApiKey = getCredentialParam('openAIApiKey', credentialData, nodeData) + const obj: Partial & { openAIApiKey?: string } = { temperature: parseFloat(temperature), modelName, diff --git a/packages/components/nodes/chatmodels/ChatOpenAI/OpenAIApi.credential.ts b/packages/components/nodes/chatmodels/ChatOpenAI/OpenAIApi.credential.ts new file mode 100644 index 000000000..96209a353 --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatOpenAI/OpenAIApi.credential.ts @@ -0,0 +1,21 @@ +import { INodeParams, INodeCredential } from '../../../src/Interface' + +class OpenAIApi implements INodeCredential { + label: string + name: string + inputs: INodeParams[] + + constructor() { + this.label = 'OpenAI API' + this.name = 'openAIApi' + this.inputs = [ + { + label: 'OpenAI Api Key', + name: 'openAIApiKey', + type: 'password' + } + ] + } +} + +module.exports = { credClass: OpenAIApi } diff --git a/packages/components/nodes/llms/OpenAI/OpenAI.ts b/packages/components/nodes/llms/OpenAI/OpenAI.ts index b0af867d6..50aa1c609 100644 --- a/packages/components/nodes/llms/OpenAI/OpenAI.ts +++ b/packages/components/nodes/llms/OpenAI/OpenAI.ts @@ -1,5 +1,5 @@ -import { INode, INodeData, INodeParams } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' +import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' import { OpenAI, OpenAIInput } from 'langchain/llms/openai' class OpenAI_LLMs implements INode { @@ -10,6 +10,7 @@ class OpenAI_LLMs implements INode { category: string description: string baseClasses: string[] + credential: INodeParams inputs: INodeParams[] constructor() { @@ -20,12 +21,13 @@ class OpenAI_LLMs implements INode { this.category = 'LLMs' this.description = 'Wrapper around OpenAI large language models' this.baseClasses = [this.type, ...getBaseClasses(OpenAI)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['openAIApi'] + } this.inputs = [ - { - label: 'OpenAI Api Key', - name: 'openAIApiKey', - type: 'password' - }, { label: 'Model Name', name: 'modelName', @@ -117,10 +119,9 @@ class OpenAI_LLMs implements INode { ] } - async init(nodeData: INodeData): Promise { + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const temperature = nodeData.inputs?.temperature as string const modelName = nodeData.inputs?.modelName as string - const openAIApiKey = nodeData.inputs?.openAIApiKey as string const maxTokens = nodeData.inputs?.maxTokens as string const topP = nodeData.inputs?.topP as string const frequencyPenalty = nodeData.inputs?.frequencyPenalty as string @@ -131,6 +132,9 @@ class OpenAI_LLMs implements INode { const streaming = nodeData.inputs?.streaming as boolean const basePath = nodeData.inputs?.basepath as string + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const openAIApiKey = getCredentialParam('openAIApiKey', credentialData, nodeData) + const obj: Partial & { openAIApiKey?: string } = { temperature: parseFloat(temperature), modelName, diff --git a/packages/components/nodes/memory/MotorheadMemory/MotorheadMemory.ts b/packages/components/nodes/memory/MotorheadMemory/MotorheadMemory.ts index 8a160223f..9caf604cb 100644 --- a/packages/components/nodes/memory/MotorheadMemory/MotorheadMemory.ts +++ b/packages/components/nodes/memory/MotorheadMemory/MotorheadMemory.ts @@ -1,5 +1,5 @@ import { INode, INodeData, INodeParams } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' import { ICommonObject } from '../../../src' import { MotorheadMemory, MotorheadMemoryInput } from 'langchain/memory' @@ -11,6 +11,7 @@ class MotorMemory_Memory implements INode { icon: string category: string baseClasses: string[] + credential: INodeParams inputs: INodeParams[] constructor() { @@ -21,6 +22,14 @@ class MotorMemory_Memory implements INode { this.category = 'Memory' this.description = 'Remembers previous conversational back and forths directly' this.baseClasses = [this.type, ...getBaseClasses(MotorheadMemory)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + optional: true, + description: 'Only needed when using hosted solution - https://getmetal.io', + credentialNames: ['motorheadMemoryApi'] + } this.inputs = [ { label: 'Base URL', @@ -43,22 +52,6 @@ class MotorMemory_Memory implements INode { default: '', additionalParams: true, optional: true - }, - { - label: 'API Key', - name: 'apiKey', - type: 'password', - description: 'Only needed when using hosted solution - https://getmetal.io', - additionalParams: true, - optional: true - }, - { - label: 'Client ID', - name: 'clientId', - type: 'string', - description: 'Only needed when using hosted solution - https://getmetal.io', - additionalParams: true, - optional: true } ] } @@ -67,11 +60,13 @@ class MotorMemory_Memory implements INode { const memoryKey = nodeData.inputs?.memoryKey as string const baseURL = nodeData.inputs?.baseURL as string const sessionId = nodeData.inputs?.sessionId as string - const apiKey = nodeData.inputs?.apiKey as string - const clientId = nodeData.inputs?.clientId as string const chatId = options?.chatId as string + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const apiKey = getCredentialParam('apiKey', credentialData, nodeData) + const clientId = getCredentialParam('clientId', credentialData, nodeData) + let obj: MotorheadMemoryInput = { returnMessages: true, sessionId: sessionId ? sessionId : chatId, diff --git a/packages/components/nodes/memory/MotorheadMemory/MotorheadMemoryApi.credential.ts b/packages/components/nodes/memory/MotorheadMemory/MotorheadMemoryApi.credential.ts new file mode 100644 index 000000000..4563cda24 --- /dev/null +++ b/packages/components/nodes/memory/MotorheadMemory/MotorheadMemoryApi.credential.ts @@ -0,0 +1,29 @@ +import { INodeParams, INodeCredential } from '../../../src/Interface' + +class MotorheadMemoryApi implements INodeCredential { + label: string + name: string + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'Motorhead Memory API' + this.name = 'motorheadMemoryApi' + this.description = + 'Refer to official guide on how to create API key and Client ID on Motorhead Memory' + this.inputs = [ + { + label: 'Client ID', + name: 'clientId', + type: 'string' + }, + { + label: 'API Key', + name: 'apiKey', + type: 'password' + } + ] + } +} + +module.exports = { credClass: MotorheadMemoryApi } diff --git a/packages/components/nodes/memory/ZepMemory/ZepMemory.ts b/packages/components/nodes/memory/ZepMemory/ZepMemory.ts index 5ca1310d0..211f60be3 100644 --- a/packages/components/nodes/memory/ZepMemory/ZepMemory.ts +++ b/packages/components/nodes/memory/ZepMemory/ZepMemory.ts @@ -1,6 +1,6 @@ import { SystemMessage } from 'langchain/schema' import { INode, INodeData, INodeParams } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' import { ZepMemory, ZepMemoryInput } from 'langchain/memory/zep' import { ICommonObject } from '../../../src' @@ -12,6 +12,7 @@ class ZepMemory_Memory implements INode { icon: string category: string baseClasses: string[] + credential: INodeParams inputs: INodeParams[] constructor() { @@ -22,6 +23,14 @@ class ZepMemory_Memory implements INode { this.category = 'Memory' this.description = 'Summarizes the conversation and stores the memory in zep server' this.baseClasses = [this.type, ...getBaseClasses(ZepMemory)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + optional: true, + description: 'Configure JWT authentication on your Zep instance (Optional)', + credentialNames: ['zepMemoryApi'] + } this.inputs = [ { label: 'Base URL', @@ -44,18 +53,12 @@ class ZepMemory_Memory implements INode { additionalParams: true, optional: true }, - { - label: 'API Key', - name: 'apiKey', - type: 'password', - additionalParams: true, - optional: true - }, { label: 'Size', name: 'k', type: 'number', default: '10', + step: 1, description: 'Window of size k to surface the last k back-and-forths to use as memory.' }, { @@ -112,11 +115,13 @@ class ZepMemory_Memory implements INode { const autoSummaryTemplate = nodeData.inputs?.autoSummaryTemplate as string const autoSummary = nodeData.inputs?.autoSummary as boolean const sessionId = nodeData.inputs?.sessionId as string - const apiKey = nodeData.inputs?.apiKey as string const k = nodeData.inputs?.k as string const chatId = options?.chatId as string + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const apiKey = getCredentialParam('apiKey', credentialData, nodeData) + const obj: ZepMemoryInput = { baseURL, sessionId: sessionId ? sessionId : chatId, diff --git a/packages/components/nodes/memory/ZepMemory/ZepMemoryApi.credential.ts b/packages/components/nodes/memory/ZepMemory/ZepMemoryApi.credential.ts new file mode 100644 index 000000000..5e92ef5d7 --- /dev/null +++ b/packages/components/nodes/memory/ZepMemory/ZepMemoryApi.credential.ts @@ -0,0 +1,24 @@ +import { INodeParams, INodeCredential } from '../../../src/Interface' + +class ZepMemoryApi implements INodeCredential { + label: string + name: string + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'Zep Memory Api' + this.name = 'zepMemoryApi' + this.description = + 'Refer to official guide on how to create API key on Zep' + this.inputs = [ + { + label: 'API Key', + name: 'apiKey', + type: 'password' + } + ] + } +} + +module.exports = { credClass: ZepMemoryApi } diff --git a/packages/components/src/Interface.ts b/packages/components/src/Interface.ts index d9233e490..4a69a9a02 100644 --- a/packages/components/src/Interface.ts +++ b/packages/components/src/Interface.ts @@ -59,7 +59,9 @@ export interface INodeParams { description?: string warning?: string options?: Array + credentialNames?: Array optional?: boolean | INodeDisplay + step?: number rows?: number list?: boolean acceptVariable?: boolean @@ -102,10 +104,18 @@ export interface INodeData extends INodeProperties { id: string inputs?: ICommonObject outputs?: ICommonObject + credential?: string instance?: any loadMethod?: string // method to load async options } +export interface INodeCredential { + label: string + name: string + description?: string + inputs?: INodeParams[] +} + export interface IMessage { message: string type: MessageType diff --git a/packages/components/src/utils.ts b/packages/components/src/utils.ts index e1399404c..3244422fe 100644 --- a/packages/components/src/utils.ts +++ b/packages/components/src/utils.ts @@ -6,6 +6,9 @@ import { JSDOM } from 'jsdom' import { BaseCallbackHandler } from 'langchain/callbacks' import { Server } from 'socket.io' import { ChainValues } from 'langchain/dist/schema' +import { DataSource } from 'typeorm' +import { ICommonObject, IDatabaseEntity, INodeData } from './Interface' +import { AES, enc } from 'crypto-js' export const numberOrExpressionRegex = '^(\\d+\\.?\\d*|{{.*}})$' //return true if string consists only numbers OR expression {{}} export const notEmptyRegex = '(.|\\s)*\\S(.|\\s)*' //return true if string is not empty or blank @@ -350,6 +353,89 @@ export const getEnvironmentVariable = (name: string): string | undefined => { } } +/** + * Returns the path of encryption key + * @returns {string} + */ +const getEncryptionKeyFilePath = (): string => { + const checkPaths = [ + path.join(__dirname, '..', '..', 'server', 'encryption.key'), + path.join(__dirname, '..', '..', '..', 'server', 'encryption.key'), + path.join(__dirname, '..', '..', '..', '..', 'server', 'encryption.key'), + path.join(__dirname, '..', '..', '..', '..', '..', 'server', 'encryption.key') + ] + for (const checkPath of checkPaths) { + if (fs.existsSync(checkPath)) { + return checkPath + } + } + return '' +} + +const getEncryptionKeyPath = (): string => { + return process.env.SECRETKEY_PATH ? path.join(process.env.SECRETKEY_PATH, 'encryption.key') : getEncryptionKeyFilePath() +} + +/** + * Returns the encryption key + * @returns {Promise} + */ +const getEncryptionKey = async (): Promise => { + try { + return await fs.promises.readFile(getEncryptionKeyPath(), 'utf8') + } catch (error) { + throw new Error(error) + } +} + +/** + * Decrypt credential data + * @param {string} encryptedData + * @param {string} componentCredentialName + * @param {IComponentCredentials} componentCredentials + * @returns {Promise} + */ +const decryptCredentialData = async (encryptedData: string): Promise => { + const encryptKey = await getEncryptionKey() + const decryptedData = AES.decrypt(encryptedData, encryptKey) + try { + return JSON.parse(decryptedData.toString(enc.Utf8)) + } catch (e) { + console.error(e) + throw new Error('Credentials could not be decrypted.') + } +} + +/** + * Get credential data + * @param {string} selectedCredentialId + * @param {ICommonObject} options + * @returns {Promise} + */ +export const getCredentialData = async (selectedCredentialId: string, options: ICommonObject): Promise => { + const appDataSource = options.appDataSource as DataSource + const databaseEntities = options.databaseEntities as IDatabaseEntity + + try { + const credential = await appDataSource.getRepository(databaseEntities['Credential']).findOneBy({ + id: selectedCredentialId + }) + + if (!credential) throw new Error(`Credential ${selectedCredentialId} not found`) + + // Decrpyt credentialData + const decryptedCredentialData = await decryptCredentialData(credential.encryptedData) + + return decryptedCredentialData + } catch (e) { + throw new Error(e) + } +} + +export const getCredentialParam = (paramName: string, credentialData: ICommonObject, nodeData: INodeData): any => { + return (nodeData.inputs as ICommonObject)[paramName] ?? credentialData[paramName] +} + /** * Custom chain handler class */ diff --git a/packages/server/package.json b/packages/server/package.json index 44b7e9911..d04bcab29 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -48,6 +48,7 @@ "@oclif/core": "^1.13.10", "axios": "^0.27.2", "cors": "^2.8.5", + "crypto-js": "^4.1.1", "dotenv": "^16.0.0", "express": "^4.17.3", "express-basic-auth": "^1.2.1", @@ -63,6 +64,7 @@ }, "devDependencies": { "@types/cors": "^2.8.12", + "@types/crypto-js": "^4.1.1", "@types/multer": "^1.4.7", "concurrently": "^7.1.0", "nodemon": "^2.0.15", diff --git a/packages/server/src/ChildProcess.ts b/packages/server/src/ChildProcess.ts index e8aeaff27..1ac805571 100644 --- a/packages/server/src/ChildProcess.ts +++ b/packages/server/src/ChildProcess.ts @@ -5,6 +5,7 @@ import { DataSource } from 'typeorm' import { ChatFlow } from './entity/ChatFlow' import { ChatMessage } from './entity/ChatMessage' import { Tool } from './entity/Tool' +import { Credential } from './entity/Credential' export class ChildProcess { /** @@ -133,7 +134,7 @@ async function initDB() { type: 'sqlite', database: path.resolve(homePath, 'database.sqlite'), synchronize: true, - entities: [ChatFlow, ChatMessage, Tool], + entities: [ChatFlow, ChatMessage, Tool, Credential], migrations: [] }) return await childAppDataSource.initialize() diff --git a/packages/server/src/DataSource.ts b/packages/server/src/DataSource.ts index 03b9d5ce6..fdf0c9248 100644 --- a/packages/server/src/DataSource.ts +++ b/packages/server/src/DataSource.ts @@ -3,6 +3,7 @@ import path from 'path' import { DataSource } from 'typeorm' import { ChatFlow } from './entity/ChatFlow' import { ChatMessage } from './entity/ChatMessage' +import { Credential } from './entity/Credential' import { Tool } from './entity/Tool' import { getUserHome } from './utils' @@ -15,7 +16,7 @@ export const init = async (): Promise => { type: 'sqlite', database: path.resolve(homePath, 'database.sqlite'), synchronize: true, - entities: [ChatFlow, ChatMessage, Tool], + entities: [ChatFlow, ChatMessage, Tool, Credential], migrations: [] }) } diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 0c6304906..49d61036c 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -38,10 +38,23 @@ export interface ITool { createdDate: Date } +export interface ICredential { + id: string + name: string + credentialName: string + encryptedData: string + updatedDate: Date + createdDate: Date +} + export interface IComponentNodes { [key: string]: INode } +export interface IComponentCredentials { + [key: string]: INode +} + export interface IVariableDict { [key: string]: string } @@ -167,3 +180,17 @@ export interface IChildProcessMessage { key: string value?: any } + +export type ICredentialDataDecrypted = ICommonObject + +// Plain credential object sent to server +export interface ICredentialReqBody { + name: string + credentialName: string + plainDataObj: ICredentialDataDecrypted +} + +// Decrypted credential object sent back to client +export interface ICredentialReturnResponse extends ICredential { + plainDataObj: ICredentialDataDecrypted +} diff --git a/packages/server/src/NodesPool.ts b/packages/server/src/NodesPool.ts index 1ee506eae..e339f5920 100644 --- a/packages/server/src/NodesPool.ts +++ b/packages/server/src/NodesPool.ts @@ -1,23 +1,33 @@ -import { IComponentNodes } from './Interface' - +import { IComponentNodes, IComponentCredentials } from './Interface' import path from 'path' import { Dirent } from 'fs' import { getNodeModulesPackagePath } from './utils' import { promises } from 'fs' +import { ICommonObject } from 'flowise-components' export class NodesPool { componentNodes: IComponentNodes = {} + componentCredentials: IComponentCredentials = {} + private credentialIconPath: ICommonObject = {} /** - * Initialize to get all nodes + * Initialize to get all nodes & credentials */ async initialize() { + await this.initializeNodes() + await this.initializeCrdentials() + } + + /** + * Initialize nodes + */ + private async initializeNodes() { const packagePath = getNodeModulesPackagePath('flowise-components') const nodesPath = path.join(packagePath, 'dist', 'nodes') const nodeFiles = await this.getFiles(nodesPath) return Promise.all( nodeFiles.map(async (file) => { - if (file.endsWith('.js')) { + if (file.endsWith('.js') && !file.endsWith('.credential.js')) { const nodeModule = await require(file) if (nodeModule.nodeClass) { @@ -37,6 +47,13 @@ export class NodesPool { filePath.pop() const nodeIconAbsolutePath = `${filePath.join('/')}/${newNodeInstance.icon}` this.componentNodes[newNodeInstance.name].icon = nodeIconAbsolutePath + + // Store icon path for componentCredentials + if (newNodeInstance.credential) { + for (const credName of newNodeInstance.credential.credentialNames) { + this.credentialIconPath[credName] = nodeIconAbsolutePath + } + } } } } @@ -44,12 +61,33 @@ export class NodesPool { ) } + /** + * Initialize credentials + */ + private async initializeCrdentials() { + const packagePath = getNodeModulesPackagePath('flowise-components') + const nodesPath = path.join(packagePath, 'dist', 'nodes') + const nodeFiles = await this.getFiles(nodesPath) + return Promise.all( + nodeFiles.map(async (file) => { + if (file.endsWith('.credential.js')) { + const credentialModule = await require(file) + if (credentialModule.credClass) { + const newCredInstance = new credentialModule.credClass() + newCredInstance.icon = this.credentialIconPath[newCredInstance.name] ?? '' + this.componentCredentials[newCredInstance.name] = newCredInstance + } + } + }) + ) + } + /** * Recursive function to get node files * @param {string} dir * @returns {string[]} */ - async getFiles(dir: string): Promise { + private async getFiles(dir: string): Promise { const dirents = await promises.readdir(dir, { withFileTypes: true }) const files = await Promise.all( dirents.map((dirent: Dirent) => { diff --git a/packages/server/src/entity/Credential.ts b/packages/server/src/entity/Credential.ts new file mode 100644 index 000000000..b724eed6f --- /dev/null +++ b/packages/server/src/entity/Credential.ts @@ -0,0 +1,24 @@ +/* eslint-disable */ +import { Entity, Column, PrimaryGeneratedColumn, Index, CreateDateColumn, UpdateDateColumn } from 'typeorm' +import { ICredential } from '../Interface' + +@Entity() +export class Credential implements ICredential { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column() + name: string + + @Column() + credentialName: string + + @Column() + encryptedData: string + + @CreateDateColumn() + createdDate: Date + + @UpdateDateColumn() + updatedDate: Date +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0f87aebad..b4316b699 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -17,7 +17,8 @@ import { INodeData, IDatabaseExport, IRunChatflowMessageValue, - IChildProcessMessage + IChildProcessMessage, + ICredentialReturnResponse } from './Interface' import { getNodeModulesPackagePath, @@ -39,17 +40,20 @@ import { isFlowValidForStream, isVectorStoreFaiss, databaseEntities, - getApiKey + getApiKey, + transformToCredentialEntity, + decryptCredentialData } from './utils' -import { cloneDeep } from 'lodash' +import { cloneDeep, omit } from 'lodash' import { getDataSource } from './DataSource' import { NodesPool } from './NodesPool' import { ChatFlow } from './entity/ChatFlow' import { ChatMessage } from './entity/ChatMessage' +import { Credential } from './entity/Credential' +import { Tool } from './entity/Tool' import { ChatflowPool } from './ChatflowPool' import { ICommonObject, INodeOptionsValue } from 'flowise-components' import { fork } from 'child_process' -import { Tool } from './entity/Tool' export class App { app: express.Application @@ -70,10 +74,11 @@ export class App { .then(async () => { logger.info('📦 [server]: Data Source has been initialized!') - // Initialize pools + // Initialize nodes pool this.nodesPool = new NodesPool() await this.nodesPool.initialize() + // Initialize chatflow pool this.chatflowPool = new ChatflowPool() // Initialize API keys @@ -104,6 +109,7 @@ export class App { '/api/v1/public-chatflows', '/api/v1/prediction/', '/api/v1/node-icon/', + '/api/v1/components-credentials-icon/', '/api/v1/chatflows-streaming' ] this.app.use((req, res, next) => { @@ -116,7 +122,7 @@ export class App { const upload = multer({ dest: `${path.join(__dirname, '..', 'uploads')}/` }) // ---------------------------------------- - // Nodes + // Components // ---------------------------------------- // Get all component nodes @@ -129,6 +135,16 @@ export class App { return res.json(returnData) }) + // Get all component credentials + this.app.get('/api/v1/components-credentials', async (req: Request, res: Response) => { + const returnData = [] + for (const credName in this.nodesPool.componentCredentials) { + const clonedCred = cloneDeep(this.nodesPool.componentCredentials[credName]) + returnData.push(clonedCred) + } + return res.json(returnData) + }) + // Get specific component node via name this.app.get('/api/v1/nodes/:name', (req: Request, res: Response) => { if (Object.prototype.hasOwnProperty.call(this.nodesPool.componentNodes, req.params.name)) { @@ -138,6 +154,27 @@ export class App { } }) + // Get component credential via name + this.app.get('/api/v1/components-credentials/:name', (req: Request, res: Response) => { + if (!req.params.name.includes('&')) { + if (Object.prototype.hasOwnProperty.call(this.nodesPool.componentCredentials, req.params.name)) { + return res.json(this.nodesPool.componentCredentials[req.params.name]) + } else { + throw new Error(`Credential ${req.params.name} not found`) + } + } else { + const returnResponse = [] + for (const name of req.params.name.split('&')) { + if (Object.prototype.hasOwnProperty.call(this.nodesPool.componentCredentials, name)) { + returnResponse.push(this.nodesPool.componentCredentials[name]) + } else { + throw new Error(`Credential ${name} not found`) + } + } + return res.json(returnResponse) + } + }) + // Returns specific component node icon via name this.app.get('/api/v1/node-icon/:name', (req: Request, res: Response) => { if (Object.prototype.hasOwnProperty.call(this.nodesPool.componentNodes, req.params.name)) { @@ -157,6 +194,25 @@ export class App { } }) + // Returns specific component credential icon via name + this.app.get('/api/v1/components-credentials-icon/:name', (req: Request, res: Response) => { + if (Object.prototype.hasOwnProperty.call(this.nodesPool.componentCredentials, req.params.name)) { + const credInstance = this.nodesPool.componentCredentials[req.params.name] + if (credInstance.icon === undefined) { + throw new Error(`Credential ${req.params.name} icon not found`) + } + + if (credInstance.icon.endsWith('.svg') || credInstance.icon.endsWith('.png') || credInstance.icon.endsWith('.jpg')) { + const filepath = credInstance.icon + res.sendFile(filepath) + } else { + throw new Error(`Credential ${req.params.name} icon is missing icon`) + } + } else { + throw new Error(`Credential ${req.params.name} not found`) + } + }) + // load async options this.app.post('/api/v1/node-load-method/:name', async (req: Request, res: Response) => { const nodeData: INodeData = req.body @@ -324,6 +380,91 @@ export class App { return res.json(results) }) + // ---------------------------------------- + // Credentials + // ---------------------------------------- + + // Create new credential + this.app.post('/api/v1/credentials', async (req: Request, res: Response) => { + const body = req.body + const newCredential = await transformToCredentialEntity(body) + const credential = this.AppDataSource.getRepository(Credential).create(newCredential) + const results = await this.AppDataSource.getRepository(Credential).save(credential) + return res.json(results) + }) + + // Get all credentials + this.app.get('/api/v1/credentials', async (req: Request, res: Response) => { + if (req.query.credentialName) { + let returnCredentials = [] + if (Array.isArray(req.query.credentialName)) { + for (let i = 0; i < req.query.credentialName.length; i += 1) { + const name = req.query.credentialName[i] as string + const credentials = await this.AppDataSource.getRepository(Credential).findBy({ + credentialName: name + }) + returnCredentials.push(...credentials) + } + } else { + const credentials = await this.AppDataSource.getRepository(Credential).findBy({ + credentialName: req.query.credentialName as string + }) + returnCredentials = [...credentials] + } + return res.json(returnCredentials) + } else { + const credentials = await this.AppDataSource.getRepository(Credential).find() + const returnCredentials = [] + for (const credential of credentials) { + returnCredentials.push(omit(credential, ['encryptedData'])) + } + return res.json(returnCredentials) + } + }) + + // Get specific credential + this.app.get('/api/v1/credentials/:id', async (req: Request, res: Response) => { + const credential = await this.AppDataSource.getRepository(Credential).findOneBy({ + id: req.params.id + }) + + if (!credential) return res.status(404).send(`Credential ${req.params.id} not found`) + + // Decrpyt credentialData + const decryptedCredentialData = await decryptCredentialData( + credential.encryptedData, + credential.credentialName, + this.nodesPool.componentCredentials + ) + const returnCredential: ICredentialReturnResponse = { + ...credential, + plainDataObj: decryptedCredentialData + } + return res.json(omit(returnCredential, ['encryptedData'])) + }) + + // Update credential + this.app.put('/api/v1/credentials/:id', async (req: Request, res: Response) => { + const credential = await this.AppDataSource.getRepository(Credential).findOneBy({ + id: req.params.id + }) + + if (!credential) return res.status(404).send(`Credential ${req.params.id} not found`) + + const body = req.body + const updateCredential = await transformToCredentialEntity(body) + this.AppDataSource.getRepository(Credential).merge(credential, updateCredential) + const result = await this.AppDataSource.getRepository(Credential).save(credential) + + return res.json(result) + }) + + // Delete all chatmessages from chatflowid + this.app.delete('/api/v1/credentials/:id', async (req: Request, res: Response) => { + const results = await this.AppDataSource.getRepository(Credential).delete({ id: req.params.id }) + return res.json(results) + }) + // ---------------------------------------- // Tools // ---------------------------------------- @@ -393,7 +534,7 @@ export class App { const flowData = chatflow.flowData const parsedFlowData: IReactFlowObject = JSON.parse(flowData) const nodes = parsedFlowData.nodes - const availableConfigs = findAvailableConfigs(nodes) + const availableConfigs = findAvailableConfigs(nodes, this.nodesPool.componentCredentials) return res.json(availableConfigs) }) diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index 3ee7a25b2..77b047cc2 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -13,18 +13,26 @@ import { IReactFlowNode, IVariableDict, INodeData, - IOverrideConfig + IOverrideConfig, + ICredentialDataDecrypted, + IComponentCredentials, + ICredentialReqBody } from '../Interface' import { cloneDeep, get, omit, merge } from 'lodash' import { ICommonObject, getInputVariables, IDatabaseEntity } from 'flowise-components' import { scryptSync, randomBytes, timingSafeEqual } from 'crypto' +import { lib, PBKDF2, AES, enc } from 'crypto-js' + import { ChatFlow } from '../entity/ChatFlow' import { ChatMessage } from '../entity/ChatMessage' +import { Credential } from '../entity/Credential' import { Tool } from '../entity/Tool' import { DataSource } from 'typeorm' const QUESTION_VAR_PREFIX = 'question' -export const databaseEntities: IDatabaseEntity = { ChatFlow: ChatFlow, ChatMessage: ChatMessage, Tool: Tool } +const REDACTED_CREDENTIAL_VALUE = '_FLOWISE_BLANK_07167752-1a71-43b1-bf8f-4f32252165db' + +export const databaseEntities: IDatabaseEntity = { ChatFlow: ChatFlow, ChatMessage: ChatMessage, Tool: Tool, Credential: Credential } /** * Returns the home folder path of the user if @@ -399,9 +407,8 @@ export const replaceInputsWithConfig = (flowNodeData: INodeData, overrideConfig: const types = 'inputs' const getParamValues = (paramsObj: ICommonObject) => { - for (const key in paramsObj) { - const paramValue: string = paramsObj[key] - paramsObj[key] = overrideConfig[key] ?? paramValue + for (const config in overrideConfig) { + paramsObj[config] = overrideConfig[config] } } @@ -623,11 +630,12 @@ export const mapMimeTypeToInputField = (mimeType: string) => { } /** - * Find all available inpur params config + * Find all available input params config * @param {IReactFlowNode[]} reactFlowNodes - * @returns {Promise} + * @param {IComponentCredentials} componentCredentials + * @returns {IOverrideConfig[]} */ -export const findAvailableConfigs = (reactFlowNodes: IReactFlowNode[]) => { +export const findAvailableConfigs = (reactFlowNodes: IReactFlowNode[], componentCredentials: IComponentCredentials) => { const configs: IOverrideConfig[] = [] for (const flowNode of reactFlowNodes) { @@ -653,6 +661,23 @@ export const findAvailableConfigs = (reactFlowNodes: IReactFlowNode[]) => { .join(', ') : 'string' } + } else if (inputParam.type === 'credential') { + // get component credential inputs + for (const name of inputParam.credentialNames ?? []) { + if (Object.prototype.hasOwnProperty.call(componentCredentials, name)) { + const inputs = componentCredentials[name]?.inputs ?? [] + for (const input of inputs) { + obj = { + node: flowNode.data.label, + label: input.label, + name: input.name, + type: input.type === 'password' ? 'string' : input.type + } + configs.push(obj) + } + } + } + continue } else { obj = { node: flowNode.data.label, @@ -705,3 +730,118 @@ export const isFlowValidForStream = (reactFlowNodes: IReactFlowNode[], endingNod return isChatOrLLMsExist && isValidChainOrAgent && !isVectorStoreFaiss(endingNodeData) && process.env.EXECUTION_MODE !== 'child' } + +/** + * Returns the path of encryption key + * @returns {string} + */ +export const getEncryptionKeyPath = (): string => { + return process.env.SECRETKEY_PATH + ? path.join(process.env.SECRETKEY_PATH, 'encryption.key') + : path.join(__dirname, '..', '..', 'encryption.key') +} + +/** + * Generate an encryption key + * @returns {string} + */ +export const generateEncryptKey = (): string => { + const salt = lib.WordArray.random(128 / 8) + const key256Bits = PBKDF2(process.env.PASSPHRASE || 'MYPASSPHRASE', salt, { + keySize: 256 / 32, + iterations: 1000 + }) + return key256Bits.toString() +} + +/** + * Returns the encryption key + * @returns {Promise} + */ +export const getEncryptionKey = async (): Promise => { + try { + return await fs.promises.readFile(getEncryptionKeyPath(), 'utf8') + } catch (error) { + const encryptKey = generateEncryptKey() + await fs.promises.writeFile(getEncryptionKeyPath(), encryptKey) + return encryptKey + } +} + +/** + * Encrypt credential data + * @param {ICredentialDataDecrypted} plainDataObj + * @returns {Promise} + */ +export const encryptCredentialData = async (plainDataObj: ICredentialDataDecrypted): Promise => { + const encryptKey = await getEncryptionKey() + return AES.encrypt(JSON.stringify(plainDataObj), encryptKey).toString() +} + +/** + * Decrypt credential data + * @param {string} encryptedData + * @param {string} componentCredentialName + * @param {IComponentCredentials} componentCredentials + * @returns {Promise} + */ +export const decryptCredentialData = async ( + encryptedData: string, + componentCredentialName?: string, + componentCredentials?: IComponentCredentials +): Promise => { + const encryptKey = await getEncryptionKey() + const decryptedData = AES.decrypt(encryptedData, encryptKey) + try { + if (componentCredentialName && componentCredentials) { + const plainDataObj = JSON.parse(decryptedData.toString(enc.Utf8)) + return redactCredentialWithPasswordType(componentCredentialName, plainDataObj, componentCredentials) + } + return JSON.parse(decryptedData.toString(enc.Utf8)) + } catch (e) { + console.error(e) + throw new Error('Credentials could not be decrypted.') + } +} + +/** + * Transform ICredentialBody from req to Credential entity + * @param {ICredentialReqBody} body + * @returns {Credential} + */ +export const transformToCredentialEntity = async (body: ICredentialReqBody): Promise => { + const encryptedData = await encryptCredentialData(body.plainDataObj) + + const credentialBody = { + name: body.name, + credentialName: body.credentialName, + encryptedData + } + + const newCredential = new Credential() + Object.assign(newCredential, credentialBody) + + return newCredential +} + +/** + * Redact values that are of password type to avoid sending back to client + * @param {string} componentCredentialName + * @param {ICredentialDataDecrypted} decryptedCredentialObj + * @param {IComponentCredentials} componentCredentials + * @returns {ICredentialDataDecrypted} + */ +export const redactCredentialWithPasswordType = ( + componentCredentialName: string, + decryptedCredentialObj: ICredentialDataDecrypted, + componentCredentials: IComponentCredentials +): ICredentialDataDecrypted => { + const plainDataObj = cloneDeep(decryptedCredentialObj) + for (const cred in plainDataObj) { + const inputParam = componentCredentials[componentCredentialName].inputs?.find((inp) => inp.type === 'password' && inp.name === cred) + if (inputParam) { + plainDataObj[cred] = REDACTED_CREDENTIAL_VALUE + } + } + return plainDataObj +} diff --git a/packages/server/src/utils/logger.ts b/packages/server/src/utils/logger.ts index 1c28b173c..c5ff26b02 100644 --- a/packages/server/src/utils/logger.ts +++ b/packages/server/src/utils/logger.ts @@ -81,7 +81,8 @@ export function expressRequestLogger(req: Request, res: Response, next: NextFunc GET: '⬇️', POST: '⬆️', PUT: '🖊', - DELETE: '❌' + DELETE: '❌', + OPTION: '🔗' } return requetsEmojis[method] || '?' diff --git a/packages/ui/src/api/credentials.js b/packages/ui/src/api/credentials.js new file mode 100644 index 000000000..9dbdcf7ad --- /dev/null +++ b/packages/ui/src/api/credentials.js @@ -0,0 +1,28 @@ +import client from './client' + +const getAllCredentials = () => client.get('/credentials') + +const getCredentialsByName = (componentCredentialName) => client.get(`/credentials?credentialName=${componentCredentialName}`) + +const getAllComponentsCredentials = () => client.get('/components-credentials') + +const getSpecificCredential = (id) => client.get(`/credentials/${id}`) + +const getSpecificComponentCredential = (name) => client.get(`/components-credentials/${name}`) + +const createCredential = (body) => client.post(`/credentials`, body) + +const updateCredential = (id, body) => client.put(`/credentials/${id}`, body) + +const deleteCredential = (id) => client.delete(`/credentials/${id}`) + +export default { + getAllCredentials, + getCredentialsByName, + getAllComponentsCredentials, + getSpecificCredential, + getSpecificComponentCredential, + createCredential, + updateCredential, + deleteCredential +} diff --git a/packages/ui/src/assets/images/credential_empty.svg b/packages/ui/src/assets/images/credential_empty.svg new file mode 100644 index 000000000..0951ee07a --- /dev/null +++ b/packages/ui/src/assets/images/credential_empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/menu-items/dashboard.js b/packages/ui/src/menu-items/dashboard.js index 948b4e4a1..87ef88f98 100644 --- a/packages/ui/src/menu-items/dashboard.js +++ b/packages/ui/src/menu-items/dashboard.js @@ -1,8 +1,8 @@ // assets -import { IconHierarchy, IconBuildingStore, IconKey, IconTool } from '@tabler/icons' +import { IconHierarchy, IconBuildingStore, IconKey, IconTool, IconLock } from '@tabler/icons' // constant -const icons = { IconHierarchy, IconBuildingStore, IconKey, IconTool } +const icons = { IconHierarchy, IconBuildingStore, IconKey, IconTool, IconLock } // ==============================|| DASHBOARD MENU ITEMS ||============================== // @@ -35,6 +35,14 @@ const dashboard = { icon: icons.IconTool, breadcrumbs: true }, + { + id: 'credentials', + title: 'Credentials', + type: 'item', + url: '/credentials', + icon: icons.IconLock, + breadcrumbs: true + }, { id: 'apikey', title: 'API Keys', diff --git a/packages/ui/src/routes/MainRoutes.js b/packages/ui/src/routes/MainRoutes.js index 28e602873..9a1c29aff 100644 --- a/packages/ui/src/routes/MainRoutes.js +++ b/packages/ui/src/routes/MainRoutes.js @@ -13,9 +13,12 @@ const Marketplaces = Loadable(lazy(() => import('views/marketplaces'))) // apikey routing const APIKey = Loadable(lazy(() => import('views/apikey'))) -// apikey routing +// tools routing const Tools = Loadable(lazy(() => import('views/tools'))) +// credentials routing +const Credentials = Loadable(lazy(() => import('views/credentials'))) + // ==============================|| MAIN ROUTING ||============================== // const MainRoutes = { @@ -41,6 +44,10 @@ const MainRoutes = { { path: '/tools', element: + }, + { + path: '/credentials', + element: } ] } diff --git a/packages/ui/src/store/constant.js b/packages/ui/src/store/constant.js index c3138257e..c0fce49db 100644 --- a/packages/ui/src/store/constant.js +++ b/packages/ui/src/store/constant.js @@ -5,3 +5,4 @@ export const appDrawerWidth = 320 export const maxScroll = 100000 export const baseURL = process.env.NODE_ENV === 'production' ? window.location.origin : window.location.origin.replace(':8080', ':3000') export const uiBaseURL = window.location.origin +export const FLOWISE_CREDENTIAL_ID = 'FLOWISE_CREDENTIAL_ID' diff --git a/packages/ui/src/ui-component/dropdown/AsyncDropdown.js b/packages/ui/src/ui-component/dropdown/AsyncDropdown.js index 8dfd782db..b24fa02b5 100644 --- a/packages/ui/src/ui-component/dropdown/AsyncDropdown.js +++ b/packages/ui/src/ui-component/dropdown/AsyncDropdown.js @@ -1,13 +1,17 @@ import { useState, useEffect, Fragment } from 'react' import { useSelector } from 'react-redux' - import PropTypes from 'prop-types' import axios from 'axios' +// Material import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete' import { Popper, CircularProgress, TextField, Box, Typography } from '@mui/material' import { styled } from '@mui/material/styles' +// API +import credentialsApi from 'api/credentials' + +// const import { baseURL } from 'store/constant' const StyledPopper = styled(Popper)({ @@ -49,6 +53,7 @@ export const AsyncDropdown = ({ onSelect, isCreateNewOption, onCreateNew, + credentialNames = [], disabled = false, disableClearable = false }) => { @@ -62,11 +67,36 @@ export const AsyncDropdown = ({ const addNewOption = [{ label: '- Create New -', name: '-create-' }] let [internalValue, setInternalValue] = useState(value ?? 'choose an option') + const fetchCredentialList = async () => { + try { + let names = '' + if (credentialNames.length > 1) { + names = credentialNames.join('&credentialName=') + } else { + names = credentialNames[0] + } + const resp = await credentialsApi.getCredentialsByName(names) + if (resp.data) { + const returnList = [] + for (let i = 0; i < resp.data.length; i += 1) { + const data = { + label: resp.data[i].name, + name: resp.data[i].id + } + returnList.push(data) + } + return returnList + } + } catch (error) { + console.error(error) + } + } + useEffect(() => { setLoading(true) ;(async () => { const fetchData = async () => { - let response = await fetchList({ name, nodeData }) + let response = credentialNames.length ? await fetchCredentialList() : await fetchList({ name, nodeData }) if (isCreateNewOption) setOptions([...response, ...addNewOption]) else setOptions([...response]) setLoading(false) @@ -142,6 +172,7 @@ AsyncDropdown.propTypes = { onSelect: PropTypes.func, onCreateNew: PropTypes.func, disabled: PropTypes.bool, + credentialNames: PropTypes.array, disableClearable: PropTypes.bool, isCreateNewOption: PropTypes.bool } diff --git a/packages/ui/src/ui-component/input/Input.js b/packages/ui/src/ui-component/input/Input.js index e77447641..8f2d55e07 100644 --- a/packages/ui/src/ui-component/input/Input.js +++ b/packages/ui/src/ui-component/input/Input.js @@ -37,7 +37,7 @@ export const Input = ({ inputParam, value, onChange, disabled = false, showDialo onChange(e.target.value) }} inputProps={{ - step: 0.1, + step: inputParam.step ?? 0.1, style: { height: inputParam.rows ? '90px' : 'inherit' } diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index 305326f7a..eb3b08bc7 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -41,6 +41,7 @@ export const initNode = (nodeData, newNodeId) => { const whitelistTypes = ['asyncOptions', 'options', 'string', 'number', 'boolean', 'password', 'json', 'code', 'date', 'file', 'folder'] + // Inputs for (let i = 0; i < incoming; i += 1) { const newInput = { ...nodeData.inputs[i], @@ -53,6 +54,16 @@ export const initNode = (nodeData, newNodeId) => { } } + // Credential + if (nodeData.credential) { + const newInput = { + ...nodeData.credential, + id: `${newNodeId}-input-${nodeData.credential.name}-${nodeData.credential.type}` + } + inputParams.unshift(newInput) + } + + // Outputs const outputAnchors = [] for (let i = 0; i < outgoing; i += 1) { if (nodeData.outputs && nodeData.outputs.length) { @@ -129,6 +140,8 @@ export const initNode = (nodeData, newNodeId) => { } ] */ + + // Inputs if (nodeData.inputs) { nodeData.inputAnchors = inputAnchors nodeData.inputParams = inputParams @@ -139,13 +152,17 @@ export const initNode = (nodeData, newNodeId) => { nodeData.inputs = {} } + // Outputs if (nodeData.outputs) { nodeData.outputs = initializeDefaultNodeData(outputAnchors) } else { nodeData.outputs = {} } - nodeData.outputAnchors = outputAnchors + + // Credential + if (nodeData.credential) nodeData.credential = '' + nodeData.id = newNodeId return nodeData diff --git a/packages/ui/src/views/canvas/CredentialInputHandler.js b/packages/ui/src/views/canvas/CredentialInputHandler.js new file mode 100644 index 000000000..4f8747191 --- /dev/null +++ b/packages/ui/src/views/canvas/CredentialInputHandler.js @@ -0,0 +1,149 @@ +import PropTypes from 'prop-types' +import { useRef, useState } from 'react' + +// material-ui +import { IconButton } from '@mui/material' +import { IconEdit } from '@tabler/icons' + +// project import +import { AsyncDropdown } from 'ui-component/dropdown/AsyncDropdown' +import AddEditCredentialDialog from 'views/credentials/AddEditCredentialDialog' +import CredentialListDialog from 'views/credentials/CredentialListDialog' + +// API +import credentialsApi from 'api/credentials' + +// ===========================|| CredentialInputHandler ||=========================== // + +const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false }) => { + const ref = useRef(null) + const [credentialId, setCredentialId] = useState(data?.credential ?? '') + const [showCredentialListDialog, setShowCredentialListDialog] = useState(false) + const [credentialListDialogProps, setCredentialListDialogProps] = useState({}) + const [showSpecificCredentialDialog, setShowSpecificCredentialDialog] = useState(false) + const [specificCredentialDialogProps, setSpecificCredentialDialogProps] = useState({}) + const [reloadTimestamp, setReloadTimestamp] = useState(Date.now().toString()) + + const editCredential = (credentialId) => { + const dialogProp = { + type: 'EDIT', + cancelButtonName: 'Cancel', + confirmButtonName: 'Save', + credentialId + } + setSpecificCredentialDialogProps(dialogProp) + setShowSpecificCredentialDialog(true) + } + + const addAsyncOption = async () => { + try { + let names = '' + if (inputParam.credentialNames.length > 1) { + names = inputParam.credentialNames.join('&') + } else { + names = inputParam.credentialNames[0] + } + const componentCredentialsResp = await credentialsApi.getSpecificComponentCredential(names) + if (componentCredentialsResp.data) { + if (Array.isArray(componentCredentialsResp.data)) { + const dialogProp = { + title: 'Add New Credential', + componentsCredentials: componentCredentialsResp.data + } + setCredentialListDialogProps(dialogProp) + setShowCredentialListDialog(true) + } else { + const dialogProp = { + type: 'ADD', + cancelButtonName: 'Cancel', + confirmButtonName: 'Add', + credentialComponent: componentCredentialsResp.data + } + setSpecificCredentialDialogProps(dialogProp) + setShowSpecificCredentialDialog(true) + } + } + } catch (error) { + console.error(error) + } + } + + const onConfirmAsyncOption = (selectedCredentialId = '') => { + setCredentialId(selectedCredentialId) + setReloadTimestamp(Date.now().toString()) + setSpecificCredentialDialogProps({}) + setShowSpecificCredentialDialog(false) + onSelect(selectedCredentialId) + } + + const onCredentialSelected = (credentialComponent) => { + setShowCredentialListDialog(false) + const dialogProp = { + type: 'ADD', + cancelButtonName: 'Cancel', + confirmButtonName: 'Add', + credentialComponent + } + setSpecificCredentialDialogProps(dialogProp) + setShowSpecificCredentialDialog(true) + } + + return ( +
+ {inputParam && ( + <> + {inputParam.type === 'credential' && ( + <> +
+
+ { + setCredentialId(newValue) + onSelect(newValue) + }} + onCreateNew={() => addAsyncOption(inputParam.name)} + /> + {credentialId && ( + editCredential(credentialId)}> + + + )} +
+ + )} + + )} + {showSpecificCredentialDialog && ( + setShowSpecificCredentialDialog(false)} + onConfirm={onConfirmAsyncOption} + > + )} + {showCredentialListDialog && ( + setShowCredentialListDialog(false)} + onCredentialSelected={onCredentialSelected} + > + )} +
+ ) +} + +CredentialInputHandler.propTypes = { + inputParam: PropTypes.object, + data: PropTypes.object, + onSelect: PropTypes.func, + disabled: PropTypes.bool +} + +export default CredentialInputHandler diff --git a/packages/ui/src/views/canvas/NodeInputHandler.js b/packages/ui/src/views/canvas/NodeInputHandler.js index ba72a4cef..176df52f8 100644 --- a/packages/ui/src/views/canvas/NodeInputHandler.js +++ b/packages/ui/src/views/canvas/NodeInputHandler.js @@ -21,9 +21,14 @@ import { JsonEditorInput } from 'ui-component/json/JsonEditor' import { TooltipWithParser } from 'ui-component/tooltip/TooltipWithParser' import ToolDialog from 'views/tools/ToolDialog' import FormatPromptValuesDialog from 'ui-component/dialog/FormatPromptValuesDialog' +import CredentialInputHandler from './CredentialInputHandler' +// utils import { getInputVariables } from 'utils/genericHelper' +// const +import { FLOWISE_CREDENTIAL_ID } from 'store/constant' + const EDITABLE_TOOLS = ['selectedTool'] const CustomWidthTooltip = styled(({ className, ...props }) => )({ @@ -226,6 +231,17 @@ const NodeInputHandler = ({ inputAnchor, inputParam, data, disabled = false, isA {inputParam.warning}
)} + {inputParam.type === 'credential' && ( + { + data.credential = newValue + data.inputs[FLOWISE_CREDENTIAL_ID] = newValue // in case data.credential is not updated + }} + /> + )} {inputParam.type === 'file' && ( { const handleSaveFlow = (chatflowName) => { if (reactFlowInstance) { - setNodes((nds) => - nds.map((node) => { - node.data = { - ...node.data, - selected: false - } - return node - }) - ) + const nodes = reactFlowInstance.getNodes().map((node) => { + const nodeData = cloneDeep(node.data) + if (Object.prototype.hasOwnProperty.call(nodeData.inputs, FLOWISE_CREDENTIAL_ID)) { + nodeData.credential = nodeData.inputs[FLOWISE_CREDENTIAL_ID] + nodeData.inputs = omit(nodeData.inputs, [FLOWISE_CREDENTIAL_ID]) + } + node.data = { + ...nodeData, + selected: false + } + return node + }) const rfInstanceObject = reactFlowInstance.toObject() + rfInstanceObject.nodes = nodes const flowData = JSON.stringify(rfInstanceObject) if (!chatflow.id) { diff --git a/packages/ui/src/views/credentials/AddEditCredentialDialog.js b/packages/ui/src/views/credentials/AddEditCredentialDialog.js new file mode 100644 index 000000000..6a5c95686 --- /dev/null +++ b/packages/ui/src/views/credentials/AddEditCredentialDialog.js @@ -0,0 +1,276 @@ +import { createPortal } from 'react-dom' +import PropTypes from 'prop-types' +import { useState, useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions' +import parser from 'html-react-parser' + +// Material +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Box, Stack, OutlinedInput, Typography } from '@mui/material' + +// Project imports +import { StyledButton } from 'ui-component/button/StyledButton' +import ConfirmDialog from 'ui-component/dialog/ConfirmDialog' +import CredentialInputHandler from './CredentialInputHandler' + +// Icons +import { IconX } from '@tabler/icons' + +// API +import credentialsApi from 'api/credentials' + +// Hooks +import useApi from 'hooks/useApi' + +// utils +import useNotifier from 'utils/useNotifier' + +// const +import { baseURL } from 'store/constant' + +const AddEditCredentialDialog = ({ show, dialogProps, onCancel, onConfirm }) => { + const portalElement = document.getElementById('portal') + + const dispatch = useDispatch() + + // ==============================|| Snackbar ||============================== // + + useNotifier() + + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const getSpecificCredentialApi = useApi(credentialsApi.getSpecificCredential) + const getSpecificComponentCredentialApi = useApi(credentialsApi.getSpecificComponentCredential) + + const [credential, setCredential] = useState({}) + const [name, setName] = useState('') + const [credentialData, setCredentialData] = useState({}) + const [componentCredential, setComponentCredential] = useState({}) + + useEffect(() => { + if (getSpecificCredentialApi.data) { + setCredential(getSpecificCredentialApi.data) + if (getSpecificCredentialApi.data.name) { + setName(getSpecificCredentialApi.data.name) + } + if (getSpecificCredentialApi.data.plainDataObj) { + setCredentialData(getSpecificCredentialApi.data.plainDataObj) + } + getSpecificComponentCredentialApi.request(getSpecificCredentialApi.data.credentialName) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getSpecificCredentialApi.data]) + + useEffect(() => { + if (getSpecificComponentCredentialApi.data) { + setComponentCredential(getSpecificComponentCredentialApi.data) + } + }, [getSpecificComponentCredentialApi.data]) + + useEffect(() => { + if (dialogProps.type === 'EDIT' && dialogProps.data) { + // When credential dialog is opened from Credentials dashboard + getSpecificCredentialApi.request(dialogProps.data.id) + } else if (dialogProps.type === 'EDIT' && dialogProps.credentialId) { + // When credential dialog is opened from node in canvas + getSpecificCredentialApi.request(dialogProps.credentialId) + } else if (dialogProps.type === 'ADD' && dialogProps.credentialComponent) { + // When credential dialog is to add a new credential + setName('') + setCredential({}) + setCredentialData({}) + setComponentCredential(dialogProps.credentialComponent) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dialogProps]) + + const addNewCredential = async () => { + try { + const obj = { + name, + credentialName: componentCredential.name, + plainDataObj: credentialData + } + const createResp = await credentialsApi.createCredential(obj) + if (createResp.data) { + enqueueSnackbar({ + message: 'New Credential added', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + onConfirm(createResp.data.id) + } + } catch (error) { + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + enqueueSnackbar({ + message: `Failed to add new Credential: ${errorData}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + onCancel() + } + } + + const saveCredential = async () => { + try { + const saveResp = await credentialsApi.updateCredential(credential.id, { + name, + credentialName: componentCredential.name, + plainDataObj: credentialData + }) + if (saveResp.data) { + enqueueSnackbar({ + message: 'Credential saved', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + onConfirm(saveResp.data.id) + } + } catch (error) { + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + enqueueSnackbar({ + message: `Failed to save Credential: ${errorData}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + onCancel() + } + } + + const component = show ? ( + + + {componentCredential && componentCredential.label && ( +
+
+ {componentCredential.name} +
+ {componentCredential.label} +
+ )} +
+ + {componentCredential && componentCredential.description && ( + +
+ {parser(componentCredential.description)} +
+
+ )} + {componentCredential && componentCredential.label && ( + + + + Credential Name +  * + + + setName(e.target.value)} + /> + + )} + {componentCredential && + componentCredential.inputs && + componentCredential.inputs.map((inputParam, index) => ( + + ))} +
+ + (dialogProps.type === 'ADD' ? addNewCredential() : saveCredential())} + > + {dialogProps.confirmButtonName} + + + +
+ ) : null + + return createPortal(component, portalElement) +} + +AddEditCredentialDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onCancel: PropTypes.func, + onConfirm: PropTypes.func +} + +export default AddEditCredentialDialog diff --git a/packages/ui/src/views/credentials/CredentialInputHandler.js b/packages/ui/src/views/credentials/CredentialInputHandler.js new file mode 100644 index 000000000..30cc57466 --- /dev/null +++ b/packages/ui/src/views/credentials/CredentialInputHandler.js @@ -0,0 +1,137 @@ +import PropTypes from 'prop-types' +import { useRef, useState } from 'react' +import { useSelector } from 'react-redux' + +// material-ui +import { Box, Typography, IconButton } from '@mui/material' +import { IconArrowsMaximize, IconAlertTriangle } from '@tabler/icons' + +// project import +import { Dropdown } from 'ui-component/dropdown/Dropdown' +import { Input } from 'ui-component/input/Input' +import { SwitchInput } from 'ui-component/switch/Switch' +import { JsonEditorInput } from 'ui-component/json/JsonEditor' +import { TooltipWithParser } from 'ui-component/tooltip/TooltipWithParser' + +// ===========================|| NodeInputHandler ||=========================== // + +const CredentialInputHandler = ({ inputParam, data, disabled = false }) => { + const customization = useSelector((state) => state.customization) + const ref = useRef(null) + + const [showExpandDialog, setShowExpandDialog] = useState(false) + const [expandDialogProps, setExpandDialogProps] = useState({}) + + const onExpandDialogClicked = (value, inputParam) => { + const dialogProp = { + value, + inputParam, + disabled, + confirmButtonName: 'Save', + cancelButtonName: 'Cancel' + } + setExpandDialogProps(dialogProp) + setShowExpandDialog(true) + } + + const onExpandDialogSave = (newValue, inputParamName) => { + setShowExpandDialog(false) + data[inputParamName] = newValue + } + + return ( +
+ {inputParam && ( + <> + +
+ + {inputParam.label} + {!inputParam.optional &&  *} + {inputParam.description && } + +
+ {inputParam.type === 'string' && inputParam.rows && ( + onExpandDialogClicked(data[inputParam.name] ?? inputParam.default ?? '', inputParam)} + > + + + )} +
+ {inputParam.warning && ( +
+ + {inputParam.warning} +
+ )} + + {inputParam.type === 'boolean' && ( + (data[inputParam.name] = newValue)} + value={data[inputParam.name] ?? inputParam.default ?? false} + /> + )} + {(inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') && ( + (data[inputParam.name] = newValue)} + value={data[inputParam.name] ?? inputParam.default ?? ''} + showDialog={showExpandDialog} + dialogProps={expandDialogProps} + onDialogCancel={() => setShowExpandDialog(false)} + onDialogConfirm={(newValue, inputParamName) => onExpandDialogSave(newValue, inputParamName)} + /> + )} + {inputParam.type === 'json' && ( + (data[inputParam.name] = newValue)} + value={data[inputParam.name] ?? inputParam.default ?? ''} + isDarkMode={customization.isDarkMode} + /> + )} + {inputParam.type === 'options' && ( + (data[inputParam.name] = newValue)} + value={data[inputParam.name] ?? inputParam.default ?? 'choose an option'} + /> + )} +
+ + )} +
+ ) +} + +CredentialInputHandler.propTypes = { + inputAnchor: PropTypes.object, + inputParam: PropTypes.object, + data: PropTypes.object, + disabled: PropTypes.bool +} + +export default CredentialInputHandler diff --git a/packages/ui/src/views/credentials/CredentialListDialog.js b/packages/ui/src/views/credentials/CredentialListDialog.js new file mode 100644 index 000000000..9333db67f --- /dev/null +++ b/packages/ui/src/views/credentials/CredentialListDialog.js @@ -0,0 +1,172 @@ +import { useState, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { useSelector } from 'react-redux' +import PropTypes from 'prop-types' +import { + List, + ListItemButton, + ListItem, + ListItemAvatar, + ListItemText, + Dialog, + DialogContent, + DialogTitle, + Box, + OutlinedInput, + InputAdornment +} from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { IconSearch, IconX } from '@tabler/icons' + +// const +import { baseURL } from 'store/constant' + +const CredentialListDialog = ({ show, dialogProps, onCancel, onCredentialSelected }) => { + const portalElement = document.getElementById('portal') + const customization = useSelector((state) => state.customization) + + const theme = useTheme() + const [searchValue, setSearchValue] = useState('') + const [componentsCredentials, setComponentsCredentials] = useState([]) + + const filterSearch = (value) => { + setSearchValue(value) + setTimeout(() => { + if (value) { + const searchData = dialogProps.componentsCredentials.filter((crd) => crd.name.toLowerCase().includes(value.toLowerCase())) + setComponentsCredentials(searchData) + } else if (value === '') { + setComponentsCredentials(dialogProps.componentsCredentials) + } + // scrollTop() + }, 500) + } + + useEffect(() => { + if (show && dialogProps.componentsCredentials) { + setComponentsCredentials(dialogProps.componentsCredentials) + } + }, [show, dialogProps]) + + const component = show ? ( + + + {dialogProps.title} + + filterSearch(e.target.value)} + placeholder='Search credential' + startAdornment={ + + + + } + endAdornment={ + + filterSearch('')} + style={{ + cursor: 'pointer' + }} + /> + + } + aria-describedby='search-helper-text' + inputProps={{ + 'aria-label': 'weight' + }} + /> + + + + + {[...componentsCredentials].map((componentCredential) => ( +
+ onCredentialSelected(componentCredential)} + sx={{ p: 0, borderRadius: `${customization.borderRadius}px` }} + > + + +
+ {componentCredential.name} +
+
+ +
+
+
+ ))} +
+
+
+ ) : null + + return createPortal(component, portalElement) +} + +CredentialListDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onCancel: PropTypes.func, + onCredentialSelected: PropTypes.func +} + +export default CredentialListDialog diff --git a/packages/ui/src/views/credentials/index.js b/packages/ui/src/views/credentials/index.js new file mode 100644 index 000000000..bcd641ed9 --- /dev/null +++ b/packages/ui/src/views/credentials/index.js @@ -0,0 +1,274 @@ +import { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions' +import moment from 'moment' + +// material-ui +import { Button, Box, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, IconButton } from '@mui/material' +import { useTheme } from '@mui/material/styles' + +// project imports +import MainCard from 'ui-component/cards/MainCard' +import { StyledButton } from 'ui-component/button/StyledButton' +import CredentialListDialog from './CredentialListDialog' +import ConfirmDialog from 'ui-component/dialog/ConfirmDialog' +import AddEditCredentialDialog from './AddEditCredentialDialog' + +// API +import credentialsApi from 'api/credentials' + +// Hooks +import useApi from 'hooks/useApi' +import useConfirm from 'hooks/useConfirm' + +// utils +import useNotifier from 'utils/useNotifier' + +// Icons +import { IconTrash, IconEdit, IconX, IconPlus } from '@tabler/icons' +import CredentialEmptySVG from 'assets/images/credential_empty.svg' + +// const +import { baseURL } from 'store/constant' + +// ==============================|| Credentials ||============================== // + +const Credentials = () => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + const dispatch = useDispatch() + useNotifier() + + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [showCredentialListDialog, setShowCredentialListDialog] = useState(false) + const [credentialListDialogProps, setCredentialListDialogProps] = useState({}) + const [showSpecificCredentialDialog, setShowSpecificCredentialDialog] = useState(false) + const [specificCredentialDialogProps, setSpecificCredentialDialogProps] = useState({}) + const [credentials, setCredentials] = useState([]) + const [componentsCredentials, setComponentsCredentials] = useState([]) + + const { confirm } = useConfirm() + + const getAllCredentialsApi = useApi(credentialsApi.getAllCredentials) + const getAllComponentsCredentialsApi = useApi(credentialsApi.getAllComponentsCredentials) + + const listCredential = () => { + const dialogProp = { + title: 'Add New Credential', + componentsCredentials + } + setCredentialListDialogProps(dialogProp) + setShowCredentialListDialog(true) + } + + const addNew = (credentialComponent) => { + const dialogProp = { + type: 'ADD', + cancelButtonName: 'Cancel', + confirmButtonName: 'Add', + credentialComponent + } + setSpecificCredentialDialogProps(dialogProp) + setShowSpecificCredentialDialog(true) + } + + const edit = (credential) => { + const dialogProp = { + type: 'EDIT', + cancelButtonName: 'Cancel', + confirmButtonName: 'Save', + data: credential + } + setSpecificCredentialDialogProps(dialogProp) + setShowSpecificCredentialDialog(true) + } + + const deleteCredential = async (credential) => { + const confirmPayload = { + title: `Delete`, + description: `Delete credential ${credential.name}?`, + confirmButtonName: 'Delete', + cancelButtonName: 'Cancel' + } + const isConfirmed = await confirm(confirmPayload) + + if (isConfirmed) { + try { + const deleteResp = await credentialsApi.deleteCredential(credential.id) + if (deleteResp.data) { + enqueueSnackbar({ + message: 'Credential deleted', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + onConfirm() + } + } catch (error) { + const errorData = error.response.data || `${error.response.status}: ${error.response.statusText}` + enqueueSnackbar({ + message: `Failed to delete Credential: ${errorData}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + onCancel() + } + } + } + + const onCredentialSelected = (credentialComponent) => { + setShowCredentialListDialog(false) + addNew(credentialComponent) + } + + const onConfirm = () => { + setShowCredentialListDialog(false) + setShowSpecificCredentialDialog(false) + getAllCredentialsApi.request() + } + + useEffect(() => { + getAllCredentialsApi.request() + getAllComponentsCredentialsApi.request() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (getAllCredentialsApi.data) { + setCredentials(getAllCredentialsApi.data) + } + }, [getAllCredentialsApi.data]) + + useEffect(() => { + if (getAllComponentsCredentialsApi.data) { + setComponentsCredentials(getAllComponentsCredentialsApi.data) + } + }, [getAllComponentsCredentialsApi.data]) + + return ( + <> + + +

Credentials 

+ + + } + > + Add Credential + +
+ {credentials.length <= 0 && ( + + + CredentialEmptySVG + +
No Credentials Yet
+
+ )} + {credentials.length > 0 && ( + + + + + Name + Last Updated + Created + + + + + + {credentials.map((credential, index) => ( + + +
+
+ {credential.credentialName} +
+ {credential.name} +
+
+ {moment(credential.updatedDate).format('DD-MMM-YY')} + {moment(credential.createdDate).format('DD-MMM-YY')} + + edit(credential)}> + + + + + deleteCredential(credential)}> + + + +
+ ))} +
+
+
+ )} +
+ setShowCredentialListDialog(false)} + onCredentialSelected={onCredentialSelected} + > + setShowSpecificCredentialDialog(false)} + onConfirm={onConfirm} + > + + + ) +} + +export default Credentials