diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 822b6343c..8e3c7b6b1 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -9,6 +9,7 @@ export interface IChatFlow { id: string name: string flowData: string + apikeyid: string deployed: boolean updatedDate: Date createdDate: Date diff --git a/packages/server/src/entity/ChatFlow.ts b/packages/server/src/entity/ChatFlow.ts index 212dfab5f..d9b129294 100644 --- a/packages/server/src/entity/ChatFlow.ts +++ b/packages/server/src/entity/ChatFlow.ts @@ -13,6 +13,9 @@ export class ChatFlow implements IChatFlow { @Column() flowData: string + @Column({ nullable: true }) + apikeyid: string + @Column() deployed: boolean diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7246e7c82..2318aab30 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -12,7 +12,12 @@ import { getEndingNode, constructGraphs, resolveVariables, - isStartNodeDependOnInput + isStartNodeDependOnInput, + getAPIKeys, + addAPIKey, + updateAPIKey, + deleteAPIKey, + compareKeys } from './utils' import { cloneDeep } from 'lodash' import { getDataSource } from './DataSource' @@ -42,6 +47,9 @@ export class App { await this.nodesPool.initialize() this.chatflowPool = new ChatflowPool() + + // Initialize API keys + await getAPIKeys() }) .catch((err) => { console.error('❌[server]: Error during Data Source initialization:', err) @@ -195,93 +203,14 @@ export class App { // Prediction // ---------------------------------------- - // Send input message and get prediction result + // Send input message and get prediction result (External) this.app.post('/api/v1/prediction/:id', async (req: Request, res: Response) => { - try { - const chatflowid = req.params.id - const incomingInput: IncomingInput = req.body + await this.processPrediction(req, res) + }) - let nodeToExecuteData: INodeData - - /* Check if: - * - Node Data already exists in pool - * - Still in sync (i.e the flow has not been modified since) - * - Flow doesn't start with nodes that depend on incomingInput.question - ***/ - if ( - Object.prototype.hasOwnProperty.call(this.chatflowPool.activeChatflows, chatflowid) && - this.chatflowPool.activeChatflows[chatflowid].inSync && - !isStartNodeDependOnInput(this.chatflowPool.activeChatflows[chatflowid].startingNodes) - ) { - nodeToExecuteData = this.chatflowPool.activeChatflows[chatflowid].endingNodeData - } else { - /*** Get chatflows and prepare data ***/ - const chatflow = await this.AppDataSource.getRepository(ChatFlow).findOneBy({ - id: chatflowid - }) - if (!chatflow) return res.status(404).send(`Chatflow ${chatflowid} not found`) - - const flowData = chatflow.flowData - const parsedFlowData: IReactFlowObject = JSON.parse(flowData) - const nodes = parsedFlowData.nodes - const edges = parsedFlowData.edges - - /*** Get Ending Node with Directed Graph ***/ - const { graph, nodeDependencies } = constructGraphs(nodes, edges) - const directedGraph = graph - const endingNodeId = getEndingNode(nodeDependencies, directedGraph) - if (!endingNodeId) return res.status(500).send(`Ending node must be either a Chain or Agent`) - - const endingNodeData = nodes.find((nd) => nd.id === endingNodeId)?.data - if (!endingNodeData) return res.status(500).send(`Ending node must be either a Chain or Agent`) - - if ( - endingNodeData.outputs && - Object.keys(endingNodeData.outputs).length && - !Object.values(endingNodeData.outputs).includes(endingNodeData.name) - ) { - return res - .status(500) - .send( - `Output of ${endingNodeData.label} (${endingNodeData.id}) must be ${endingNodeData.label}, can't be an Output Prediction` - ) - } - - /*** Get Starting Nodes with Non-Directed Graph ***/ - const constructedObj = constructGraphs(nodes, edges, true) - const nonDirectedGraph = constructedObj.graph - const { startingNodeIds, depthQueue } = getStartingNodes(nonDirectedGraph, endingNodeId) - - /*** BFS to traverse from Starting Nodes to Ending Node ***/ - const reactFlowNodes = await buildLangchain( - startingNodeIds, - nodes, - graph, - depthQueue, - this.nodesPool.componentNodes, - incomingInput.question - ) - - const nodeToExecute = reactFlowNodes.find((node: IReactFlowNode) => node.id === endingNodeId) - if (!nodeToExecute) return res.status(404).send(`Node ${endingNodeId} not found`) - - const reactFlowNodeData: INodeData = resolveVariables(nodeToExecute.data, reactFlowNodes, incomingInput.question) - nodeToExecuteData = reactFlowNodeData - - const startingNodes = nodes.filter((nd) => startingNodeIds.includes(nd.id)) - this.chatflowPool.add(chatflowid, nodeToExecuteData, startingNodes) - } - - const nodeInstanceFilePath = this.nodesPool.componentNodes[nodeToExecuteData.name].filePath as string - const nodeModule = await import(nodeInstanceFilePath) - const nodeInstance = new nodeModule.nodeClass() - - const result = await nodeInstance.run(nodeToExecuteData, incomingInput.question, { chatHistory: incomingInput.history }) - - return res.json(result) - } catch (e: any) { - return res.status(500).send(e.message) - } + // Send input message and get prediction result (Internal) + this.app.post('/api/v1/internal-prediction/:id', async (req: Request, res: Response) => { + await this.processPrediction(req, res, true) }) // ---------------------------------------- @@ -308,6 +237,34 @@ export class App { return res.json(templates) }) + // ---------------------------------------- + // API Keys + // ---------------------------------------- + + // Get api keys + this.app.get('/api/v1/apikey', async (req: Request, res: Response) => { + const keys = await getAPIKeys() + return res.json(keys) + }) + + // Add new api key + this.app.post('/api/v1/apikey', async (req: Request, res: Response) => { + const keys = await addAPIKey(req.body.keyName) + return res.json(keys) + }) + + // Update api key + this.app.put('/api/v1/apikey/:id', async (req: Request, res: Response) => { + const keys = await updateAPIKey(req.params.id, req.body.keyName) + return res.json(keys) + }) + + // Delete new api key + this.app.delete('/api/v1/apikey/:id', async (req: Request, res: Response) => { + const keys = await deleteAPIKey(req.params.id) + return res.json(keys) + }) + // ---------------------------------------- // Serve UI static // ---------------------------------------- @@ -324,6 +281,109 @@ export class App { }) } + async processPrediction(req: Request, res: Response, isInternal = false) { + try { + const chatflowid = req.params.id + const incomingInput: IncomingInput = req.body + + let nodeToExecuteData: INodeData + + const chatflow = await this.AppDataSource.getRepository(ChatFlow).findOneBy({ + id: chatflowid + }) + if (!chatflow) return res.status(404).send(`Chatflow ${chatflowid} not found`) + + if (!isInternal) { + const chatFlowApiKeyId = chatflow.apikeyid + const authorizationHeader = (req.headers['Authorization'] as string) ?? (req.headers['authorization'] as string) ?? '' + + if (chatFlowApiKeyId && !authorizationHeader) return res.status(401).send(`Unauthorized`) + + const suppliedKey = authorizationHeader.split(`Bearer `).pop() + if (chatFlowApiKeyId && suppliedKey) { + const keys = await getAPIKeys() + const apiSecret = keys.find((key) => key.id === chatFlowApiKeyId)?.apiSecret + if (!compareKeys(apiSecret, suppliedKey)) return res.status(401).send(`Unauthorized`) + } + } + + /* Check if: + * - Node Data already exists in pool + * - Still in sync (i.e the flow has not been modified since) + * - Flow doesn't start with nodes that depend on incomingInput.question + ***/ + if ( + Object.prototype.hasOwnProperty.call(this.chatflowPool.activeChatflows, chatflowid) && + this.chatflowPool.activeChatflows[chatflowid].inSync && + !isStartNodeDependOnInput(this.chatflowPool.activeChatflows[chatflowid].startingNodes) + ) { + nodeToExecuteData = this.chatflowPool.activeChatflows[chatflowid].endingNodeData + } else { + /*** Get chatflows and prepare data ***/ + + const flowData = chatflow.flowData + const parsedFlowData: IReactFlowObject = JSON.parse(flowData) + const nodes = parsedFlowData.nodes + const edges = parsedFlowData.edges + + /*** Get Ending Node with Directed Graph ***/ + const { graph, nodeDependencies } = constructGraphs(nodes, edges) + const directedGraph = graph + const endingNodeId = getEndingNode(nodeDependencies, directedGraph) + if (!endingNodeId) return res.status(500).send(`Ending node must be either a Chain or Agent`) + + const endingNodeData = nodes.find((nd) => nd.id === endingNodeId)?.data + if (!endingNodeData) return res.status(500).send(`Ending node must be either a Chain or Agent`) + + if ( + endingNodeData.outputs && + Object.keys(endingNodeData.outputs).length && + !Object.values(endingNodeData.outputs).includes(endingNodeData.name) + ) { + return res + .status(500) + .send( + `Output of ${endingNodeData.label} (${endingNodeData.id}) must be ${endingNodeData.label}, can't be an Output Prediction` + ) + } + + /*** Get Starting Nodes with Non-Directed Graph ***/ + const constructedObj = constructGraphs(nodes, edges, true) + const nonDirectedGraph = constructedObj.graph + const { startingNodeIds, depthQueue } = getStartingNodes(nonDirectedGraph, endingNodeId) + + /*** BFS to traverse from Starting Nodes to Ending Node ***/ + const reactFlowNodes = await buildLangchain( + startingNodeIds, + nodes, + graph, + depthQueue, + this.nodesPool.componentNodes, + incomingInput.question + ) + + const nodeToExecute = reactFlowNodes.find((node: IReactFlowNode) => node.id === endingNodeId) + if (!nodeToExecute) return res.status(404).send(`Node ${endingNodeId} not found`) + + const reactFlowNodeData: INodeData = resolveVariables(nodeToExecute.data, reactFlowNodes, incomingInput.question) + nodeToExecuteData = reactFlowNodeData + + const startingNodes = nodes.filter((nd) => startingNodeIds.includes(nd.id)) + this.chatflowPool.add(chatflowid, nodeToExecuteData, startingNodes) + } + + const nodeInstanceFilePath = this.nodesPool.componentNodes[nodeToExecuteData.name].filePath as string + const nodeModule = await import(nodeInstanceFilePath) + const nodeInstance = new nodeModule.nodeClass() + + const result = await nodeInstance.run(nodeToExecuteData, incomingInput.question, { chatHistory: incomingInput.history }) + + return res.json(result) + } catch (e: any) { + return res.status(500).send(e.message) + } + } + async stopApp() { try { const removePromises: any[] = [] diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index 1e2a91e96..f961cc222 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -1,5 +1,6 @@ import path from 'path' import fs from 'fs' +import moment from 'moment' import { IComponentNodes, IDepthQueue, @@ -14,6 +15,7 @@ import { } from '../Interface' import { cloneDeep, get } from 'lodash' import { ICommonObject, getInputVariables } from 'flowise-components' +import { scryptSync, randomBytes, timingSafeEqual } from 'crypto' const QUESTION_VAR_PREFIX = 'question' @@ -362,3 +364,119 @@ export const isStartNodeDependOnInput = (startingNodes: IReactFlowNode[]): boole } return false } + +/** + * Returns the api key path + * @returns {string} + */ +export const getAPIKeyPath = (): string => { + return path.join(__dirname, '..', '..', 'api.json') +} + +/** + * Generate the api key + * @returns {string} + */ +export const generateAPIKey = (): string => { + const buffer = randomBytes(32) + return buffer.toString('base64') +} + +/** + * Generate the secret key + * @param {string} apiKey + * @returns {string} + */ +export const generateSecretHash = (apiKey: string): string => { + const salt = randomBytes(8).toString('hex') + const buffer = scryptSync(apiKey, salt, 64) as Buffer + return `${buffer.toString('hex')}.${salt}` +} + +/** + * Verify valid keys + * @param {string} storedKey + * @param {string} suppliedKey + * @returns {boolean} + */ +export const compareKeys = (storedKey: string, suppliedKey: string): boolean => { + const [hashedPassword, salt] = storedKey.split('.') + const buffer = scryptSync(suppliedKey, salt, 64) as Buffer + return timingSafeEqual(Buffer.from(hashedPassword, 'hex'), buffer) +} + +/** + * Get API keys + * @returns {Promise} + */ +export const getAPIKeys = async (): Promise => { + try { + const content = await fs.promises.readFile(getAPIKeyPath(), 'utf8') + return JSON.parse(content) + } catch (error) { + const keyName = 'DefaultKey' + const apiKey = generateAPIKey() + const apiSecret = generateSecretHash(apiKey) + const content = [ + { + keyName, + apiKey, + apiSecret, + createdAt: moment().format('DD-MMM-YY'), + id: randomBytes(16).toString('hex') + } + ] + await fs.promises.writeFile(getAPIKeyPath(), JSON.stringify(content), 'utf8') + return content + } +} + +/** + * Add new API key + * @param {string} keyName + * @returns {Promise} + */ +export const addAPIKey = async (keyName: string): Promise => { + const existingAPIKeys = await getAPIKeys() + const apiKey = generateAPIKey() + const apiSecret = generateSecretHash(apiKey) + const content = [ + ...existingAPIKeys, + { + keyName, + apiKey, + apiSecret, + createdAt: moment().format('DD-MMM-YY'), + id: randomBytes(16).toString('hex') + } + ] + await fs.promises.writeFile(getAPIKeyPath(), JSON.stringify(content), 'utf8') + return content +} + +/** + * Update existing API key + * @param {string} keyIdToUpdate + * @param {string} newKeyName + * @returns {Promise} + */ +export const updateAPIKey = async (keyIdToUpdate: string, newKeyName: string): Promise => { + const existingAPIKeys = await getAPIKeys() + const keyIndex = existingAPIKeys.findIndex((key) => key.id === keyIdToUpdate) + if (keyIndex < 0) return [] + existingAPIKeys[keyIndex].keyName = newKeyName + await fs.promises.writeFile(getAPIKeyPath(), JSON.stringify(existingAPIKeys), 'utf8') + return existingAPIKeys +} + +/** + * Delete API key + * @param {string} keyIdToDelete + * @returns {Promise} + */ +export const deleteAPIKey = async (keyIdToDelete: string): Promise => { + const existingAPIKeys = await getAPIKeys() + const result = existingAPIKeys.filter((key) => key.id !== keyIdToDelete) + await fs.promises.writeFile(getAPIKeyPath(), JSON.stringify(result), 'utf8') + return result +} diff --git a/packages/ui/src/api/apikey.js b/packages/ui/src/api/apikey.js new file mode 100644 index 000000000..aed0a2d5f --- /dev/null +++ b/packages/ui/src/api/apikey.js @@ -0,0 +1,16 @@ +import client from './client' + +const getAllAPIKeys = () => client.get('/apikey') + +const createNewAPI = (body) => client.post(`/apikey`, body) + +const updateAPI = (id, body) => client.put(`/apikey/${id}`, body) + +const deleteAPI = (id) => client.delete(`/apikey/${id}`) + +export default { + getAllAPIKeys, + createNewAPI, + updateAPI, + deleteAPI +} diff --git a/packages/ui/src/api/prediction.js b/packages/ui/src/api/prediction.js index 6c1e2089c..d3512843c 100644 --- a/packages/ui/src/api/prediction.js +++ b/packages/ui/src/api/prediction.js @@ -1,6 +1,6 @@ import client from './client' -const sendMessageAndGetPrediction = (id, input) => client.post(`/prediction/${id}`, input) +const sendMessageAndGetPrediction = (id, input) => client.post(`/internal-prediction/${id}`, input) export default { sendMessageAndGetPrediction diff --git a/packages/ui/src/menu-items/dashboard.js b/packages/ui/src/menu-items/dashboard.js index 8bdea5984..f1cd5062e 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 } from '@tabler/icons' +import { IconHierarchy, IconBuildingStore, IconKey } from '@tabler/icons' // constant -const icons = { IconHierarchy, IconBuildingStore } +const icons = { IconHierarchy, IconBuildingStore, IconKey } // ==============================|| DASHBOARD MENU ITEMS ||============================== // @@ -26,6 +26,14 @@ const dashboard = { url: '/marketplaces', icon: icons.IconBuildingStore, breadcrumbs: true + }, + { + id: 'apikey', + title: 'API Keys', + type: 'item', + url: '/apikey', + icon: icons.IconKey, + breadcrumbs: true } ] } diff --git a/packages/ui/src/routes/MainRoutes.js b/packages/ui/src/routes/MainRoutes.js index c6580614a..5353e41a8 100644 --- a/packages/ui/src/routes/MainRoutes.js +++ b/packages/ui/src/routes/MainRoutes.js @@ -10,6 +10,9 @@ const Chatflows = Loadable(lazy(() => import('views/chatflows'))) // marketplaces routing const Marketplaces = Loadable(lazy(() => import('views/marketplaces'))) +// apikey routing +const APIKey = Loadable(lazy(() => import('views/apikey'))) + // ==============================|| MAIN ROUTING ||============================== // const MainRoutes = { @@ -27,6 +30,10 @@ const MainRoutes = { { path: '/marketplaces', element: + }, + { + path: '/apikey', + element: } ] } diff --git a/packages/ui/src/ui-component/dialog/APICodeDialog.js b/packages/ui/src/ui-component/dialog/APICodeDialog.js index e23e91b26..c72148ecc 100644 --- a/packages/ui/src/ui-component/dialog/APICodeDialog.js +++ b/packages/ui/src/ui-component/dialog/APICodeDialog.js @@ -1,14 +1,31 @@ import { createPortal } from 'react-dom' -import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useState, useEffect } from 'react' +import { useDispatch } from 'react-redux' import PropTypes from 'prop-types' import { Tabs, Tab, Dialog, DialogContent, DialogTitle, Box } from '@mui/material' import { CopyBlock, atomOneDark } from 'react-code-blocks' + +// Project import +import { Dropdown } from 'ui-component/dropdown/Dropdown' + +// Const import { baseURL } from 'store/constant' +import { SET_CHATFLOW } from 'store/actions' + +// Images import pythonSVG from 'assets/images/python.svg' import javascriptSVG from 'assets/images/javascript.svg' import cURLSVG from 'assets/images/cURL.svg' +// API +import apiKeyApi from 'api/apikey' +import chatflowsApi from 'api/chatflows' + +// Hooks +import useApi from 'hooks/useApi' + function TabPanel(props) { const { children, value, index, ...other } = props return ( @@ -39,8 +56,36 @@ function a11yProps(index) { const APICodeDialog = ({ show, dialogProps, onCancel }) => { const portalElement = document.getElementById('portal') + const navigate = useNavigate() + const dispatch = useDispatch() const codes = ['Python', 'JavaScript', 'cURL'] const [value, setValue] = useState(0) + const [keyOptions, setKeyOptions] = useState([]) + const [apiKeys, setAPIKeys] = useState([]) + const [chatflowApiKeyId, setChatflowApiKeyId] = useState('') + const [selectedApiKey, setSelectedApiKey] = useState({}) + + const getAllAPIKeysApi = useApi(apiKeyApi.getAllAPIKeys) + const updateChatflowApi = useApi(chatflowsApi.updateChatflow) + + const onApiKeySelected = (keyValue) => { + if (keyValue === 'addnewkey') { + navigate('/apikey') + return + } + setChatflowApiKeyId(keyValue) + setSelectedApiKey(apiKeys.find((key) => key.id === keyValue)) + const updateBody = { + apikeyid: keyValue + } + updateChatflowApi.request(dialogProps.chatflowid, updateBody) + } + + useEffect(() => { + if (updateChatflowApi.data) { + dispatch({ type: SET_CHATFLOW, chatflow: updateChatflowApi.data }) + } + }, [updateChatflowApi.data, dispatch]) const handleChange = (event, newValue) => { setValue(newValue) @@ -83,6 +128,46 @@ output = query({ return '' } + const getCodeWithAuthorization = (codeLang) => { + if (codeLang === 'Python') { + return `import requests + +API_URL = "${baseURL}/api/v1/prediction/${dialogProps.chatflowid}" +headers = {"Authorization": "Bearer ${selectedApiKey?.apiKey}"} + +def query(payload): + response = requests.post(API_URL, headers=headers, json=payload) + return response.json() + +output = query({ + "question": "Hey, how are you?", +}) +` + } else if (codeLang === 'JavaScript') { + return `async function query(data) { + const response = await fetch( + "${baseURL}/api/v1/prediction/${dialogProps.chatflowid}", + { + headers: { Authorization: "Bearer ${selectedApiKey?.apiKey}" }, + method: "POST", + body: { + "question": "Hey, how are you?" + }, + } + ); + const result = await response.json(); + return result; +} +` + } else if (codeLang === 'cURL') { + return `curl ${baseURL}/api/v1/prediction/${dialogProps.chatflowid} \\ + -X POST \\ + -d '{"question": "Hey, how are you?"}' + -H "Authorization: Bearer ${selectedApiKey?.apiKey}"` + } + return '' + } + const getLang = (codeLang) => { if (codeLang === 'Python') { return 'python' @@ -105,6 +190,40 @@ output = query({ return pythonSVG } + useEffect(() => { + if (getAllAPIKeysApi.data) { + const options = [ + { + label: 'No Authorization', + name: '' + } + ] + for (const key of getAllAPIKeysApi.data) { + options.push({ + label: key.keyName, + name: key.id + }) + } + options.push({ + label: '- Add New Key -', + name: 'addnewkey' + }) + setKeyOptions(options) + setAPIKeys(getAllAPIKeysApi.data) + + if (dialogProps.chatflowApiKeyId) { + setChatflowApiKeyId(dialogProps.chatflowApiKeyId) + setSelectedApiKey(getAllAPIKeysApi.data.find((key) => key.id === dialogProps.chatflowApiKeyId)) + } + } + }, [dialogProps, getAllAPIKeysApi.data]) + + useEffect(() => { + if (show) getAllAPIKeysApi.request() + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [show]) + const component = show ? ( - - {codes.map((codeLang, index) => ( - - } - iconPosition='left' - key={index} - label={codeLang} - {...a11yProps(index)} - > - ))} - +
+
+ + {codes.map((codeLang, index) => ( + + } + iconPosition='start' + key={index} + label={codeLang} + {...a11yProps(index)} + > + ))} + +
+
+ onApiKeySelected(newValue)} + value={dialogProps.chatflowApiKeyId ?? chatflowApiKeyId ?? 'Choose an API key'} + /> +
+
{codes.map((codeLang, index) => ( { + const portalElement = document.getElementById('portal') + + const theme = useTheme() + const dispatch = useDispatch() + + // ==============================|| Snackbar ||============================== // + + useNotifier() + + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [keyName, setKeyName] = useState('') + const [anchorEl, setAnchorEl] = useState(null) + const openPopOver = Boolean(anchorEl) + + useEffect(() => { + if (dialogProps.type === 'EDIT' && dialogProps.key) { + setKeyName(dialogProps.key.keyName) + } else if (dialogProps.type === 'ADD') { + setKeyName('') + } + }, [dialogProps]) + + const handleClosePopOver = () => { + setAnchorEl(null) + } + + const addNewKey = async () => { + try { + const createResp = await apikeyApi.createNewAPI({ keyName }) + if (createResp.data) { + enqueueSnackbar({ + message: 'New API key added', + 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 add new API key: ${errorData}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + onCancel() + } + } + + const saveKey = async () => { + try { + const saveResp = await apikeyApi.updateAPI(dialogProps.key.id, { keyName }) + if (saveResp.data) { + enqueueSnackbar({ + message: 'API Key saved', + 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 save API key: ${errorData}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + onCancel() + } + } + + const component = show ? ( + + + {dialogProps.title} + + + {dialogProps.type === 'EDIT' && ( + + API Key + + + {dialogProps.key.apiKey} + + { + navigator.clipboard.writeText(dialogProps.key.apiKey) + setAnchorEl(event.currentTarget) + setTimeout(() => { + handleClosePopOver() + }, 1500) + }} + > + + + + + Copied! + + + + + )} + + + + Key Name + + setKeyName(e.target.value)} + /> + + + + (dialogProps.type === 'ADD' ? addNewKey() : saveKey())}> + {dialogProps.confirmButtonName} + + + + ) : null + + return createPortal(component, portalElement) +} + +APIKeyDialog.propTypes = { + show: PropTypes.bool, + dialogProps: PropTypes.object, + onCancel: PropTypes.func, + onConfirm: PropTypes.func +} + +export default APIKeyDialog diff --git a/packages/ui/src/views/apikey/index.js b/packages/ui/src/views/apikey/index.js new file mode 100644 index 000000000..a2b2e639f --- /dev/null +++ b/packages/ui/src/views/apikey/index.js @@ -0,0 +1,279 @@ +import { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from 'store/actions' + +// material-ui +import { + Button, + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + IconButton, + Popover, + Typography +} 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 APIKeyDialog from './APIKeyDialog' +import ConfirmDialog from 'ui-component/dialog/ConfirmDialog' + +// API +import apiKeyApi from 'api/apikey' + +// Hooks +import useApi from 'hooks/useApi' +import useConfirm from 'hooks/useConfirm' + +// utils +import useNotifier from 'utils/useNotifier' + +// Icons +import { IconTrash, IconEdit, IconCopy, IconX, IconPlus, IconEye, IconEyeOff } from '@tabler/icons' +import APIEmptySVG from 'assets/images/api_empty.svg' + +// ==============================|| APIKey ||============================== // + +const APIKey = () => { + 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 [showDialog, setShowDialog] = useState(false) + const [dialogProps, setDialogProps] = useState({}) + const [apiKeys, setAPIKeys] = useState([]) + const [anchorEl, setAnchorEl] = useState(null) + const [showApiKeys, setShowApiKeys] = useState([]) + const openPopOver = Boolean(anchorEl) + + const { confirm } = useConfirm() + + const getAllAPIKeysApi = useApi(apiKeyApi.getAllAPIKeys) + + const onShowApiKeyClick = (apikey) => { + const index = showApiKeys.indexOf(apikey) + if (index > -1) { + //showApiKeys.splice(index, 1) + const newShowApiKeys = showApiKeys.filter(function (item) { + return item !== apikey + }) + setShowApiKeys(newShowApiKeys) + } else { + setShowApiKeys((prevkeys) => [...prevkeys, apikey]) + } + } + + const handleClosePopOver = () => { + setAnchorEl(null) + } + + const addNew = () => { + const dialogProp = { + title: 'Add New API Key', + type: 'ADD', + cancelButtonName: 'Cancel', + confirmButtonName: 'Add' + } + setDialogProps(dialogProp) + setShowDialog(true) + } + + const edit = (key) => { + const dialogProp = { + title: 'Edit API Key', + type: 'EDIT', + cancelButtonName: 'Cancel', + confirmButtonName: 'Save', + key + } + setDialogProps(dialogProp) + setShowDialog(true) + } + + const deleteKey = async (key) => { + const confirmPayload = { + title: `Delete`, + description: `Delete key ${key.keyName}?`, + confirmButtonName: 'Delete', + cancelButtonName: 'Cancel' + } + const isConfirmed = await confirm(confirmPayload) + + if (isConfirmed) { + try { + const deleteResp = await apiKeyApi.deleteAPI(key.id) + if (deleteResp.data) { + enqueueSnackbar({ + message: 'API key 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 API key: ${errorData}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + onCancel() + } + } + } + + const onConfirm = () => { + setShowDialog(false) + getAllAPIKeysApi.request() + } + + useEffect(() => { + getAllAPIKeysApi.request() + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (getAllAPIKeysApi.data) { + setAPIKeys(getAllAPIKeysApi.data) + } + }, [getAllAPIKeysApi.data]) + + return ( + <> + + +

API Keys 

+ + + }> + Create Key + +
+ {apiKeys.length <= 0 && ( + + + APIEmptySVG + +
No API Keys Yet
+
+ )} + {apiKeys.length > 0 && ( + + + + + Key Name + API Key + Created + + + + + + {apiKeys.map((key, index) => ( + + + {key.keyName} + + + {showApiKeys.includes(key.apiKey) + ? key.apiKey + : `${key.apiKey.substring(0, 2)}${'•'.repeat(18)}${key.apiKey.substring( + key.apiKey.length - 5 + )}`} + { + navigator.clipboard.writeText(key.apiKey) + setAnchorEl(event.currentTarget) + setTimeout(() => { + handleClosePopOver() + }, 1500) + }} + > + + + onShowApiKeyClick(key.apiKey)}> + {showApiKeys.includes(key.apiKey) ? : } + + + + Copied! + + + + {key.createdAt} + + edit(key)}> + + + + + deleteKey(key)}> + + + + + ))} + +
+
+ )} +
+ setShowDialog(false)} + onConfirm={onConfirm} + > + + + ) +} + +export default APIKey diff --git a/packages/ui/src/views/canvas/CanvasHeader.js b/packages/ui/src/views/canvas/CanvasHeader.js index 266deb70a..70c6aa02f 100644 --- a/packages/ui/src/views/canvas/CanvasHeader.js +++ b/packages/ui/src/views/canvas/CanvasHeader.js @@ -8,7 +8,7 @@ import { useTheme } from '@mui/material/styles' import { Avatar, Box, ButtonBase, Typography, Stack, TextField } from '@mui/material' // icons -import { IconSettings, IconChevronLeft, IconDeviceFloppy, IconPencil, IconCheck, IconX, IconWorldWww } from '@tabler/icons' +import { IconSettings, IconChevronLeft, IconDeviceFloppy, IconPencil, IconCheck, IconX, IconCode } from '@tabler/icons' // project imports import Settings from 'views/settings' @@ -82,7 +82,8 @@ const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFl const onAPIDialogClick = () => { setAPIDialogProps({ title: 'Use this chatflow with API', - chatflowid: chatflow.id + chatflowid: chatflow.id, + chatflowApiKeyId: chatflow.apikeyid }) setAPIDialogOpen(true) } @@ -247,7 +248,7 @@ const CanvasHeader = ({ chatflow, handleSaveFlow, handleDeleteFlow, handleLoadFl color='inherit' onClick={onAPIDialogClick} > - +