2
0

Add OpenAI block

Also migrate credentials to tRPC

Closes #253
This commit is contained in:
Baptiste Arnaud
2023-03-09 08:46:36 +01:00
parent 97cfdfe79f
commit ff04edf139
86 changed files with 2583 additions and 1055 deletions

View File

@ -12,21 +12,23 @@ import {
import { ChevronLeftIcon } from '@/components/icons'
import React, { ReactNode } from 'react'
type Props<T> = {
currentItem?: T
onItemSelect: (item: T) => void
items: T[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Props<T extends readonly any[]> = {
currentItem: T[number] | undefined
onItemSelect: (item: T[number]) => void
items: T
placeholder?: string
}
export const DropdownList = <T,>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const DropdownList = <T extends readonly any[]>({
currentItem,
onItemSelect,
items,
placeholder = '',
...props
}: Props<T> & MenuButtonProps) => {
const handleMenuItemClick = (operator: T) => () => {
const handleMenuItemClick = (operator: T[number]) => () => {
onItemSelect(operator)
}
return (

View File

@ -0,0 +1,27 @@
import { useColorModeValue, HStack, Tag, Text } from '@chakra-ui/react'
import { Variable } from 'models'
export const SetVariableLabel = ({
variableId,
variables,
}: {
variableId: string
variables?: Variable[]
}) => {
const textColor = useColorModeValue('gray.600', 'gray.400')
const variableName = variables?.find(
(variable) => variable.id === variableId
)?.name
if (!variableName) return null
return (
<HStack fontStyle="italic" spacing={1}>
<Text fontSize="sm" color={textColor}>
Set
</Text>
<Tag bg="orange.400" color="white" size="sm">
{variableName}
</Tag>
</HStack>
)
}

View File

@ -1,4 +1,12 @@
import { Box, Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import {
Box,
Button,
Fade,
Flex,
IconButton,
SlideFade,
Stack,
} from '@chakra-ui/react'
import { TrashIcon, PlusIcon } from '@/components/icons'
import { createId } from '@paralleldrive/cuid2'
import React, { useState } from 'react'
@ -7,26 +15,25 @@ type ItemWithId<T> = T & { id: string }
export type TableListItemProps<T> = {
item: T
debounceTimeout?: number
onItemChange: (item: T) => void
}
type Props<T> = {
initialItems: ItemWithId<T>[]
isOrdered?: boolean
addLabel?: string
debounceTimeout?: number
onItemsChange: (items: ItemWithId<T>[]) => void
Item: (props: TableListItemProps<T>) => JSX.Element
ComponentBetweenItems?: (props: unknown) => JSX.Element
onItemsChange: (items: ItemWithId<T>[]) => void
}
export const TableList = <T,>({
initialItems,
onItemsChange,
isOrdered,
addLabel = 'Add',
debounceTimeout,
Item,
ComponentBetweenItems,
onItemsChange,
}: Props<T>) => {
const [items, setItems] = useState(initialItems)
const [showDeleteIndex, setShowDeleteIndex] = useState<number | null>(null)
@ -38,6 +45,15 @@ export const TableList = <T,>({
onItemsChange([...items, newItem])
}
const insertItem = (itemIndex: number) => () => {
const id = createId()
const newItem = { id } as ItemWithId<T>
const newItems = [...items]
newItems.splice(itemIndex + 1, 0, newItem)
setItems(newItems)
onItemsChange(newItems)
}
const updateItem = (itemIndex: number, updates: Partial<T>) => {
const newItems = items.map((item, idx) =>
idx === itemIndex ? { ...item, ...updates } : item
@ -62,7 +78,7 @@ export const TableList = <T,>({
const handleMouseLeave = () => setShowDeleteIndex(null)
return (
<Stack spacing="4">
<Stack spacing={0} pt="2">
{items.map((item, itemIndex) => (
<Box key={item.id}>
{itemIndex !== 0 && ComponentBetweenItems && (
@ -73,35 +89,82 @@ export const TableList = <T,>({
onMouseEnter={handleMouseEnter(itemIndex)}
onMouseLeave={handleMouseLeave}
mt={itemIndex !== 0 && ComponentBetweenItems ? 4 : 0}
justifyContent="center"
pb="4"
>
<Item
item={item}
onItemChange={handleCellChange(itemIndex)}
debounceTimeout={debounceTimeout}
/>
<Fade in={showDeleteIndex === itemIndex}>
<Item item={item} onItemChange={handleCellChange(itemIndex)} />
<Fade
in={showDeleteIndex === itemIndex}
style={{
position: 'absolute',
left: '-15px',
top: '-15px',
}}
unmountOnExit
>
<IconButton
icon={<TrashIcon />}
aria-label="Remove cell"
onClick={deleteItem(itemIndex)}
pos="absolute"
left="-15px"
top="-15px"
size="sm"
shadow="md"
/>
</Fade>
{isOrdered && (
<>
{itemIndex === 0 && (
<SlideFade
offsetY="-5px"
in={showDeleteIndex === itemIndex}
style={{
position: 'absolute',
top: '-15px',
}}
unmountOnExit
>
<IconButton
aria-label={addLabel}
icon={<PlusIcon />}
size="xs"
shadow="md"
colorScheme="blue"
onClick={insertItem(itemIndex - 1)}
/>
</SlideFade>
)}
<SlideFade
offsetY="5px"
in={showDeleteIndex === itemIndex}
style={{
position: 'absolute',
bottom: '5px',
}}
unmountOnExit
>
<IconButton
aria-label={addLabel}
icon={<PlusIcon />}
size="xs"
shadow="md"
colorScheme="blue"
onClick={insertItem(itemIndex)}
/>
</SlideFade>
</>
)}
</Flex>
</Box>
))}
<Button
leftIcon={<PlusIcon />}
onClick={createItem}
flexShrink={0}
colorScheme="blue"
>
{addLabel}
</Button>
{!isOrdered && (
<Button
leftIcon={<PlusIcon />}
onClick={createItem}
flexShrink={0}
colorScheme="blue"
>
{addLabel}
</Button>
)}
</Stack>
)
}

View File

@ -16,12 +16,12 @@ import { useDebouncedCallback } from 'use-debounce'
import { env } from 'utils'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
type Value<HasVariable> = HasVariable extends undefined | true
type Value<HasVariable> = HasVariable extends true | undefined
? number | VariableString
: number
type Props<HasVariable extends boolean> = {
defaultValue?: Value<HasVariable>
defaultValue: Value<HasVariable> | undefined
debounceTimeout?: number
withVariableButton?: HasVariable
label?: string

View File

@ -3,6 +3,7 @@ import { injectVariableInText } from '@/features/variables/utils/injectVariableI
import { focusInput } from '@/utils/focusInput'
import {
FormControl,
FormHelperText,
FormLabel,
HStack,
Input as ChakraInput,
@ -26,6 +27,7 @@ export type TextInputProps = {
onChange: (value: string) => void
debounceTimeout?: number
label?: ReactNode
helperText?: ReactNode
moreInfoTooltip?: string
withVariableButton?: boolean
isRequired?: boolean
@ -42,6 +44,7 @@ export const TextInput = forwardRef(function TextInput(
defaultValue,
debounceTimeout = 1000,
label,
helperText,
moreInfoTooltip,
withVariableButton = true,
isRequired,
@ -137,6 +140,7 @@ export const TextInput = forwardRef(function TextInput(
) : (
Input
)}
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
)
})

View File

@ -52,7 +52,7 @@ test.describe.parallel('Buttons input block', () => {
await page.getByLabel('Button label:').fill('Go')
await page.getByPlaceholder('Select a variable').nth(1).click()
await page.getByText('var1').click()
await expect(page.getByText('Collectsvar1')).toBeVisible()
await expect(page.getByText('Setvar1')).toBeVisible()
await page.click('[data-testid="block2-icon"]')
await page.locator('text=Item 1').hover()

View File

@ -1,15 +1,9 @@
import { BlockIndices, ChoiceInputBlock, Variable } from 'models'
import { BlockIndices, ChoiceInputBlock } from 'models'
import React from 'react'
import { ItemNodesList } from '@/features/graph/components/Nodes/ItemNode'
import {
HStack,
Stack,
Tag,
Text,
useColorModeValue,
Wrap,
} from '@chakra-ui/react'
import { Stack, Tag, Text, Wrap } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { SetVariableLabel } from '@/components/SetVariableLabel'
type Props = {
block: ChoiceInputBlock
@ -25,7 +19,7 @@ export const ButtonsBlockNode = ({ block, indices }: Props) => {
return (
<Stack w="full">
{block.options.variableId ? (
<CollectVariableLabel
<SetVariableLabel
variableId={block.options.variableId}
variables={typebot?.variables}
/>
@ -44,28 +38,3 @@ export const ButtonsBlockNode = ({ block, indices }: Props) => {
</Stack>
)
}
const CollectVariableLabel = ({
variableId,
variables,
}: {
variableId: string
variables?: Variable[]
}) => {
const textColor = useColorModeValue('gray.600', 'gray.400')
const variableName = variables?.find(
(variable) => variable.id === variableId
)?.name
if (!variableName) return null
return (
<HStack fontStyle="italic" spacing={1}>
<Text fontSize="sm" color={textColor}>
Collects
</Text>
<Tag bg="orange.400" color="white" size="sm">
{variableName}
</Tag>
</HStack>
)
}

View File

@ -11,12 +11,13 @@ import {
AccordionPanel,
} from '@chakra-ui/react'
import { DropdownList } from '@/components/DropdownList'
import { CredentialsType, PaymentInputOptions, PaymentProvider } from 'models'
import React, { ChangeEvent, useState } from 'react'
import { PaymentInputOptions, PaymentProvider } from 'models'
import React, { ChangeEvent } from 'react'
import { currencies } from './currencies'
import { StripeConfigModal } from './StripeConfigModal'
import { CredentialsDropdown } from '@/features/credentials'
import { TextInput } from '@/components/inputs'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
type Props = {
options: PaymentInputOptions
@ -24,8 +25,8 @@ type Props = {
}
export const PaymentSettings = ({ options, onOptionsChange }: Props) => {
const { workspace } = useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure()
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
const handleProviderChange = (provider: PaymentProvider) => {
onOptionsChange({
@ -35,7 +36,6 @@ export const PaymentSettings = ({ options, onOptionsChange }: Props) => {
}
const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({
...options,
credentialsId,
@ -96,13 +96,15 @@ export const PaymentSettings = ({ options, onOptionsChange }: Props) => {
</Stack>
<Stack>
<Text>Account:</Text>
<CredentialsDropdown
type={CredentialsType.STRIPE}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
refreshDropdownKey={refreshCredentialsKey}
/>
{workspace && (
<CredentialsDropdown
type="stripe"
workspaceId={workspace.id}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
/>
)}
</Stack>
<HStack>
<TextInput

View File

@ -14,15 +14,15 @@ import {
HStack,
} from '@chakra-ui/react'
import { useUser } from '@/features/account'
import { CredentialsType, StripeCredentialsData } from 'models'
import React, { useState } from 'react'
import { useWorkspace } from '@/features/workspace'
import { omit } from 'utils'
import { useToast } from '@/hooks/useToast'
import { TextInput } from '@/components/inputs'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { TextLink } from '@/components/TextLink'
import { createCredentialsQuery } from '@/features/credentials'
import { StripeCredentials } from 'models'
import { trpc } from '@/lib/trpc'
import { isNotEmpty } from 'utils'
type Props = {
isOpen: boolean
@ -40,12 +40,32 @@ export const StripeConfigModal = ({
const [isCreating, setIsCreating] = useState(false)
const { showToast } = useToast()
const [stripeConfig, setStripeConfig] = useState<
StripeCredentialsData & { name: string }
StripeCredentials['data'] & { name: string }
>({
name: '',
live: { publicKey: '', secretKey: '' },
test: { publicKey: '', secretKey: '' },
})
const {
credentials: {
listCredentials: { refetch: refetchCredentials },
},
} = trpc.useContext()
const { mutate } = trpc.credentials.createCredentials.useMutation({
onMutate: () => setIsCreating(true),
onSettled: () => setIsCreating(false),
onError: (err) => {
showToast({
description: err.message,
status: 'error',
})
},
onSuccess: (data) => {
refetchCredentials()
onNewCredentials(data.credentialsId)
onClose()
},
})
const handleNameChange = (name: string) =>
setStripeConfig({
@ -77,22 +97,26 @@ export const StripeConfigModal = ({
test: { ...stripeConfig.test, secretKey },
})
const handleCreateClick = async () => {
const createCredentials = async () => {
if (!user?.email || !workspace?.id) return
setIsCreating(true)
const { data, error } = await createCredentialsQuery({
data: omit(stripeConfig, 'name'),
name: stripeConfig.name,
type: CredentialsType.STRIPE,
workspaceId: workspace.id,
mutate({
credentials: {
data: {
live: stripeConfig.live,
test: {
publicKey: isNotEmpty(stripeConfig.test.publicKey)
? stripeConfig.test.publicKey
: undefined,
secretKey: isNotEmpty(stripeConfig.test.secretKey)
? stripeConfig.test.secretKey
: undefined,
},
},
name: stripeConfig.name,
type: 'stripe',
workspaceId: workspace.id,
},
})
setIsCreating(false)
if (error)
return showToast({ title: error.name, description: error.message })
if (!data?.credentials)
return showToast({ description: "Credentials wasn't created" })
onNewCredentials(data.credentials.id)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
@ -108,6 +132,7 @@ export const StripeConfigModal = ({
onChange={handleNameChange}
placeholder="Typebot"
withVariableButton={false}
debounceTimeout={0}
/>
<Stack>
<FormLabel>
@ -121,11 +146,13 @@ export const StripeConfigModal = ({
onChange={handleTestPublicKeyChange}
placeholder="pk_test_..."
withVariableButton={false}
debounceTimeout={0}
/>
<TextInput
onChange={handleTestSecretKeyChange}
placeholder="sk_test_..."
withVariableButton={false}
debounceTimeout={0}
/>
</HStack>
</Stack>
@ -137,6 +164,7 @@ export const StripeConfigModal = ({
onChange={handlePublicKeyChange}
placeholder="pk_live_..."
withVariableButton={false}
debounceTimeout={0}
/>
</FormControl>
<FormControl>
@ -144,6 +172,7 @@ export const StripeConfigModal = ({
onChange={handleSecretKeyChange}
placeholder="sk_live_..."
withVariableButton={false}
debounceTimeout={0}
/>
</FormControl>
</HStack>
@ -162,7 +191,7 @@ export const StripeConfigModal = ({
<ModalFooter>
<Button
colorScheme="blue"
onClick={handleCreateClick}
onClick={createCredentials}
isDisabled={
stripeConfig.live.publicKey === '' ||
stripeConfig.name === '' ||

View File

@ -64,7 +64,7 @@ export const RatingInputSettings = ({
</FormLabel>
<DropdownList
onItemSelect={handleTypeChange}
items={['Icons', 'Numbers']}
items={['Icons', 'Numbers'] as const}
currentItem={options.buttonType}
/>
</Stack>

View File

@ -19,7 +19,7 @@ export const CellWithValueStack = ({
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px" w="full">
<DropdownList<string>
<DropdownList
currentItem={item.column}
onItemSelect={handleColumnSelect}
items={columns}

View File

@ -21,7 +21,7 @@ export const CellWithVariableIdStack = ({
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList<string>
<DropdownList
currentItem={item.column}
onItemSelect={handleColumnSelect}
items={columns}

View File

@ -3,7 +3,6 @@ import { DropdownList } from '@/components/DropdownList'
import { useTypebot } from '@/features/editor'
import {
Cell,
CredentialsType,
defaultGoogleSheetsGetOptions,
defaultGoogleSheetsInsertOptions,
defaultGoogleSheetsUpdateOptions,
@ -27,6 +26,7 @@ import { useSheets } from '../../hooks/useSheets'
import { Sheet } from '../../types'
import { RowsFilterTableList } from './RowsFilterTableList'
import { createId } from '@paralleldrive/cuid2'
import { useWorkspace } from '@/features/workspace'
type Props = {
options: GoogleSheetsOptions
@ -39,6 +39,7 @@ export const GoogleSheetsSettingsBody = ({
onOptionsChange,
blockId,
}: Props) => {
const { workspace } = useWorkspace()
const { save } = useTypebot()
const { sheets, isLoading } = useSheets({
credentialsId: options?.credentialsId,
@ -94,12 +95,15 @@ export const GoogleSheetsSettingsBody = ({
return (
<Stack>
<CredentialsDropdown
type={CredentialsType.GOOGLE_SHEETS}
currentCredentialsId={options?.credentialsId}
onCredentialsSelect={handleCredentialsIdChange}
onCreateNewClick={handleCreateNewClick}
/>
{workspace && (
<CredentialsDropdown
type="google sheets"
workspaceId={workspace.id}
currentCredentialsId={options?.credentialsId}
onCredentialsSelect={handleCredentialsIdChange}
onCreateNewClick={handleCreateNewClick}
/>
)}
<GoogleSheetConnectModal
blockId={blockId}
isOpen={isOpen}
@ -125,7 +129,7 @@ export const GoogleSheetsSettingsBody = ({
isDefined(options.sheetId) && (
<>
<Divider />
<DropdownList<GoogleSheetsAction>
<DropdownList
currentItem={'action' in options ? options.action : undefined}
onItemSelect={handleActionChange}
items={Object.values(GoogleSheetsAction)}

View File

@ -29,13 +29,13 @@ export const RowsFilterComparisonItem = ({
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList<string>
<DropdownList
currentItem={item.column}
onItemSelect={handleColumnSelect}
items={columns}
placeholder="Select a column"
/>
<DropdownList<ComparisonOperators>
<DropdownList
currentItem={item.comparisonOperator}
onItemSelect={handleSelectComparisonOperator}
items={Object.values(ComparisonOperators)}

View File

@ -42,7 +42,7 @@ export const RowsFilterTableList = ({
Item={createRowsFilterComparisonItem}
ComponentBetweenItems={() => (
<Flex justify="center">
<DropdownList<LogicalOperator>
<DropdownList
currentItem={filter?.logicalOperator}
onItemSelect={handleLogicalOperatorChange}
items={Object.values(LogicalOperator)}

View File

@ -3,11 +3,8 @@ import { ExternalLinkIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import { MakeComBlock, Webhook, WebhookOptions } from 'models'
import React, { useCallback, useEffect, useState } from 'react'
import { byId, env } from 'utils'
import { byId } from 'utils'
import { WebhookAdvancedConfigForm } from '../../webhook/components/WebhookAdvancedConfigForm'
import { useDebouncedCallback } from 'use-debounce'
const debounceWebhookTimeout = 2000
type Props = {
block: MakeComBlock
@ -22,19 +19,13 @@ export const MakeComSettings = ({
const webhook = webhooks.find(byId(webhookId))
const [localWebhook, _setLocalWebhook] = useState(webhook)
const updateWebhookDebounced = useDebouncedCallback(
async (newLocalWebhook) => {
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
},
env('E2E_TEST') === 'true' ? 0 : debounceWebhookTimeout
)
const setLocalWebhook = useCallback(
(newLocalWebhook: Webhook) => {
async (newLocalWebhook: Webhook) => {
_setLocalWebhook(newLocalWebhook)
updateWebhookDebounced(newLocalWebhook)
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
},
[updateWebhookDebounced]
[updateWebhook]
)
useEffect(() => {
@ -51,13 +42,6 @@ export const MakeComSettings = ({
})
}, [webhook, localWebhook, setLocalWebhook])
useEffect(
() => () => {
updateWebhookDebounced.flush()
},
[updateWebhookDebounced]
)
return (
<Stack spacing={4}>
<Alert status={localWebhook?.url ? 'success' : 'info'} rounded="md">

View File

@ -0,0 +1,125 @@
import { TextInput } from '@/components/inputs/TextInput'
import { TextLink } from '@/components/TextLink'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
ModalFooter,
Button,
} from '@chakra-ui/react'
import React, { useState } from 'react'
const openAITokensPage = 'https://platform.openai.com/account/api-keys'
type Props = {
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const OpenAICredentialsModal = ({
isOpen,
onClose,
onNewCredentials,
}: Props) => {
const { workspace } = useWorkspace()
const { showToast } = useToast()
const [apiKey, setApiKey] = useState('')
const [name, setName] = useState('')
const [isCreating, setIsCreating] = useState(false)
const {
credentials: {
listCredentials: { refetch: refetchCredentials },
},
} = trpc.useContext()
const { mutate } = trpc.credentials.createCredentials.useMutation({
onMutate: () => setIsCreating(true),
onSettled: () => setIsCreating(false),
onError: (err) => {
showToast({
description: err.message,
status: 'error',
})
},
onSuccess: (data) => {
refetchCredentials()
onNewCredentials(data.credentialsId)
onClose()
},
})
const createOpenAICredentials = async (e: React.FormEvent) => {
e.preventDefault()
if (!workspace) return
mutate({
credentials: {
type: 'openai',
workspaceId: workspace.id,
name,
data: {
apiKey,
},
},
})
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Add OpenAI account</ModalHeader>
<ModalCloseButton />
<form onSubmit={createOpenAICredentials}>
<ModalBody as={Stack} spacing="6">
<TextInput
isRequired
label="Name"
onChange={setName}
placeholder="My account"
withVariableButton={false}
debounceTimeout={0}
/>
<TextInput
isRequired
type="password"
label="API key"
helperText={
<>
You can generate an API key{' '}
<TextLink href={openAITokensPage} isExternal>
here
</TextLink>
.
</>
}
onChange={setApiKey}
placeholder="sk-..."
withVariableButton={false}
debounceTimeout={0}
/>
</ModalBody>
<ModalFooter>
<Button
type="submit"
isLoading={isCreating}
isDisabled={apiKey === '' || name === ''}
colorScheme="blue"
>
Create
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,7 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const OpenAILogo = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...props}>
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
</Icon>
)

View File

@ -0,0 +1,39 @@
import { SetVariableLabel } from '@/components/SetVariableLabel'
import { useTypebot } from '@/features/editor'
import { Stack, Text } from '@chakra-ui/react'
import {
ChatCompletionOpenAIOptions,
CreateImageOpenAIOptions,
OpenAIBlock,
} from 'models/features/blocks/integrations/openai'
type Props = {
task: OpenAIBlock['options']['task']
responseMapping:
| ChatCompletionOpenAIOptions['responseMapping']
| CreateImageOpenAIOptions['responseMapping']
}
export const OpenAINodeBody = ({ task, responseMapping }: Props) => {
const { typebot } = useTypebot()
return (
<Stack>
<Text color={task ? 'currentcolor' : 'gray.500'} noOfLines={1}>
{task ?? 'Configure...'}
</Text>
{typebot &&
responseMapping
.map((mapping) => mapping.variableId)
.map((variableId, idx) =>
variableId ? (
<SetVariableLabel
key={variableId + idx}
variables={typebot.variables}
variableId={variableId}
/>
) : null
)}
</Stack>
)
}

View File

@ -0,0 +1,99 @@
import { Stack, useDisclosure } from '@chakra-ui/react'
import React from 'react'
import { CredentialsDropdown } from '@/features/credentials'
import {
ChatCompletionOpenAIOptions,
CreateImageOpenAIOptions,
defaultChatCompletionOptions,
OpenAIBlock,
openAITasks,
} from 'models/features/blocks/integrations/openai'
import { OpenAICredentialsModal } from './OpenAICredentialsModal'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { DropdownList } from '@/components/DropdownList'
import { OpenAIChatCompletionSettings } from './createChatCompletion/OpenAIChatCompletionSettings'
import { createId } from '@paralleldrive/cuid2'
type OpenAITask = (typeof openAITasks)[number]
type Props = {
options: OpenAIBlock['options']
onOptionsChange: (options: OpenAIBlock['options']) => void
}
export const OpenAISettings = ({ options, onOptionsChange }: Props) => {
const { workspace } = useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure()
const updateCredentialsId = (credentialsId: string | undefined) => {
onOptionsChange({
...options,
credentialsId,
})
}
const updateTask = (task: OpenAITask) => {
switch (task) {
case 'Create chat completion': {
onOptionsChange({
credentialsId: options?.credentialsId,
...defaultChatCompletionOptions(createId),
})
break
}
}
}
return (
<Stack>
{workspace && (
<CredentialsDropdown
type="openai"
workspaceId={workspace.id}
currentCredentialsId={options?.credentialsId}
onCredentialsSelect={updateCredentialsId}
onCreateNewClick={onOpen}
/>
)}
<OpenAICredentialsModal
isOpen={isOpen}
onClose={onClose}
onNewCredentials={updateCredentialsId}
/>
<DropdownList
currentItem={options.task}
items={openAITasks.slice(0, -1)}
onItemSelect={updateTask}
placeholder="Select task"
/>
{options.task && (
<OpenAITaskSettings
options={options}
onOptionsChange={onOptionsChange}
/>
)}
</Stack>
)
}
const OpenAITaskSettings = ({
options,
onOptionsChange,
}: {
options: ChatCompletionOpenAIOptions | CreateImageOpenAIOptions
onOptionsChange: (options: OpenAIBlock['options']) => void
}) => {
switch (options.task) {
case 'Create chat completion': {
return (
<OpenAIChatCompletionSettings
options={options}
onOptionsChange={onOptionsChange}
/>
)
}
case 'Create image': {
return <></>
}
}
}

View File

@ -0,0 +1,36 @@
import { DropdownList } from '@/components/DropdownList'
import { TextInput } from '@/components/inputs'
import { TableListItemProps } from '@/components/TableList'
import { Stack } from '@chakra-ui/react'
import {
chatCompletionMessageRoles,
ChatCompletionOpenAIOptions,
} from 'models/features/blocks/integrations/openai'
type Props = TableListItemProps<ChatCompletionOpenAIOptions['messages'][number]>
export const ChatCompletionMessageItem = ({ item, onItemChange }: Props) => {
const changeRole = (role: (typeof chatCompletionMessageRoles)[number]) => {
onItemChange({ ...item, role })
}
const changeContent = (content: string) => {
onItemChange({ ...item, content })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList
currentItem={item.role}
items={chatCompletionMessageRoles}
onItemSelect={changeRole}
placeholder="Select role"
/>
<TextInput
defaultValue={item.content}
onChange={changeContent}
placeholder="Content"
/>
</Stack>
)
}

View File

@ -0,0 +1,39 @@
import { DropdownList } from '@/components/DropdownList'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { TableListItemProps } from '@/components/TableList'
import { Stack } from '@chakra-ui/react'
import { Variable } from 'models'
import {
ChatCompletionOpenAIOptions,
chatCompletionResponseValues,
} from 'models/features/blocks/integrations/openai'
type Props = TableListItemProps<
ChatCompletionOpenAIOptions['responseMapping'][number]
>
export const ChatCompletionResponseItem = ({ item, onItemChange }: Props) => {
const changeValueToExtract = (
valueToExtract: (typeof chatCompletionResponseValues)[number]
) => {
onItemChange({ ...item, valueToExtract })
}
const changeVariableId = (variable: Pick<Variable, 'id'> | undefined) => {
onItemChange({ ...item, variableId: variable ? variable.id : undefined })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList
currentItem={item.valueToExtract ?? 'Message content'}
items={chatCompletionResponseValues}
onItemSelect={changeValueToExtract}
/>
<VariableSearchInput
onSelectVariable={changeVariableId}
initialVariableId={item.variableId}
/>
</Stack>
)
}

View File

@ -0,0 +1,107 @@
import { DropdownList } from '@/components/DropdownList'
import { TableList } from '@/components/TableList'
import {
chatCompletionModels,
ChatCompletionOpenAIOptions,
} from 'models/features/blocks/integrations/openai'
import { ChatCompletionMessageItem } from './ChatCompletionMessageItem'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Stack,
Text,
} from '@chakra-ui/react'
import { TextLink } from '@/components/TextLink'
import { ChatCompletionResponseItem } from './ChatCompletionResponseItem'
const apiReferenceUrl =
'https://platform.openai.com/docs/api-reference/chat/create'
type Props = {
options: ChatCompletionOpenAIOptions
onOptionsChange: (options: ChatCompletionOpenAIOptions) => void
}
export const OpenAIChatCompletionSettings = ({
options,
onOptionsChange,
}: Props) => {
const updateModel = (model: (typeof chatCompletionModels)[number]) => {
onOptionsChange({
...options,
model,
})
}
const updateMessages = (messages: typeof options.messages) => {
onOptionsChange({
...options,
messages,
})
}
const updateResponseMapping = (
responseMapping: typeof options.responseMapping
) => {
onOptionsChange({
...options,
responseMapping,
})
}
return (
<Stack spacing={4} pt="2">
<Text fontSize="sm" color="gray.500">
Read the{' '}
<TextLink href={apiReferenceUrl} isExternal>
API reference
</TextLink>{' '}
to better understand the available options.
</Text>
<DropdownList
currentItem={options.model}
items={chatCompletionModels}
onItemSelect={updateModel}
/>
<Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Messages
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<TableList
initialItems={options.messages}
Item={ChatCompletionMessageItem}
onItemsChange={updateMessages}
isOrdered
addLabel="Add message"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Save answer
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel>
<TableList
initialItems={options.responseMapping}
Item={ChatCompletionResponseItem}
onItemsChange={updateResponseMapping}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)
}

View File

@ -0,0 +1,39 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from 'utils/playwright/databaseActions'
import { createId } from '@paralleldrive/cuid2'
import { IntegrationBlockType } from 'models'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
const typebotId = createId()
test('should be configurable', async ({ page }) => {
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: IntegrationBlockType.OPEN_AI,
options: {},
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.getByText('Configure...').click()
await page.getByRole('button', { name: 'Select an account' }).click()
await page.getByRole('menuitem', { name: 'Connect new' }).click()
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
await page.getByPlaceholder('My account').fill('My account')
await page.getByPlaceholder('sk-...').fill('sk-test')
await page.getByRole('button', { name: 'Create' }).click()
await page.getByRole('button', { name: 'Select task' }).click()
await page.getByRole('menuitem', { name: 'Create chat completion' }).click()
await page.getByRole('button', { name: 'Messages' }).click()
await page.getByRole('button', { name: 'Select role' }).click()
await page.getByRole('menuitem', { name: 'system' }).click()
await page.getByPlaceholder('Content').first().fill('You are a helpful bot')
await page.getByRole('button', { name: 'Add message' }).nth(1).click()
await page.getByRole('button', { name: 'Select role' }).click()
await page.getByRole('menuitem', { name: 'assistant' }).click()
await page.getByPlaceholder('Content').nth(1).fill('Hi there!')
await page.getByRole('button', { name: 'Save answer' }).click()
await page.getByTestId('variables-input').click()
})

View File

@ -2,14 +2,11 @@ import { Alert, AlertIcon, Button, Link, Stack, Text } from '@chakra-ui/react'
import { ExternalLinkIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import { PabblyConnectBlock, Webhook, WebhookOptions } from 'models'
import React, { useEffect, useState } from 'react'
import { byId, env } from 'utils'
import React, { useState } from 'react'
import { byId } from 'utils'
import { WebhookAdvancedConfigForm } from '../../webhook/components/WebhookAdvancedConfigForm'
import { useDebouncedCallback } from 'use-debounce'
import { TextInput } from '@/components/inputs'
const debounceWebhookTimeout = 2000
type Props = {
block: PabblyConnectBlock
onOptionsChange: (options: WebhookOptions) => void
@ -25,25 +22,11 @@ export const PabblyConnectSettings = ({
webhooks.find(byId(webhookId))
)
const updateWebhookDebounced = useDebouncedCallback(
async (newLocalWebhook) => {
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
},
env('E2E_TEST') === 'true' ? 0 : debounceWebhookTimeout
)
const setLocalWebhook = (newLocalWebhook: Webhook) => {
const setLocalWebhook = async (newLocalWebhook: Webhook) => {
_setLocalWebhook(newLocalWebhook)
updateWebhookDebounced(newLocalWebhook)
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
}
useEffect(
() => () => {
updateWebhookDebounced.flush()
},
[updateWebhookDebounced]
)
const handleUrlChange = (url: string) =>
localWebhook &&
setLocalWebhook({

View File

@ -8,14 +8,15 @@ import {
FormLabel,
} from '@chakra-ui/react'
import { CodeEditor } from '@/components/inputs/CodeEditor'
import { CredentialsType, SendEmailOptions, Variable } from 'models'
import React, { useState } from 'react'
import { SendEmailOptions, Variable } from 'models'
import React from 'react'
import { env, isNotEmpty } from 'utils'
import { SmtpConfigModal } from './SmtpConfigModal'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { CredentialsDropdown } from '@/features/credentials'
import { TextInput, Textarea } from '@/components/inputs'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
type Props = {
options: SendEmailOptions
@ -23,11 +24,10 @@ type Props = {
}
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
const { workspace } = useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure()
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({
...options,
credentialsId: credentialsId === undefined ? 'default' : credentialsId,
@ -109,16 +109,18 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
<Stack spacing={4}>
<Stack>
<Text>From: </Text>
<CredentialsDropdown
type={CredentialsType.SMTP}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
defaultCredentialLabel={env('SMTP_FROM')
?.match(/<(.*)>/)
?.pop()}
refreshDropdownKey={refreshCredentialsKey}
/>
{workspace && (
<CredentialsDropdown
type="smtp"
workspaceId={workspace.id}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
defaultCredentialLabel={env('SMTP_FROM')
?.match(/<(.*)>/)
?.pop()}
/>
)}
</Stack>
<TextInput
label="Reply to:"

View File

@ -2,12 +2,12 @@ import { TextInput, NumberInput } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { Stack } from '@chakra-ui/react'
import { isDefined } from '@udecode/plate-common'
import { SmtpCredentialsData } from 'models'
import { SmtpCredentials } from 'models'
import React from 'react'
type Props = {
config: SmtpCredentialsData
onConfigChange: (config: SmtpCredentialsData) => void
config: SmtpCredentials['data']
onConfigChange: (config: SmtpCredentials['data']) => void
}
export const SmtpConfigForm = ({ config, onConfigChange }: Props) => {

View File

@ -9,14 +9,14 @@ import {
Button,
} from '@chakra-ui/react'
import { useUser } from '@/features/account'
import { CredentialsType, SmtpCredentialsData } from 'models'
import React, { useState } from 'react'
import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '@/hooks/useToast'
import { testSmtpConfig } from '../../queries/testSmtpConfigQuery'
import { createCredentialsQuery } from '@/features/credentials'
import { SmtpCredentials } from 'models'
import { trpc } from '@/lib/trpc'
type Props = {
isOpen: boolean
@ -33,10 +33,29 @@ export const SmtpConfigModal = ({
const { workspace } = useWorkspace()
const [isCreating, setIsCreating] = useState(false)
const { showToast } = useToast()
const [smtpConfig, setSmtpConfig] = useState<SmtpCredentialsData>({
const [smtpConfig, setSmtpConfig] = useState<SmtpCredentials['data']>({
from: {},
port: 25,
})
const {
credentials: {
listCredentials: { refetch: refetchCredentials },
},
} = trpc.useContext()
const { mutate } = trpc.credentials.createCredentials.useMutation({
onSettled: () => setIsCreating(false),
onError: (err) => {
showToast({
description: err.message,
status: 'error',
})
},
onSuccess: (data) => {
refetchCredentials()
onNewCredentials(data.credentialsId)
onClose()
},
})
const handleCreateClick = async () => {
if (!user?.email || !workspace?.id) return
@ -53,19 +72,14 @@ export const SmtpConfigModal = ({
description: "We couldn't send the test email with your configuration",
})
}
const { data, error } = await createCredentialsQuery({
data: smtpConfig,
name: smtpConfig.from.email as string,
type: CredentialsType.SMTP,
workspaceId: workspace.id,
mutate({
credentials: {
data: smtpConfig,
name: smtpConfig.from.email as string,
type: 'smtp',
workspaceId: workspace.id,
},
})
setIsCreating(false)
if (error)
return showToast({ title: error.name, description: error.message })
if (!data?.credentials)
return showToast({ description: "Credentials wasn't created" })
onNewCredentials(data.credentials.id)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>

View File

@ -1,7 +1,7 @@
import { SmtpCredentialsData } from 'models'
import { SmtpCredentials } from 'models'
import { sendRequest } from 'utils'
export const testSmtpConfig = (smtpData: SmtpCredentialsData, to: string) =>
export const testSmtpConfig = (smtpData: SmtpCredentials['data'], to: string) =>
sendRequest({
method: 'POST',
url: '/api/integrations/email/test-config',

View File

@ -118,7 +118,7 @@ export const WebhookAdvancedConfigForm = ({
<>
<HStack justify="space-between">
<Text>Method:</Text>
<DropdownList<HttpMethod>
<DropdownList
currentItem={webhook.method as HttpMethod}
onItemSelect={handleMethodChange}
items={Object.values(HttpMethod)}
@ -130,13 +130,12 @@ export const WebhookAdvancedConfigForm = ({
Query params
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={4} as={Stack} spacing="6">
<AccordionPanel>
<TableList<KeyValue>
initialItems={webhook.queryParams}
onItemsChange={handleQueryParamsChange}
Item={QueryParamsInputs}
addLabel="Add a param"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
@ -145,13 +144,12 @@ export const WebhookAdvancedConfigForm = ({
Headers
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={4} as={Stack} spacing="6">
<AccordionPanel>
<TableList<KeyValue>
initialItems={webhook.headers}
onItemsChange={handleHeadersChange}
Item={HeadersInputs}
addLabel="Add a value"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
@ -181,7 +179,7 @@ export const WebhookAdvancedConfigForm = ({
Variable values for test
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={4} as={Stack} spacing="6">
<AccordionPanel>
<TableList<VariableForTest>
initialItems={
options?.variablesForTest ?? { byId: {}, allIds: [] }
@ -189,7 +187,6 @@ export const WebhookAdvancedConfigForm = ({
onItemsChange={handleVariablesChange}
Item={VariableForTestInputs}
addLabel="Add an entry"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>
@ -215,13 +212,12 @@ export const WebhookAdvancedConfigForm = ({
Save in variables
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={4} as={Stack} spacing="6">
<AccordionPanel>
<TableList<ResponseVariableMapping>
initialItems={options.responseVariableMapping}
onItemsChange={handleResponseMappingChange}
Item={ResponseMappingInputs}
addLabel="Add an entry"
debounceTimeout={0}
/>
</AccordionPanel>
</AccordionItem>

View File

@ -24,7 +24,6 @@ export const KeyValueInputs = ({
onItemChange,
keyPlaceholder,
valuePlaceholder,
debounceTimeout,
}: TableListItemProps<KeyValue> & {
keyPlaceholder?: string
valuePlaceholder?: string
@ -44,14 +43,12 @@ export const KeyValueInputs = ({
defaultValue={item.key ?? ''}
onChange={handleKeyChange}
placeholder={keyPlaceholder}
debounceTimeout={debounceTimeout}
/>
<TextInput
label="Value:"
defaultValue={item.value ?? ''}
onChange={handleValueChange}
placeholder={valuePlaceholder}
debounceTimeout={debounceTimeout}
/>
</Stack>
)

View File

@ -7,7 +7,6 @@ import { VariableForTest, Variable } from 'models'
export const VariableForTestInputs = ({
item,
onItemChange,
debounceTimeout,
}: TableListItemProps<VariableForTest>) => {
const handleVariableSelect = (variable?: Variable) =>
onItemChange({ ...item, variableId: variable?.id })
@ -29,7 +28,6 @@ export const VariableForTestInputs = ({
label="Test value:"
defaultValue={item.value ?? ''}
onChange={handleValueChange}
debounceTimeout={debounceTimeout}
/>
</Stack>
)

View File

@ -1,14 +1,11 @@
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { Spinner, Stack } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { WebhookOptions, Webhook, WebhookBlock } from 'models'
import { byId, env } from 'utils'
import { byId } from 'utils'
import { TextInput } from '@/components/inputs'
import { useDebouncedCallback } from 'use-debounce'
import { WebhookAdvancedConfigForm } from '../WebhookAdvancedConfigForm'
const debounceWebhookTimeout = 2000
type Props = {
block: WebhookBlock
onOptionsChange: (options: WebhookOptions) => void
@ -22,26 +19,13 @@ export const WebhookSettings = ({
const [localWebhook, _setLocalWebhook] = useState(
webhooks.find(byId(webhookId))
)
const updateWebhookDebounced = useDebouncedCallback(
async (newLocalWebhook) => {
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
},
env('E2E_TEST') === 'true' ? 0 : debounceWebhookTimeout
)
const setLocalWebhook = (newLocalWebhook: Webhook) => {
const setLocalWebhook = async (newLocalWebhook: Webhook) => {
_setLocalWebhook(newLocalWebhook)
updateWebhookDebounced(newLocalWebhook)
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
}
useEffect(
() => () => {
updateWebhookDebounced.flush()
},
[updateWebhookDebounced]
)
const handleUrlChange = (url?: string) =>
const updateUrl = (url?: string) =>
localWebhook && setLocalWebhook({ ...localWebhook, url: url ?? null })
if (!localWebhook) return <Spinner />
@ -51,8 +35,7 @@ export const WebhookSettings = ({
<TextInput
placeholder="Paste webhook URL..."
defaultValue={localWebhook.url ?? ''}
onChange={handleUrlChange}
debounceTimeout={0}
onChange={updateUrl}
/>
<WebhookAdvancedConfigForm
blockId={blockId}

View File

@ -3,11 +3,8 @@ import { ExternalLinkIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import { Webhook, WebhookOptions, ZapierBlock } from 'models'
import React, { useCallback, useEffect, useState } from 'react'
import { byId, env } from 'utils'
import { byId } from 'utils'
import { WebhookAdvancedConfigForm } from '../../webhook/components/WebhookAdvancedConfigForm'
import { useDebouncedCallback } from 'use-debounce'
const debounceWebhookTimeout = 2000
type Props = {
block: ZapierBlock
@ -23,19 +20,12 @@ export const ZapierSettings = ({
const [localWebhook, _setLocalWebhook] = useState(webhook)
const updateWebhookDebounced = useDebouncedCallback(
async (newLocalWebhook) => {
const setLocalWebhook = useCallback(
async (newLocalWebhook: Webhook) => {
_setLocalWebhook(newLocalWebhook)
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
},
env('E2E_TEST') === 'true' ? 0 : debounceWebhookTimeout
)
const setLocalWebhook = useCallback(
(newLocalWebhook: Webhook) => {
_setLocalWebhook(newLocalWebhook)
updateWebhookDebounced(newLocalWebhook)
},
[updateWebhookDebounced]
[updateWebhook]
)
useEffect(() => {
@ -52,13 +42,6 @@ export const ZapierSettings = ({
})
}, [webhook, localWebhook, setLocalWebhook])
useEffect(
() => () => {
updateWebhookDebounced.flush()
},
[updateWebhookDebounced]
)
return (
<Stack spacing={4}>
<Alert status={localWebhook?.url ? 'success' : 'info'} rounded="md">

View File

@ -32,7 +32,7 @@ export const ComparisonItem = ({
onSelectVariable={handleSelectVariable}
placeholder="Search for a variable"
/>
<DropdownList<ComparisonOperators>
<DropdownList
currentItem={item.comparisonOperator}
onItemSelect={handleSelectComparisonOperator}
items={Object.values(ComparisonOperators)}

View File

@ -23,7 +23,7 @@ export const ConditionItemForm = ({ itemContent, onItemChange }: Props) => {
Item={ComparisonItem}
ComponentBetweenItems={() => (
<Flex justify="center">
<DropdownList<LogicalOperator>
<DropdownList
currentItem={itemContent.logicalOperator}
onItemSelect={handleLogicalOperatorChange}
items={Object.values(LogicalOperator)}

View File

@ -0,0 +1,66 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { stripeCredentialsSchema } from 'models/features/blocks/inputs/payment/schemas'
import { googleSheetsCredentialsSchema } from 'models/features/blocks/integrations/googleSheets/schemas'
import { openAICredentialsSchema } from 'models/features/blocks/integrations/openai'
import { smtpCredentialsSchema } from 'models/features/blocks/integrations/sendEmail'
import { encrypt } from 'utils/api/encryption'
import { z } from 'zod'
const inputShape = {
data: true,
type: true,
workspaceId: true,
name: true,
} as const
export const createCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/credentials',
protect: true,
summary: 'Create credentials',
tags: ['Credentials'],
},
})
.input(
z.object({
credentials: z.discriminatedUnion('type', [
stripeCredentialsSchema.pick(inputShape),
smtpCredentialsSchema.pick(inputShape),
googleSheetsCredentialsSchema.pick(inputShape),
openAICredentialsSchema.pick(inputShape),
]),
})
)
.output(
z.object({
credentialsId: z.string(),
})
)
.mutation(async ({ input: { credentials }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: credentials.workspaceId,
members: { some: { userId: user.id } },
},
select: { id: true },
})
if (!workspace)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
const { encryptedData, iv } = encrypt(credentials.data)
const createdCredentials = await prisma.credentials.create({
data: {
...credentials,
data: encryptedData,
iv,
},
select: {
id: true,
},
})
return { credentialsId: createdCredentials.id }
})

View File

@ -0,0 +1,49 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const deleteCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/credentials/:credentialsId',
protect: true,
summary: 'Delete credentials',
tags: ['Credentials'],
},
})
.input(
z.object({
credentialsId: z.string(),
workspaceId: z.string(),
})
)
.output(
z.object({
credentialsId: z.string(),
})
)
.mutation(
async ({ input: { credentialsId, workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id } },
},
select: { id: true },
})
if (!workspace)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
await prisma.credentials.delete({
where: {
id: credentialsId,
},
})
return { credentialsId }
}
)

View File

@ -0,0 +1,55 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { stripeCredentialsSchema } from 'models/features/blocks/inputs/payment/schemas'
import { googleSheetsCredentialsSchema } from 'models/features/blocks/integrations/googleSheets/schemas'
import { openAICredentialsSchema } from 'models/features/blocks/integrations/openai'
import { smtpCredentialsSchema } from 'models/features/blocks/integrations/sendEmail'
import { z } from 'zod'
export const listCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/credentials',
protect: true,
summary: 'List workspace credentials',
tags: ['Credentials'],
},
})
.input(
z.object({
workspaceId: z.string(),
type: stripeCredentialsSchema.shape.type
.or(smtpCredentialsSchema.shape.type)
.or(googleSheetsCredentialsSchema.shape.type)
.or(openAICredentialsSchema.shape.type),
})
)
.output(
z.object({
credentials: z.array(z.object({ id: z.string(), name: z.string() })),
})
)
.query(async ({ input: { workspaceId, type }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id } },
},
select: { id: true },
})
if (!workspace)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
const credentials = await prisma.credentials.findMany({
where: {
type,
workspaceId,
},
select: {
id: true,
name: true,
},
})
return { credentials }
})

View File

@ -0,0 +1,10 @@
import { router } from '@/utils/server/trpc'
import { createCredentials } from './createCredentials'
import { deleteCredentials } from './deleteCredentials'
import { listCredentials } from './listCredentials'
export const credentialsRouter = router({
createCredentials,
listCredentials,
deleteCredentials,
})

View File

@ -10,58 +10,73 @@ import {
Text,
} from '@chakra-ui/react'
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@/components/icons'
import React, { useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { CredentialsType } from 'models'
import { useWorkspace } from '@/features/workspace'
import { useToast } from '../../../hooks/useToast'
import { deleteCredentialsQuery, useCredentials } from '@/features/credentials'
import { Credentials } from 'models'
import { trpc } from '@/lib/trpc'
type Props = Omit<MenuButtonProps, 'type'> & {
type: CredentialsType
type: Credentials['type']
workspaceId: string
currentCredentialsId?: string
onCredentialsSelect: (credentialId?: string) => void
onCreateNewClick: () => void
defaultCredentialLabel?: string
refreshDropdownKey?: number
}
export const CredentialsDropdown = ({
type,
workspaceId,
currentCredentialsId,
onCredentialsSelect,
onCreateNewClick,
defaultCredentialLabel,
refreshDropdownKey,
...props
}: Props) => {
const router = useRouter()
const { workspace } = useWorkspace()
const { showToast } = useToast()
const { credentials, mutate } = useCredentials({
workspaceId: workspace?.id,
const { data, refetch } = trpc.credentials.listCredentials.useQuery({
workspaceId,
type,
})
const [isDeleting, setIsDeleting] = useState<string>()
const { mutate } = trpc.credentials.deleteCredentials.useMutation({
onMutate: ({ credentialsId }) => {
setIsDeleting(credentialsId)
},
onError: (error) => {
showToast({
description: error.message,
})
},
onSuccess: ({ credentialsId }) => {
if (credentialsId === currentCredentialsId) onCredentialsSelect(undefined)
refetch()
},
onSettled: () => {
setIsDeleting(undefined)
},
})
const defaultCredentialsLabel = defaultCredentialLabel ?? `Select an account`
const credentialsList = useMemo(() => {
return credentials.filter((credential) => credential.type === type)
}, [type, credentials])
const currentCredential = useMemo(
() => credentials.find((c) => c.id === currentCredentialsId),
[currentCredentialsId, credentials]
const currentCredential = data?.credentials.find(
(c) => c.id === currentCredentialsId
)
const handleMenuItemClick = (credentialsId: string) => () => {
onCredentialsSelect(credentialsId)
}
const handleMenuItemClick = useCallback(
(credentialsId: string) => () => {
onCredentialsSelect(credentialsId)
},
[onCredentialsSelect]
)
useEffect(() => {
if ((refreshDropdownKey ?? 0) > 0) mutate({ credentials })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshDropdownKey])
const clearQueryParams = useCallback(() => {
const hasQueryParams = router.asPath.includes('?')
if (hasQueryParams)
router.push(router.asPath.split('?')[0], undefined, { shallow: true })
}, [router])
useEffect(() => {
if (!router.isReady) return
@ -69,29 +84,17 @@ export const CredentialsDropdown = ({
handleMenuItemClick(router.query.credentialsId.toString())()
clearQueryParams()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady])
}, [
clearQueryParams,
handleMenuItemClick,
router.isReady,
router.query.credentialsId,
])
const clearQueryParams = () => {
const hasQueryParams = router.asPath.includes('?')
if (hasQueryParams)
router.push(router.asPath.split('?')[0], undefined, { shallow: true })
}
const handleDeleteDomainClick =
const deleteCredentials =
(credentialsId: string) => async (e: React.MouseEvent) => {
e.stopPropagation()
if (!workspace?.id) return
setIsDeleting(credentialsId)
const { error } = await deleteCredentialsQuery(
workspace.id,
credentialsId
)
setIsDeleting(undefined)
if (error)
return showToast({ title: error.name, description: error.message })
onCredentialsSelect(undefined)
mutate({ credentials: credentials.filter((c) => c.id !== credentialsId) })
mutate({ workspaceId, credentialsId })
}
return (
@ -121,7 +124,7 @@ export const CredentialsDropdown = ({
{defaultCredentialLabel}
</MenuItem>
)}
{credentialsList.map((credentials) => (
{data?.credentials.map((credentials) => (
<MenuItem
role="menuitem"
minH="40px"
@ -137,7 +140,7 @@ export const CredentialsDropdown = ({
icon={<TrashIcon />}
aria-label="Remove credentials"
size="xs"
onClick={handleDeleteDomainClick(credentials.id)}
onClick={deleteCredentials(credentials.id)}
isLoading={isDeleting === credentials.id}
/>
</MenuItem>

View File

@ -1,23 +0,0 @@
import { fetcher } from '@/utils/helpers'
import { Credentials } from 'models'
import { stringify } from 'qs'
import useSWR from 'swr'
export const useCredentials = ({
workspaceId,
onError,
}: {
workspaceId?: string
onError?: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ credentials: Credentials[] }, Error>(
workspaceId ? `/api/credentials?${stringify({ workspaceId })}` : null,
fetcher
)
if (error && onError) onError(error)
return {
credentials: data?.credentials ?? [],
isLoading: !error && !data,
mutate,
}
}

View File

@ -1,4 +1 @@
export { CredentialsDropdown } from './components/CredentialsDropdown'
export { useCredentials } from './hooks/useCredentials'
export { createCredentialsQuery } from './queries/createCredentialsQuery'
export { deleteCredentialsQuery } from './queries/deleteCredentialsQuery'

View File

@ -1,16 +0,0 @@
import { Credentials } from 'models'
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const createCredentialsQuery = async (
credentials: Omit<Credentials, 'id' | 'iv' | 'createdAt'>
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/credentials?${stringify({
workspaceId: credentials.workspaceId,
})}`,
method: 'POST',
body: credentials,
})

View File

@ -1,14 +0,0 @@
import { Credentials } from 'db'
import { stringify } from 'qs'
import { sendRequest } from 'utils'
export const deleteCredentialsQuery = async (
workspaceId: string,
credentialsId: string
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/credentials/${credentialsId}?${stringify({ workspaceId })}`,
method: 'DELETE',
})

View File

@ -38,6 +38,7 @@ import { AudioBubbleIcon } from '@/features/blocks/bubbles/audio'
import { WaitIcon } from '@/features/blocks/logic/wait/components/WaitIcon'
import { ScriptIcon } from '@/features/blocks/logic/script/components/ScriptIcon'
import { JumpIcon } from '@/features/blocks/logic/jump/components/JumpIcon'
import { OpenAILogo } from '@/features/blocks/integrations/openai/components/OpenAILogo'
type BlockIconProps = { type: BlockType } & IconProps
@ -106,6 +107,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => {
return <SendEmailIcon {...props} />
case IntegrationBlockType.CHATWOOT:
return <ChatwootLogo {...props} />
case IntegrationBlockType.OPEN_AI:
return <OpenAILogo {...props} />
case 'start':
return <FlagIcon {...props} />
}

View File

@ -73,5 +73,7 @@ export const BlockLabel = ({ type }: Props): JSX.Element => {
return <Text>Email</Text>
case IntegrationBlockType.CHATWOOT:
return <Text>Chatwoot</Text>
case IntegrationBlockType.OPEN_AI:
return <Text>OpenAI</Text>
}
}

View File

@ -40,6 +40,7 @@ import { WaitNodeContent } from '@/features/blocks/logic/wait/components/WaitNod
import { ScriptNodeContent } from '@/features/blocks/logic/script/components/ScriptNodeContent'
import { ButtonsBlockNode } from '@/features/blocks/inputs/buttons/components/ButtonsBlockNode'
import { JumpNodeBody } from '@/features/blocks/logic/jump/components/JumpNodeBody'
import { OpenAINodeBody } from '@/features/blocks/integrations/openai/components/OpenAINodeBody'
type Props = {
block: Block | StartBlock
@ -169,6 +170,18 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
case IntegrationBlockType.CHATWOOT: {
return <ChatwootBlockNodeLabel block={block} />
}
case IntegrationBlockType.OPEN_AI: {
return (
<OpenAINodeBody
task={block.options.task}
responseMapping={
'responseMapping' in block.options
? block.options.responseMapping
: []
}
/>
)
}
case 'start': {
return <Text>Start</Text>
}

View File

@ -44,6 +44,7 @@ import { ScriptSettings } from '@/features/blocks/logic/script/components/Script
import { JumpSettings } from '@/features/blocks/logic/jump/components/JumpSettings'
import { MakeComSettings } from '@/features/blocks/integrations/makeCom/components/MakeComSettings'
import { PabblyConnectSettings } from '@/features/blocks/integrations/pabbly/components/PabblyConnectSettings'
import { OpenAISettings } from '@/features/blocks/integrations/openai/components/OpenAISettings'
type Props = {
block: BlockWithOptions
@ -97,7 +98,7 @@ export const BlockSettings = ({
block: BlockWithOptions
onBlockChange: (block: Partial<Block>) => void
}): JSX.Element => {
const handleOptionsChange = (options: BlockOptions) => {
const updateOptions = (options: BlockOptions) => {
onBlockChange({ options } as Partial<Block>)
}
@ -106,7 +107,7 @@ export const BlockSettings = ({
return (
<TextInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -114,7 +115,7 @@ export const BlockSettings = ({
return (
<NumberInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -122,7 +123,7 @@ export const BlockSettings = ({
return (
<EmailInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -130,7 +131,7 @@ export const BlockSettings = ({
return (
<UrlInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -138,7 +139,7 @@ export const BlockSettings = ({
return (
<DateInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -146,7 +147,7 @@ export const BlockSettings = ({
return (
<PhoneNumberSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -154,7 +155,7 @@ export const BlockSettings = ({
return (
<ButtonsBlockSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -162,7 +163,7 @@ export const BlockSettings = ({
return (
<PaymentSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -170,7 +171,7 @@ export const BlockSettings = ({
return (
<RatingInputSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -178,7 +179,7 @@ export const BlockSettings = ({
return (
<FileInputSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -186,7 +187,7 @@ export const BlockSettings = ({
return (
<SetVariableSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -194,7 +195,7 @@ export const BlockSettings = ({
return (
<RedirectSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -202,7 +203,7 @@ export const BlockSettings = ({
return (
<ScriptSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -210,16 +211,13 @@ export const BlockSettings = ({
return (
<TypebotLinkForm
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
case LogicBlockType.WAIT: {
return (
<WaitSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
<WaitSettings options={block.options} onOptionsChange={updateOptions} />
)
}
case LogicBlockType.JUMP: {
@ -227,7 +225,7 @@ export const BlockSettings = ({
<JumpSettings
groupId={block.groupId}
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -235,7 +233,7 @@ export const BlockSettings = ({
return (
<GoogleSheetsSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
blockId={block.id}
/>
)
@ -244,38 +242,29 @@ export const BlockSettings = ({
return (
<GoogleAnalyticsSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
case IntegrationBlockType.ZAPIER: {
return (
<ZapierSettings block={block} onOptionsChange={handleOptionsChange} />
)
return <ZapierSettings block={block} onOptionsChange={updateOptions} />
}
case IntegrationBlockType.MAKE_COM: {
return (
<MakeComSettings block={block} onOptionsChange={handleOptionsChange} />
)
return <MakeComSettings block={block} onOptionsChange={updateOptions} />
}
case IntegrationBlockType.PABBLY_CONNECT: {
return (
<PabblyConnectSettings
block={block}
onOptionsChange={handleOptionsChange}
/>
<PabblyConnectSettings block={block} onOptionsChange={updateOptions} />
)
}
case IntegrationBlockType.WEBHOOK: {
return (
<WebhookSettings block={block} onOptionsChange={handleOptionsChange} />
)
return <WebhookSettings block={block} onOptionsChange={updateOptions} />
}
case IntegrationBlockType.EMAIL: {
return (
<SendEmailSettings
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
@ -283,7 +272,15 @@ export const BlockSettings = ({
return (
<ChatwootSettingsForm
options={block.options}
onOptionsChange={handleOptionsChange}
onOptionsChange={updateOptions}
/>
)
}
case IntegrationBlockType.OPEN_AI: {
return (
<OpenAISettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}

View File

@ -426,6 +426,8 @@ const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => {
return defaultSendEmailOptions
case IntegrationBlockType.CHATWOOT:
return defaultChatwootOptions
case IntegrationBlockType.OPEN_AI:
return {}
}
}

View File

@ -72,7 +72,7 @@ export const StandardSettings = ({
w="70px"
value={inputValues.widthValue}
/>
<DropdownList<string>
<DropdownList
items={['px', '%']}
onItemSelect={handleWidthTypeSelect}
currentItem={inputValues.widthType}
@ -92,7 +92,7 @@ export const StandardSettings = ({
w="70px"
value={inputValues.heightValue}
/>
<DropdownList<string>
<DropdownList
items={['px', '%']}
onItemSelect={handleHeightTypeSelect}
currentItem={inputValues.heightType}

View File

@ -1,6 +1,6 @@
import { Credentials as CredentialsFromDb } from 'db'
import { OAuth2Client, Credentials } from 'google-auth-library'
import { GoogleSheetsCredentialsData } from 'models'
import { GoogleSheetsCredentials } from 'models'
import { isDefined } from 'utils'
import { decrypt, encrypt } from 'utils/api'
import prisma from './prisma'
@ -24,7 +24,7 @@ export const getAuthenticatedGoogleClient = async (
const data = decrypt(
credentials.data,
credentials.iv
) as GoogleSheetsCredentialsData
) as GoogleSheetsCredentials['data']
oauth2Client.setCredentials(data)
oauth2Client.on('tokens', updateTokens(credentials.id, data))
@ -32,14 +32,17 @@ export const getAuthenticatedGoogleClient = async (
}
const updateTokens =
(credentialsId: string, existingCredentials: GoogleSheetsCredentialsData) =>
(
credentialsId: string,
existingCredentials: GoogleSheetsCredentials['data']
) =>
async (credentials: Credentials) => {
if (
isDefined(existingCredentials.id_token) &&
credentials.id_token !== existingCredentials.id_token
)
return
const newCredentials: GoogleSheetsCredentialsData = {
const newCredentials: GoogleSheetsCredentials['data'] = {
...existingCredentials,
expiry_date: credentials.expiry_date,
access_token: credentials.access_token,

View File

@ -3,7 +3,6 @@ import { Prisma } from 'db'
import prisma from '@/lib/prisma'
import { googleSheetsScopes } from './consent-url'
import { stringify } from 'querystring'
import { CredentialsType } from 'models'
import { badRequest, encrypt, notAuthenticated } from 'utils/api'
import { oauth2Client } from '@/lib/googleSheets'
import { getAuthenticatedUser } from '@/features/auth/api'
@ -41,11 +40,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { encryptedData, iv } = encrypt(tokens)
const credentials = {
name: email,
type: CredentialsType.GOOGLE_SHEETS,
type: 'google sheets',
workspaceId,
data: encryptedData,
iv,
} as Prisma.CredentialsUncheckedCreateInput
} satisfies Prisma.CredentialsUncheckedCreateInput
const { id: credentialsId } = await prisma.credentials.create({
data: credentials,
})

View File

@ -1,8 +1,8 @@
import { SmtpCredentialsData } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { createTransport } from 'nodemailer'
import { getAuthenticatedUser } from '@/features/auth/api'
import { notAuthenticated } from 'utils/api'
import { SmtpCredentials } from 'models'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
@ -10,7 +10,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const { from, port, isTlsEnabled, username, password, host, to } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as SmtpCredentialsData & { to: string }
) as SmtpCredentials['data'] & { to: string }
const transporter = createTransport({
host,
port,

View File

@ -1,5 +1,6 @@
import { billingRouter } from '@/features/billing/api/router'
import { webhookRouter } from '@/features/blocks/integrations/webhook/api'
import { credentialsRouter } from '@/features/credentials/api/router'
import { getAppVersionProcedure } from '@/features/dashboard/api/getAppVersionProcedure'
import { resultsRouter } from '@/features/results/api'
import { typebotRouter } from '@/features/typebot/api'
@ -13,6 +14,7 @@ export const trpcRouter = router({
webhook: webhookRouter,
results: resultsRouter,
billing: billingRouter,
credentials: credentialsRouter,
})
export type AppRouter = typeof trpcRouter