feat(editor): ✨ Add send email integration
This commit is contained in:
22
.env.example
22
.env.example
@ -1,14 +1,24 @@
|
|||||||
DATABASE_URL=postgresql://username:password@host:5450/typebot?schema=public
|
DATABASE_URL=postgresql://username:password@host:5450/typebot?schema=public
|
||||||
|
|
||||||
SECRET=secret
|
SECRET=q3t6v9y$B&E)H@McQfTjWnZr4u7x!z%C # 256-bits secret (can be generated here: https://www.allkeysgenerator.com/Random/Security-Encryption-Key-Generator.aspx)
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# Used for email auth and email notifications
|
# Used for email auth and email notifications
|
||||||
EMAIL_SERVER_USER=username
|
AUTH_EMAIL_SERVER_USERNAME=username
|
||||||
EMAIL_SERVER_PASSWORD=password
|
AUTH_EMAIL_SERVER_PASSWORD=password
|
||||||
EMAIL_SERVER_HOST=smtp.example.com
|
AUTH_EMAIL_SERVER_HOST=smtp.example.com
|
||||||
EMAIL_SERVER_PORT=587
|
AUTH_EMAIL_SERVER_PORT=587
|
||||||
EMAIL_FROM=noreply@example.com
|
AUTH_EMAIL_FROM_EMAIL=noreply@example.com
|
||||||
|
AUTH_EMAIL_FROM_NAME="John Smith"
|
||||||
|
|
||||||
|
# (Optional) Used for email notifications
|
||||||
|
EMAIL_NOTIFICATIONS_SERVER_USERNAME=username
|
||||||
|
EMAIL_NOTIFICATIONS_SERVER_PASSWORD=password
|
||||||
|
EMAIL_NOTIFICATIONS_SERVER_HOST=smtp.example.com
|
||||||
|
EMAIL_NOTIFICATIONS_SERVER_PORT=587
|
||||||
|
EMAIL_NOTIFICATIONS_FROM_EMAIL=noreply@example.com
|
||||||
|
EMAIL_NOTIFICATIONS_FROM_NAME="John Smith"
|
||||||
|
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
# Used for uploading images, videos, etc...
|
# Used for uploading images, videos, etc...
|
||||||
|
@ -339,3 +339,10 @@ export const EyeIcon = (props: IconProps) => (
|
|||||||
<circle cx="12" cy="12" r="3"></circle>
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
</Icon>
|
</Icon>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const SendEmailIcon = (props: IconProps) => (
|
||||||
|
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
NumberIcon,
|
NumberIcon,
|
||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
|
SendEmailIcon,
|
||||||
TextIcon,
|
TextIcon,
|
||||||
WebhookIcon,
|
WebhookIcon,
|
||||||
} from 'assets/icons'
|
} from 'assets/icons'
|
||||||
@ -61,7 +62,9 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
|
|||||||
case IntegrationStepType.GOOGLE_ANALYTICS:
|
case IntegrationStepType.GOOGLE_ANALYTICS:
|
||||||
return <GoogleAnalyticsLogo {...props} />
|
return <GoogleAnalyticsLogo {...props} />
|
||||||
case IntegrationStepType.WEBHOOK:
|
case IntegrationStepType.WEBHOOK:
|
||||||
return <WebhookIcon />
|
return <WebhookIcon {...props} />
|
||||||
|
case IntegrationStepType.EMAIL:
|
||||||
|
return <SendEmailIcon {...props} />
|
||||||
case 'start':
|
case 'start':
|
||||||
return <FlagIcon {...props} />
|
return <FlagIcon {...props} />
|
||||||
default:
|
default:
|
||||||
|
@ -51,6 +51,8 @@ export const StepTypeLabel = ({ type }: Props) => {
|
|||||||
)
|
)
|
||||||
case IntegrationStepType.WEBHOOK:
|
case IntegrationStepType.WEBHOOK:
|
||||||
return <Text>Webhook</Text>
|
return <Text>Webhook</Text>
|
||||||
|
case IntegrationStepType.EMAIL:
|
||||||
|
return <Text>Email</Text>
|
||||||
default:
|
default:
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
@ -11,14 +11,15 @@ import {
|
|||||||
import { ChevronLeftIcon, PlusIcon } from 'assets/icons'
|
import { ChevronLeftIcon, PlusIcon } from 'assets/icons'
|
||||||
import React, { useEffect, useMemo } from 'react'
|
import React, { useEffect, useMemo } from 'react'
|
||||||
import { useUser } from 'contexts/UserContext'
|
import { useUser } from 'contexts/UserContext'
|
||||||
import { CredentialsType } from 'db'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
import { CredentialsType } from 'models'
|
||||||
|
|
||||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||||
type: CredentialsType
|
type: CredentialsType
|
||||||
currentCredentialsId?: string
|
currentCredentialsId?: string
|
||||||
onCredentialsSelect: (credentialId: string) => void
|
onCredentialsSelect: (credentialId: string) => void
|
||||||
onCreateNewClick: () => void
|
onCreateNewClick: () => void
|
||||||
|
defaultCredentialLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CredentialsDropdown = ({
|
export const CredentialsDropdown = ({
|
||||||
@ -26,11 +27,14 @@ export const CredentialsDropdown = ({
|
|||||||
currentCredentialsId,
|
currentCredentialsId,
|
||||||
onCredentialsSelect,
|
onCredentialsSelect,
|
||||||
onCreateNewClick,
|
onCreateNewClick,
|
||||||
|
defaultCredentialLabel,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { credentials } = useUser()
|
const { credentials } = useUser()
|
||||||
|
|
||||||
|
const defaultCredentialsLabel = defaultCredentialLabel ?? `Select an account`
|
||||||
|
|
||||||
const credentialsList = useMemo(() => {
|
const credentialsList = useMemo(() => {
|
||||||
return credentials.filter((credential) => credential.type === type)
|
return credentials.filter((credential) => credential.type === type)
|
||||||
}, [type, credentials])
|
}, [type, credentials])
|
||||||
@ -70,11 +74,22 @@ export const CredentialsDropdown = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Text isTruncated overflowY="visible" h="20px">
|
<Text isTruncated overflowY="visible" h="20px">
|
||||||
{currentCredential ? currentCredential.name : 'Select an account'}
|
{currentCredential ? currentCredential.name : defaultCredentialsLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList maxW="500px">
|
<MenuList maxW="500px">
|
||||||
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||||
|
{defaultCredentialLabel && (
|
||||||
|
<MenuItem
|
||||||
|
maxW="500px"
|
||||||
|
overflow="hidden"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
onClick={handleMenuItemClick('default')}
|
||||||
|
>
|
||||||
|
{defaultCredentialLabel}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{credentialsList.map((credentials) => (
|
{credentialsList.map((credentials) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={credentials.id}
|
key={credentials.id}
|
||||||
|
@ -34,6 +34,7 @@ import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
|
|||||||
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
|
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
|
||||||
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
|
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
|
||||||
import { RedirectSettings } from './bodies/RedirectSettings'
|
import { RedirectSettings } from './bodies/RedirectSettings'
|
||||||
|
import { SendEmailSettings } from './bodies/SendEmailSettings/SendEmailSettings'
|
||||||
import { SetVariableSettings } from './bodies/SetVariableSettings'
|
import { SetVariableSettings } from './bodies/SetVariableSettings'
|
||||||
import { WebhookSettings } from './bodies/WebhookSettings'
|
import { WebhookSettings } from './bodies/WebhookSettings'
|
||||||
|
|
||||||
@ -213,6 +214,14 @@ export const StepSettings = ({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
case IntegrationStepType.EMAIL: {
|
||||||
|
return (
|
||||||
|
<SendEmailSettings
|
||||||
|
options={step.options}
|
||||||
|
onOptionsChange={handleOptionsChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,9 @@ import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
|
|||||||
import { DropdownList } from 'components/shared/DropdownList'
|
import { DropdownList } from 'components/shared/DropdownList'
|
||||||
import { TableList, TableListItemProps } from 'components/shared/TableList'
|
import { TableList, TableListItemProps } from 'components/shared/TableList'
|
||||||
import { useTypebot } from 'contexts/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
import { CredentialsType } from 'db'
|
|
||||||
import {
|
import {
|
||||||
Cell,
|
Cell,
|
||||||
|
CredentialsType,
|
||||||
ExtractingCell,
|
ExtractingCell,
|
||||||
GoogleSheetsAction,
|
GoogleSheetsAction,
|
||||||
GoogleSheetsGetOptions,
|
GoogleSheetsGetOptions,
|
||||||
|
@ -0,0 +1,92 @@
|
|||||||
|
import { Stack, useDisclosure, Text } from '@chakra-ui/react'
|
||||||
|
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
|
||||||
|
import {
|
||||||
|
InputWithVariableButton,
|
||||||
|
TextareaWithVariableButton,
|
||||||
|
} from 'components/shared/TextboxWithVariableButton'
|
||||||
|
import { CredentialsType, SendEmailOptions } from 'models'
|
||||||
|
import React from 'react'
|
||||||
|
import { SmtpConfigModal } from './SmtpConfigModal'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
options: SendEmailOptions
|
||||||
|
onOptionsChange: (options: SendEmailOptions) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
|
const handleCredentialsSelect = (credentialsId: string) =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
credentialsId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToChange = (recipientsStr: string) => {
|
||||||
|
const recipients: string[] = recipientsStr
|
||||||
|
.split(',')
|
||||||
|
.map((str) => str.trim())
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
recipients,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubjectChange = (subject: string) =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
subject,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBodyChange = (body: string) =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Stack>
|
||||||
|
<Text>From: </Text>
|
||||||
|
<CredentialsDropdown
|
||||||
|
type={CredentialsType.SMTP}
|
||||||
|
currentCredentialsId={options.credentialsId}
|
||||||
|
onCredentialsSelect={handleCredentialsSelect}
|
||||||
|
onCreateNewClick={onOpen}
|
||||||
|
defaultCredentialLabel={
|
||||||
|
process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Text>To: </Text>
|
||||||
|
<InputWithVariableButton
|
||||||
|
onChange={handleToChange}
|
||||||
|
initialValue={options.recipients.join(', ')}
|
||||||
|
placeholder="email1@gmail.com, email2@gmail.com"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Text>Subject: </Text>
|
||||||
|
<InputWithVariableButton
|
||||||
|
data-testid="subject-input"
|
||||||
|
onChange={handleSubjectChange}
|
||||||
|
initialValue={options.subject ?? ''}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Text>Body: </Text>
|
||||||
|
<TextareaWithVariableButton
|
||||||
|
data-testid="body-input"
|
||||||
|
minH="300px"
|
||||||
|
onChange={handleBodyChange}
|
||||||
|
initialValue={options.body ?? ''}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<SmtpConfigModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
onNewCredentials={handleCredentialsSelect}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
import { FormControl, FormLabel, HStack, Stack } from '@chakra-ui/react'
|
||||||
|
import { isDefined } from '@udecode/plate-common'
|
||||||
|
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
||||||
|
import { SmartNumberInput } from 'components/shared/SmartNumberInput'
|
||||||
|
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||||
|
import { SmtpCredentialsData } from 'models'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
config: SmtpCredentialsData
|
||||||
|
onConfigChange: (config: SmtpCredentialsData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpConfigForm = ({ config, onConfigChange }: Props) => {
|
||||||
|
const handleFromEmailChange = (email: string) =>
|
||||||
|
onConfigChange({ ...config, from: { ...config.from, email } })
|
||||||
|
const handleFromNameChange = (name: string) =>
|
||||||
|
onConfigChange({ ...config, from: { ...config.from, name } })
|
||||||
|
const handleHostChange = (host: string) => onConfigChange({ ...config, host })
|
||||||
|
const handleUsernameChange = (username: string) =>
|
||||||
|
onConfigChange({ ...config, username })
|
||||||
|
const handlePasswordChange = (password: string) =>
|
||||||
|
onConfigChange({ ...config, password })
|
||||||
|
const handleTlsCheck = (isTlsEnabled: boolean) =>
|
||||||
|
onConfigChange({ ...config, isTlsEnabled })
|
||||||
|
const handlePortNumberChange = (port?: number) =>
|
||||||
|
isDefined(port) && onConfigChange({ ...config, port })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack as="form" spacing={4}>
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>From email:</FormLabel>
|
||||||
|
<DebouncedInput
|
||||||
|
initialValue={config.from.email ?? ''}
|
||||||
|
onChange={handleFromEmailChange}
|
||||||
|
placeholder="notifications@provider.com"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>From name:</FormLabel>
|
||||||
|
<DebouncedInput
|
||||||
|
initialValue={config.from.name ?? ''}
|
||||||
|
onChange={handleFromNameChange}
|
||||||
|
placeholder="John Smith"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>Host:</FormLabel>
|
||||||
|
<DebouncedInput
|
||||||
|
initialValue={config.host ?? ''}
|
||||||
|
onChange={handleHostChange}
|
||||||
|
placeholder="mail.provider.com"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>Username / Email:</FormLabel>
|
||||||
|
<DebouncedInput
|
||||||
|
type="email"
|
||||||
|
initialValue={config.username ?? ''}
|
||||||
|
onChange={handleUsernameChange}
|
||||||
|
placeholder="user@provider.com"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>Password:</FormLabel>
|
||||||
|
<DebouncedInput
|
||||||
|
type="password"
|
||||||
|
initialValue={config.password ?? ''}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<SwitchWithLabel
|
||||||
|
id="Tls"
|
||||||
|
label={'Use TLS?'}
|
||||||
|
initialValue={config.isTlsEnabled ?? false}
|
||||||
|
onCheckChange={handleTlsCheck}
|
||||||
|
/>
|
||||||
|
<FormControl as={HStack} justifyContent="space-between">
|
||||||
|
<FormLabel mb="0">Port number:</FormLabel>
|
||||||
|
<SmartNumberInput
|
||||||
|
placeholder="25"
|
||||||
|
value={config.port}
|
||||||
|
onValueChange={handlePortNumberChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
Button,
|
||||||
|
useToast,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { useUser } from 'contexts/UserContext'
|
||||||
|
import { CredentialsType, SmtpCredentialsData } from 'models'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { createCredentials } from 'services/credentials'
|
||||||
|
import { isNotDefined } from 'utils'
|
||||||
|
import { SmtpConfigForm } from './SmtpConfigForm'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onNewCredentials: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmtpConfigModal = ({
|
||||||
|
isOpen,
|
||||||
|
onNewCredentials,
|
||||||
|
onClose,
|
||||||
|
}: Props) => {
|
||||||
|
const { user, mutateCredentials } = useUser()
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const toast = useToast({
|
||||||
|
position: 'top-right',
|
||||||
|
status: 'error',
|
||||||
|
})
|
||||||
|
const [smtpConfig, setSmtpConfig] = useState<SmtpCredentialsData>({
|
||||||
|
from: {},
|
||||||
|
port: 25,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateClick = async () => {
|
||||||
|
if (!user) return
|
||||||
|
setIsCreating(true)
|
||||||
|
const { data, error } = await createCredentials(user.id, {
|
||||||
|
data: smtpConfig,
|
||||||
|
name: smtpConfig.from.email as string,
|
||||||
|
type: CredentialsType.SMTP,
|
||||||
|
})
|
||||||
|
await mutateCredentials()
|
||||||
|
setIsCreating(false)
|
||||||
|
if (error) return toast({ title: error.name, description: error.message })
|
||||||
|
if (!data?.credentials)
|
||||||
|
return toast({ description: "Credentials wasn't created" })
|
||||||
|
onNewCredentials(data.credentials.id)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Create SMTP config</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<SmtpConfigForm config={smtpConfig} onConfigChange={setSmtpConfig} />
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
colorScheme="blue"
|
||||||
|
mr={3}
|
||||||
|
onClick={handleCreateClick}
|
||||||
|
isDisabled={
|
||||||
|
isNotDefined(smtpConfig.from.email) ||
|
||||||
|
isNotDefined(smtpConfig.from.name) ||
|
||||||
|
isNotDefined(smtpConfig.host) ||
|
||||||
|
isNotDefined(smtpConfig.username) ||
|
||||||
|
isNotDefined(smtpConfig.password)
|
||||||
|
}
|
||||||
|
isLoading={isCreating}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost">Close</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export { SendEmailSettings } from './SendEmailSettings'
|
@ -20,6 +20,7 @@ import {
|
|||||||
import { ConfigureContent } from './contents/ConfigureContent'
|
import { ConfigureContent } from './contents/ConfigureContent'
|
||||||
import { ImageBubbleContent } from './contents/ImageBubbleContent'
|
import { ImageBubbleContent } from './contents/ImageBubbleContent'
|
||||||
import { PlaceholderContent } from './contents/PlaceholderContent'
|
import { PlaceholderContent } from './contents/PlaceholderContent'
|
||||||
|
import { SendEmailContent } from './contents/SendEmailContent'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
step: Step | StartStep
|
step: Step | StartStep
|
||||||
@ -101,6 +102,9 @@ export const StepNodeContent = ({ step, indices }: Props) => {
|
|||||||
case IntegrationStepType.WEBHOOK: {
|
case IntegrationStepType.WEBHOOK: {
|
||||||
return <WebhookContent step={step} />
|
return <WebhookContent step={step} />
|
||||||
}
|
}
|
||||||
|
case IntegrationStepType.EMAIL: {
|
||||||
|
return <SendEmailContent step={step} />
|
||||||
|
}
|
||||||
case 'start': {
|
case 'start': {
|
||||||
return <Text>Start</Text>
|
return <Text>Start</Text>
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
import { Tag, Text, Wrap, WrapItem } from '@chakra-ui/react'
|
||||||
|
import { SendEmailStep } from 'models'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
step: SendEmailStep
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendEmailContent = ({ step }: Props) => {
|
||||||
|
if (step.options.recipients.length === 0)
|
||||||
|
return <Text color="gray.500">Configure...</Text>
|
||||||
|
return (
|
||||||
|
<Wrap isTruncated pr="6">
|
||||||
|
<WrapItem>
|
||||||
|
<Text>Send email to</Text>
|
||||||
|
</WrapItem>
|
||||||
|
{step.options.recipients.map((to) => (
|
||||||
|
<WrapItem key={to}>
|
||||||
|
<Tag>{to}</Tag>
|
||||||
|
</WrapItem>
|
||||||
|
))}
|
||||||
|
</Wrap>
|
||||||
|
)
|
||||||
|
}
|
@ -107,7 +107,6 @@ export const SearchableDropdown = ({
|
|||||||
<PopoverContent
|
<PopoverContent
|
||||||
maxH="35vh"
|
maxH="35vh"
|
||||||
overflowY="scroll"
|
overflowY="scroll"
|
||||||
spacing="0"
|
|
||||||
role="menu"
|
role="menu"
|
||||||
w="inherit"
|
w="inherit"
|
||||||
shadow="lg"
|
shadow="lg"
|
||||||
|
@ -36,7 +36,7 @@ export const TextBoxWithVariableButton = ({
|
|||||||
const [carretPosition, setCarretPosition] = useState<number>(0)
|
const [carretPosition, setCarretPosition] = useState<number>(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== initialValue) onChange(value)
|
onChange(value)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
|
@ -123,7 +123,6 @@ export const VariableSearchInput = ({
|
|||||||
<PopoverContent
|
<PopoverContent
|
||||||
maxH="35vh"
|
maxH="35vh"
|
||||||
overflowY="scroll"
|
overflowY="scroll"
|
||||||
spacing="0"
|
|
||||||
role="menu"
|
role="menu"
|
||||||
w="inherit"
|
w="inherit"
|
||||||
shadow="lg"
|
shadow="lg"
|
||||||
|
@ -13,7 +13,9 @@ import { updateUser as updateUserInDb } from 'services/user'
|
|||||||
import { useToast } from '@chakra-ui/react'
|
import { useToast } from '@chakra-ui/react'
|
||||||
import { deepEqual } from 'fast-equals'
|
import { deepEqual } from 'fast-equals'
|
||||||
import { useCredentials } from 'services/credentials'
|
import { useCredentials } from 'services/credentials'
|
||||||
import { Credentials, User } from 'db'
|
import { User } from 'db'
|
||||||
|
import { KeyedMutator } from 'swr'
|
||||||
|
import { Credentials } from 'models'
|
||||||
|
|
||||||
const userContext = createContext<{
|
const userContext = createContext<{
|
||||||
user?: User
|
user?: User
|
||||||
@ -24,6 +26,9 @@ const userContext = createContext<{
|
|||||||
credentials: Credentials[]
|
credentials: Credentials[]
|
||||||
updateUser: (newUser: Partial<User>) => void
|
updateUser: (newUser: Partial<User>) => void
|
||||||
saveUser: () => void
|
saveUser: () => void
|
||||||
|
mutateCredentials: KeyedMutator<{
|
||||||
|
credentials: Credentials[]
|
||||||
|
}>
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
@ -32,7 +37,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const [user, setUser] = useState<User | undefined>()
|
const [user, setUser] = useState<User | undefined>()
|
||||||
const { credentials } = useCredentials({
|
const { credentials, mutate } = useCredentials({
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
toast({ title: error.name, description: error.message }),
|
toast({ title: error.name, description: error.message }),
|
||||||
@ -94,6 +99,7 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
|||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
isOAuthProvider,
|
isOAuthProvider,
|
||||||
credentials: credentials ?? [],
|
credentials: credentials ?? [],
|
||||||
|
mutateCredentials: mutate,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { Prisma, Credentials as CredentialsFromDb } from 'db'
|
import { Credentials as CredentialsFromDb } from 'db'
|
||||||
import { OAuth2Client, Credentials } from 'google-auth-library'
|
import { OAuth2Client } from 'google-auth-library'
|
||||||
|
import { GoogleSheetsCredentialsData } from 'models'
|
||||||
|
import { decrypt, encrypt } from 'utils'
|
||||||
import prisma from './prisma'
|
import prisma from './prisma'
|
||||||
|
|
||||||
export const oauth2Client = new OAuth2Client(
|
export const oauth2Client = new OAuth2Client(
|
||||||
@ -15,14 +17,21 @@ export const getAuthenticatedGoogleClient = async (
|
|||||||
const credentials = (await prisma.credentials.findFirst({
|
const credentials = (await prisma.credentials.findFirst({
|
||||||
where: { id: credentialsId, ownerId: userId },
|
where: { id: credentialsId, ownerId: userId },
|
||||||
})) as CredentialsFromDb
|
})) as CredentialsFromDb
|
||||||
oauth2Client.setCredentials(credentials.data as Credentials)
|
const data = decrypt(
|
||||||
|
credentials.data,
|
||||||
|
credentials.iv
|
||||||
|
) as GoogleSheetsCredentialsData
|
||||||
|
oauth2Client.setCredentials(data)
|
||||||
oauth2Client.on('tokens', updateTokens(credentialsId))
|
oauth2Client.on('tokens', updateTokens(credentialsId))
|
||||||
return oauth2Client
|
return oauth2Client
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTokens =
|
const updateTokens =
|
||||||
(credentialsId: string) => async (credentials: Credentials) =>
|
(credentialsId: string) =>
|
||||||
prisma.credentials.update({
|
async (credentials: GoogleSheetsCredentialsData) => {
|
||||||
|
const { encryptedData, iv } = encrypt(credentials)
|
||||||
|
return prisma.credentials.update({
|
||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
data: { data: credentials as Prisma.InputJsonValue },
|
data: { data: encryptedData, iv },
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
@ -11,14 +11,14 @@ import { NextApiRequest, NextApiResponse } from 'next'
|
|||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
EmailProvider({
|
EmailProvider({
|
||||||
server: {
|
server: {
|
||||||
host: process.env.EMAIL_SERVER_HOST,
|
host: process.env.AUTH_EMAIL_SERVER_HOST,
|
||||||
port: Number(process.env.EMAIL_SERVER_PORT),
|
port: Number(process.env.AUTH_EMAIL_SERVER_PORT),
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.EMAIL_SERVER_USER,
|
user: process.env.AUTH_EMAIL_SERVER_USER,
|
||||||
pass: process.env.EMAIL_SERVER_PASSWORD,
|
pass: process.env.AUTH_EMAIL_SERVER_PASSWORD,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
from: process.env.EMAIL_FROM,
|
from: `"${process.env.AUTH_EMAIL_FROM_NAME}" <${process.env.AUTH_EMAIL_FROM_EMAIL}>`,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { oauth2Client } from 'libs/google-sheets'
|
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { getSession } from 'next-auth/react'
|
import { getSession } from 'next-auth/react'
|
||||||
import { CredentialsType, Prisma, User } from 'db'
|
import { Prisma, User } from 'db'
|
||||||
import prisma from 'libs/prisma'
|
import prisma from 'libs/prisma'
|
||||||
import { googleSheetsScopes } from './consent-url'
|
import { googleSheetsScopes } from './consent-url'
|
||||||
import { stringify } from 'querystring'
|
import { stringify } from 'querystring'
|
||||||
|
import { CredentialsType } from 'models'
|
||||||
|
import { encrypt } from 'utils'
|
||||||
|
import { oauth2Client } from 'libs/google-sheets'
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const session = await getSession({ req })
|
const session = await getSession({ req })
|
||||||
@ -35,12 +37,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.send({ message: "User didn't accepted required scopes" })
|
.send({ message: "User didn't accepted required scopes" })
|
||||||
|
const { encryptedData, iv } = encrypt(tokens)
|
||||||
const credentials = {
|
const credentials = {
|
||||||
name: email,
|
name: email,
|
||||||
type: CredentialsType.GOOGLE_SHEETS,
|
type: CredentialsType.GOOGLE_SHEETS,
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
data: tokens as Prisma.InputJsonValue,
|
data: encryptedData,
|
||||||
}
|
iv,
|
||||||
|
} as Prisma.CredentialsUncheckedCreateInput
|
||||||
const { id: credentialsId } = await prisma.credentials.upsert({
|
const { id: credentialsId } = await prisma.credentials.upsert({
|
||||||
create: credentials,
|
create: credentials,
|
||||||
update: credentials,
|
update: credentials,
|
||||||
|
@ -47,17 +47,17 @@ const executeWebhook = async (
|
|||||||
const contentType = headers ? headers['Content-Type'] : undefined
|
const contentType = headers ? headers['Content-Type'] : undefined
|
||||||
try {
|
try {
|
||||||
const response = await got(
|
const response = await got(
|
||||||
parseVariables({ text: webhook.url + `?${queryParams}`, variables }),
|
parseVariables(variables)(webhook.url + `?${queryParams}`),
|
||||||
{
|
{
|
||||||
method: webhook.method as Method,
|
method: webhook.method as Method,
|
||||||
headers,
|
headers,
|
||||||
json:
|
json:
|
||||||
contentType !== 'x-www-form-urlencoded' && webhook.body
|
contentType !== 'x-www-form-urlencoded' && webhook.body
|
||||||
? JSON.parse(parseVariables({ text: webhook.body, variables }))
|
? JSON.parse(parseVariables(variables)(webhook.body))
|
||||||
: undefined,
|
: undefined,
|
||||||
form:
|
form:
|
||||||
contentType === 'x-www-form-urlencoded' && webhook.body
|
contentType === 'x-www-form-urlencoded' && webhook.body
|
||||||
? JSON.parse(parseVariables({ text: webhook.body, variables }))
|
? JSON.parse(parseVariables(variables)(webhook.body))
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -96,7 +96,7 @@ const convertKeyValueTableToObject = (
|
|||||||
if (!item.key) return {}
|
if (!item.key) return {}
|
||||||
return {
|
return {
|
||||||
...object,
|
...object,
|
||||||
[item.key]: parseVariables({ text: item.value, variables }),
|
[item.key]: parseVariables(variables)(item.value ?? ''),
|
||||||
}
|
}
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { User } from 'db'
|
import { Prisma, User } from 'db'
|
||||||
import prisma from 'libs/prisma'
|
import prisma from 'libs/prisma'
|
||||||
|
import { Credentials } from 'models'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { getSession } from 'next-auth/react'
|
import { getSession } from 'next-auth/react'
|
||||||
import { methodNotAllowed } from 'utils'
|
import { encrypt, methodNotAllowed } from 'utils'
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const session = await getSession({ req })
|
const session = await getSession({ req })
|
||||||
@ -16,6 +17,20 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const credentials = await prisma.credentials.findMany({
|
const credentials = await prisma.credentials.findMany({
|
||||||
where: { ownerId: user.id },
|
where: { ownerId: user.id },
|
||||||
|
select: { name: true, type: true, ownerId: true, id: true },
|
||||||
|
})
|
||||||
|
return res.send({ credentials })
|
||||||
|
}
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const data = JSON.parse(req.body) as Omit<Credentials, 'ownerId'>
|
||||||
|
const { encryptedData, iv } = encrypt(data.data)
|
||||||
|
const credentials = await prisma.credentials.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
data: encryptedData,
|
||||||
|
iv,
|
||||||
|
ownerId: user.id,
|
||||||
|
} as Prisma.CredentialsUncheckedCreateInput,
|
||||||
})
|
})
|
||||||
return res.send({ credentials })
|
return res.send({ credentials })
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
},
|
||||||
retries: process.env.NO_RETRIES ? 0 : 2,
|
retries: process.env.NO_RETRIES ? 0 : 2,
|
||||||
workers: process.env.CI ? 1 : 3,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
maxFailures: process.env.CI ? 10 : undefined,
|
maxFailures: process.env.CI ? 10 : undefined,
|
||||||
use: {
|
use: {
|
||||||
|
@ -2,4 +2,11 @@ PLAYWRIGHT_BUILDER_TEST_BASE_URL=http://localhost:3000
|
|||||||
|
|
||||||
# For auth
|
# For auth
|
||||||
GITHUB_EMAIL=
|
GITHUB_EMAIL=
|
||||||
GITHUB_PASSWORD=
|
GITHUB_PASSWORD=
|
||||||
|
|
||||||
|
# SMTP Credentials (Generated on https://ethereal.email/)
|
||||||
|
SMTP_HOST=smtp.ethereal.email
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=true
|
||||||
|
SMTP_USERNAME=tobin.tillman65@ethereal.email
|
||||||
|
SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx
|
@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"id": "ckzcj4tfu1686gg1ae4fdj8uv",
|
||||||
|
"createdAt": "2022-02-07T10:06:35.274Z",
|
||||||
|
"updatedAt": "2022-02-07T10:06:35.274Z",
|
||||||
|
"name": "My typebot",
|
||||||
|
"ownerId": "ckz6t9iep0006k31a22j05fwq",
|
||||||
|
"publishedTypebotId": null,
|
||||||
|
"folderId": null,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "kSDJqC9TmM25eAM3a2yn3o",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "phSmjJU2gYq7b11hpima8b",
|
||||||
|
"type": "start",
|
||||||
|
"label": "Start",
|
||||||
|
"blockId": "kSDJqC9TmM25eAM3a2yn3o",
|
||||||
|
"outgoingEdgeId": "vKtpPmbmqgeGC4vwCfPEdv"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Start",
|
||||||
|
"graphCoordinates": { "x": 0, "y": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b5r2MMyftV1nv9vyr6VkZh",
|
||||||
|
"graphCoordinates": { "x": 242, "y": 174 },
|
||||||
|
"title": "Block #2",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "sb7ibhNAKfvs8yy8fz3XRMT",
|
||||||
|
"blockId": "b5r2MMyftV1nv9vyr6VkZh",
|
||||||
|
"type": "text",
|
||||||
|
"content": {
|
||||||
|
"html": "<div>Send email</div>",
|
||||||
|
"richText": [
|
||||||
|
{ "type": "p", "children": [{ "text": "Send email" }] }
|
||||||
|
],
|
||||||
|
"plainText": "Send email"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "svM58drFcdtdJ7DaJCfTLXm",
|
||||||
|
"blockId": "b5r2MMyftV1nv9vyr6VkZh",
|
||||||
|
"type": "choice input",
|
||||||
|
"options": { "buttonLabel": "Send", "isMultipleChoice": false },
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "nxQEmdaQXc9eFjrbrVBavH",
|
||||||
|
"stepId": "svM58drFcdtdJ7DaJCfTLXm",
|
||||||
|
"type": 0,
|
||||||
|
"content": "Go"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outgoingEdgeId": "ioB4s1iRBb8wXiRam8Pp4s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6jr7XM9GbVkJ2Ru1WyL45v",
|
||||||
|
"graphCoordinates": { "x": 609, "y": 429 },
|
||||||
|
"title": "Block #2",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "sr2sdAzN5dGao1gCiDWCG8i",
|
||||||
|
"blockId": "6jr7XM9GbVkJ2Ru1WyL45v",
|
||||||
|
"type": "Email",
|
||||||
|
"options": { "credentialsId": "default", "recipients": [] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variables": [],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"from": {
|
||||||
|
"blockId": "kSDJqC9TmM25eAM3a2yn3o",
|
||||||
|
"stepId": "phSmjJU2gYq7b11hpima8b"
|
||||||
|
},
|
||||||
|
"to": { "blockId": "b5r2MMyftV1nv9vyr6VkZh" },
|
||||||
|
"id": "vKtpPmbmqgeGC4vwCfPEdv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": {
|
||||||
|
"blockId": "b5r2MMyftV1nv9vyr6VkZh",
|
||||||
|
"stepId": "svM58drFcdtdJ7DaJCfTLXm"
|
||||||
|
},
|
||||||
|
"to": { "blockId": "6jr7XM9GbVkJ2Ru1WyL45v" },
|
||||||
|
"id": "ioB4s1iRBb8wXiRam8Pp4s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme": {
|
||||||
|
"chat": {
|
||||||
|
"inputs": {
|
||||||
|
"color": "#303235",
|
||||||
|
"backgroundColor": "#FFFFFF",
|
||||||
|
"placeholderColor": "#9095A0"
|
||||||
|
},
|
||||||
|
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
|
||||||
|
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
|
||||||
|
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
|
||||||
|
},
|
||||||
|
"general": { "font": "Open Sans", "background": { "type": "None" } }
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"general": { "isBrandingEnabled": true },
|
||||||
|
"metadata": {
|
||||||
|
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
|
||||||
|
},
|
||||||
|
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
||||||
|
},
|
||||||
|
"publicId": null
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 1.4 MiB |
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Block,
|
Block,
|
||||||
|
CredentialsType,
|
||||||
defaultSettings,
|
defaultSettings,
|
||||||
defaultTheme,
|
defaultTheme,
|
||||||
PublicBlock,
|
PublicBlock,
|
||||||
@ -7,8 +8,9 @@ import {
|
|||||||
Step,
|
Step,
|
||||||
Typebot,
|
Typebot,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { CredentialsType, DashboardFolder, PrismaClient, User } from 'db'
|
import { DashboardFolder, PrismaClient, User } from 'db'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
|
import { encrypt } from 'utils'
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
@ -48,24 +50,27 @@ export const createFolders = (partialFolders: Partial<DashboardFolder>[]) =>
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
const createCredentials = () =>
|
const createCredentials = () => {
|
||||||
prisma.credentials.createMany({
|
const { encryptedData, iv } = encrypt({
|
||||||
|
expiry_date: 1642441058842,
|
||||||
|
access_token:
|
||||||
|
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
|
||||||
|
// This token is linked to a mock Google account (typebot.test.user@gmail.com)
|
||||||
|
refresh_token:
|
||||||
|
'1//0379tIHBxszeXCgYIARAAGAMSNwF-L9Ir0zhkzhblwXqn3_jYqRP3pajcUpqkjRU3fKZZ_eQakOa28amUHSQ-Q9fMzk89MpRTvkc',
|
||||||
|
})
|
||||||
|
return prisma.credentials.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
name: 'test2@gmail.com',
|
name: 'test2@gmail.com',
|
||||||
ownerId: process.env.PLAYWRIGHT_USER_ID as string,
|
ownerId: process.env.PLAYWRIGHT_USER_ID as string,
|
||||||
type: CredentialsType.GOOGLE_SHEETS,
|
type: CredentialsType.GOOGLE_SHEETS,
|
||||||
data: {
|
data: encryptedData,
|
||||||
expiry_date: 1642441058842,
|
iv,
|
||||||
access_token:
|
|
||||||
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
|
|
||||||
// This token is linked to a mock Google account (typebot.test.user@gmail.com)
|
|
||||||
refresh_token:
|
|
||||||
'1//0379tIHBxszeXCgYIARAAGAMSNwF-L9Ir0zhkzhblwXqn3_jYqRP3pajcUpqkjRU3fKZZ_eQakOa28amUHSQ-Q9fMzk89MpRTvkc',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const updateUser = (data: Partial<User>) =>
|
export const updateUser = (data: Partial<User>) =>
|
||||||
prisma.user.update({
|
prisma.user.update({
|
||||||
|
80
apps/builder/playwright/tests/integrations/sendEmail.spec.ts
Normal file
80
apps/builder/playwright/tests/integrations/sendEmail.spec.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import { importTypebotInDatabase } from '../../services/database'
|
||||||
|
import path from 'path'
|
||||||
|
import { generate } from 'short-uuid'
|
||||||
|
import { typebotViewer } from '../../services/selectorUtils'
|
||||||
|
|
||||||
|
const typebotId = generate()
|
||||||
|
|
||||||
|
test.describe('Send email step', () => {
|
||||||
|
test('its configuration should work', async ({ page }) => {
|
||||||
|
if (
|
||||||
|
!process.env.SMTP_USERNAME ||
|
||||||
|
!process.env.SMTP_PORT ||
|
||||||
|
!process.env.SMTP_SECURE ||
|
||||||
|
!process.env.SMTP_HOST ||
|
||||||
|
!process.env.SMTP_PASSWORD
|
||||||
|
)
|
||||||
|
throw new Error('SMTP_ env vars are missing')
|
||||||
|
await importTypebotInDatabase(
|
||||||
|
path.join(
|
||||||
|
__dirname,
|
||||||
|
'../../fixtures/typebots/integrations/sendEmail.json'
|
||||||
|
),
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/typebots/${typebotId}/edit`)
|
||||||
|
await page.click('text=Configure...')
|
||||||
|
await page.click(
|
||||||
|
`text=${process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL}`
|
||||||
|
)
|
||||||
|
await page.click('text=Connect new')
|
||||||
|
const createButton = page.locator('button >> text=Create')
|
||||||
|
await expect(createButton).toBeDisabled()
|
||||||
|
await page.fill(
|
||||||
|
'[placeholder="notifications@provider.com"]',
|
||||||
|
process.env.SMTP_USERNAME
|
||||||
|
)
|
||||||
|
await page.fill('[placeholder="John Smith"]', 'John Smith')
|
||||||
|
await page.fill('[placeholder="mail.provider.com"]', process.env.SMTP_HOST)
|
||||||
|
await page.fill(
|
||||||
|
'[placeholder="user@provider.com"]',
|
||||||
|
process.env.SMTP_USERNAME
|
||||||
|
)
|
||||||
|
await page.fill('[type="password"]', process.env.SMTP_PASSWORD)
|
||||||
|
if (process.env.SMTP_SECURE === 'true') await page.click('text=Use TLS?')
|
||||||
|
await page.fill('input[role="spinbutton"]', process.env.SMTP_PORT)
|
||||||
|
await expect(createButton).toBeEnabled()
|
||||||
|
await createButton.click()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator(`button >> text=${process.env.SMTP_USERNAME}`)
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
await page.fill(
|
||||||
|
'[placeholder="email1@gmail.com, email2@gmail.com"]',
|
||||||
|
'email1@gmail.com, email2@gmail.com'
|
||||||
|
)
|
||||||
|
await expect(page.locator('span >> text=email1@gmail.com')).toBeVisible()
|
||||||
|
await expect(page.locator('span >> text=email2@gmail.com')).toBeVisible()
|
||||||
|
|
||||||
|
await page.fill(
|
||||||
|
'[placeholder="email1@gmail.com, email2@gmail.com"]',
|
||||||
|
'email1@gmail.com, email2@gmail.com'
|
||||||
|
)
|
||||||
|
await page.fill('[data-testid="subject-input"]', 'Email subject')
|
||||||
|
await page.fill('[data-testid="body-input"]', 'Here is my email')
|
||||||
|
|
||||||
|
await page.click('text=Preview')
|
||||||
|
await typebotViewer(page).locator('text=Go').click()
|
||||||
|
await page.waitForResponse(
|
||||||
|
(resp) =>
|
||||||
|
resp.request().url().includes('/api/integrations/email') &&
|
||||||
|
resp.status() === 200 &&
|
||||||
|
resp.request().method() === 'POST'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
@ -1,5 +1,6 @@
|
|||||||
import { Credentials } from 'db'
|
import { Credentials } from 'models'
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
|
import { sendRequest } from 'utils'
|
||||||
import { fetcher } from './utils'
|
import { fetcher } from './utils'
|
||||||
|
|
||||||
export const useCredentials = ({
|
export const useCredentials = ({
|
||||||
@ -20,3 +21,15 @@ export const useCredentials = ({
|
|||||||
mutate,
|
mutate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createCredentials = async (
|
||||||
|
userId: string,
|
||||||
|
credentials: Omit<Credentials, 'ownerId' | 'id' | 'iv'>
|
||||||
|
) =>
|
||||||
|
sendRequest<{
|
||||||
|
credentials: Credentials
|
||||||
|
}>({
|
||||||
|
url: `/api/users/${userId}/credentials`,
|
||||||
|
method: 'POST',
|
||||||
|
body: credentials,
|
||||||
|
})
|
||||||
|
@ -34,6 +34,7 @@ import {
|
|||||||
Item,
|
Item,
|
||||||
ItemType,
|
ItemType,
|
||||||
defaultConditionContent,
|
defaultConditionContent,
|
||||||
|
defaultSendEmailOptions,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import shortId, { generate } from 'short-uuid'
|
import shortId, { generate } from 'short-uuid'
|
||||||
import { Typebot } from 'models'
|
import { Typebot } from 'models'
|
||||||
@ -207,6 +208,8 @@ const parseDefaultStepOptions = (type: StepWithOptionsType): StepOptions => {
|
|||||||
return defaultGoogleAnalyticsOptions
|
return defaultGoogleAnalyticsOptions
|
||||||
case IntegrationStepType.WEBHOOK:
|
case IntegrationStepType.WEBHOOK:
|
||||||
return defaultWebhookOptions
|
return defaultWebhookOptions
|
||||||
|
case IntegrationStepType.EMAIL:
|
||||||
|
return defaultSendEmailOptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { Prisma, Credentials as CredentialsFromDb } from 'db'
|
import { Credentials as CredentialsFromDb } from 'db'
|
||||||
import { OAuth2Client, Credentials } from 'google-auth-library'
|
import { OAuth2Client, Credentials } from 'google-auth-library'
|
||||||
|
import { GoogleSheetsCredentialsData } from 'models'
|
||||||
|
import { decrypt, encrypt } from 'utils'
|
||||||
import prisma from './prisma'
|
import prisma from './prisma'
|
||||||
|
|
||||||
export const oauth2Client = new OAuth2Client(
|
export const oauth2Client = new OAuth2Client(
|
||||||
@ -14,14 +16,20 @@ export const getAuthenticatedGoogleClient = async (
|
|||||||
const credentials = (await prisma.credentials.findFirst({
|
const credentials = (await prisma.credentials.findFirst({
|
||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
})) as CredentialsFromDb
|
})) as CredentialsFromDb
|
||||||
oauth2Client.setCredentials(credentials.data as Credentials)
|
const data = decrypt(
|
||||||
|
credentials.data,
|
||||||
|
credentials.iv
|
||||||
|
) as GoogleSheetsCredentialsData
|
||||||
|
oauth2Client.setCredentials(data)
|
||||||
oauth2Client.on('tokens', updateTokens(credentialsId))
|
oauth2Client.on('tokens', updateTokens(credentialsId))
|
||||||
return oauth2Client
|
return oauth2Client
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTokens =
|
const updateTokens =
|
||||||
(credentialsId: string) => async (credentials: Credentials) =>
|
(credentialsId: string) => async (credentials: Credentials) => {
|
||||||
prisma.credentials.update({
|
const { encryptedData, iv } = encrypt(credentials)
|
||||||
|
return prisma.credentials.update({
|
||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
data: { data: credentials as Prisma.InputJsonValue },
|
data: { data: encryptedData, iv },
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
"google-spreadsheet": "^3.2.0",
|
"google-spreadsheet": "^3.2.0",
|
||||||
"models": "*",
|
"models": "*",
|
||||||
"next": "^12.0.7",
|
"next": "^12.0.7",
|
||||||
|
"nodemailer": "^6.7.2",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"utils": "*"
|
"utils": "*"
|
||||||
@ -23,6 +24,7 @@
|
|||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
"@types/google-spreadsheet": "^3.1.5",
|
"@types/google-spreadsheet": "^3.1.5",
|
||||||
"@types/node": "^17.0.4",
|
"@types/node": "^17.0.4",
|
||||||
|
"@types/nodemailer": "^6.4.4",
|
||||||
"@types/react": "^17.0.38",
|
"@types/react": "^17.0.38",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.9.0",
|
"@typescript-eslint/eslint-plugin": "^5.9.0",
|
||||||
"eslint": "<8.0.0",
|
"eslint": "<8.0.0",
|
||||||
|
68
apps/viewer/pages/api/integrations/email.ts
Normal file
68
apps/viewer/pages/api/integrations/email.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import prisma from 'libs/prisma'
|
||||||
|
import { SendEmailOptions, SmtpCredentialsData } from 'models'
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import { createTransport } from 'nodemailer'
|
||||||
|
import { Options } from 'nodemailer/lib/smtp-transport'
|
||||||
|
import { decrypt, initMiddleware } from 'utils'
|
||||||
|
|
||||||
|
import Cors from 'cors'
|
||||||
|
|
||||||
|
const cors = initMiddleware(Cors())
|
||||||
|
|
||||||
|
const defaultTransportOptions: Options = {
|
||||||
|
host: process.env.EMAIL_NOTIFICATIONS_SERVER_HOST,
|
||||||
|
port: Number(process.env.EMAIL_NOTIFICATIONS_SERVER_PORT),
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_NOTIFICATIONS_SERVER_USER,
|
||||||
|
pass: process.env.EMAIL_NOTIFICATIONS_SERVER_PASSWORD,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFrom = `"${process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_NAME}" <${process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL}>`
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
await cors(req, res)
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const { credentialsId, recipients, body, subject } = JSON.parse(
|
||||||
|
req.body
|
||||||
|
) as SendEmailOptions
|
||||||
|
const credentials = await prisma.credentials.findUnique({
|
||||||
|
where: { id: credentialsId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!credentials)
|
||||||
|
return res.status(404).send({ message: "Couldn't find credentials" })
|
||||||
|
const { host, port, isTlsEnabled, username, password, from } = decrypt(
|
||||||
|
credentials.data,
|
||||||
|
credentials.iv
|
||||||
|
) as SmtpCredentialsData
|
||||||
|
|
||||||
|
const transporter = createTransport(
|
||||||
|
credentialsId === 'default'
|
||||||
|
? defaultTransportOptions
|
||||||
|
: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: isTlsEnabled ?? undefined,
|
||||||
|
auth: {
|
||||||
|
user: username,
|
||||||
|
pass: password,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from:
|
||||||
|
credentialsId === 'default'
|
||||||
|
? defaultFrom
|
||||||
|
: `"${from.name}" <${from.email}>`,
|
||||||
|
to: recipients.join(', '),
|
||||||
|
subject,
|
||||||
|
text: body,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).send({ message: 'Email sent!', info })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default handler
|
@ -22,8 +22,7 @@ export const ImageBubble = ({ step, onTransitionEnd }: Props) => {
|
|||||||
const [isTyping, setIsTyping] = useState(true)
|
const [isTyping, setIsTyping] = useState(true)
|
||||||
|
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() => parseVariables(typebot.variables)(step.content?.url),
|
||||||
parseVariables({ text: step.content?.url, variables: typebot.variables }),
|
|
||||||
[step.content?.url, typebot.variables]
|
[step.content?.url, typebot.variables]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -26,8 +26,7 @@ export const TextBubble = ({ step, onTransitionEnd }: Props) => {
|
|||||||
const [isTyping, setIsTyping] = useState(true)
|
const [isTyping, setIsTyping] = useState(true)
|
||||||
|
|
||||||
const content = useMemo(
|
const content = useMemo(
|
||||||
() =>
|
() => parseVariables(typebot.variables)(step.content.html),
|
||||||
parseVariables({ text: step.content.html, variables: typebot.variables }),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[typebot.variables]
|
[typebot.variables]
|
||||||
)
|
)
|
||||||
|
@ -85,7 +85,7 @@ const VideoContent = ({
|
|||||||
variables: Variable[]
|
variables: Variable[]
|
||||||
}) => {
|
}) => {
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() => parseVariables({ text: content?.url, variables: variables }),
|
() => parseVariables(variables)(content?.url),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[variables]
|
[variables]
|
||||||
)
|
)
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
GoogleSheetsGetOptions,
|
GoogleSheetsGetOptions,
|
||||||
GoogleAnalyticsStep,
|
GoogleAnalyticsStep,
|
||||||
WebhookStep,
|
WebhookStep,
|
||||||
|
SendEmailStep,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { stringify } from 'qs'
|
import { stringify } from 'qs'
|
||||||
import { sendRequest } from 'utils'
|
import { sendRequest } from 'utils'
|
||||||
@ -25,7 +26,7 @@ export const executeIntegration = (
|
|||||||
variables: Variable[],
|
variables: Variable[],
|
||||||
indices: Indices,
|
indices: Indices,
|
||||||
updateVariableValue: (variableId: string, value: string) => void
|
updateVariableValue: (variableId: string, value: string) => void
|
||||||
) => {
|
): Promise<string | undefined> => {
|
||||||
switch (step.type) {
|
switch (step.type) {
|
||||||
case IntegrationStepType.GOOGLE_SHEETS:
|
case IntegrationStepType.GOOGLE_SHEETS:
|
||||||
return executeGoogleSheetIntegration(step, variables, updateVariableValue)
|
return executeGoogleSheetIntegration(step, variables, updateVariableValue)
|
||||||
@ -39,6 +40,8 @@ export const executeIntegration = (
|
|||||||
indices,
|
indices,
|
||||||
updateVariableValue
|
updateVariableValue
|
||||||
)
|
)
|
||||||
|
case IntegrationStepType.EMAIL:
|
||||||
|
return sendEmail(step, variables)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,10 +49,11 @@ export const executeGoogleAnalyticsIntegration = async (
|
|||||||
step: GoogleAnalyticsStep,
|
step: GoogleAnalyticsStep,
|
||||||
variables: Variable[]
|
variables: Variable[]
|
||||||
) => {
|
) => {
|
||||||
if (!step.options?.trackingId) return
|
if (!step.options?.trackingId) return step.outgoingEdgeId
|
||||||
const { default: initGoogleAnalytics } = await import('../../lib/gtag')
|
const { default: initGoogleAnalytics } = await import('../../lib/gtag')
|
||||||
await initGoogleAnalytics(step.options.trackingId)
|
await initGoogleAnalytics(step.options.trackingId)
|
||||||
sendGaEvent(parseVariablesInObject(step.options, variables))
|
sendGaEvent(parseVariablesInObject(step.options, variables))
|
||||||
|
return step.outgoingEdgeId
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeGoogleSheetIntegration = async (
|
const executeGoogleSheetIntegration = async (
|
||||||
@ -100,10 +104,7 @@ const updateRowInGoogleSheets = async (
|
|||||||
values: parseCellValues(options.cellsToUpsert, variables),
|
values: parseCellValues(options.cellsToUpsert, variables),
|
||||||
referenceCell: {
|
referenceCell: {
|
||||||
column: options.referenceCell.column,
|
column: options.referenceCell.column,
|
||||||
value: parseVariables({
|
value: parseVariables(variables)(options.referenceCell.value ?? ''),
|
||||||
text: options.referenceCell.value ?? '',
|
|
||||||
variables,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -120,10 +121,7 @@ const getRowFromGoogleSheets = async (
|
|||||||
credentialsId: options.credentialsId,
|
credentialsId: options.credentialsId,
|
||||||
referenceCell: {
|
referenceCell: {
|
||||||
column: options.referenceCell.column,
|
column: options.referenceCell.column,
|
||||||
value: parseVariables({
|
value: parseVariables(variables)(options.referenceCell.value ?? ''),
|
||||||
text: options.referenceCell.value ?? '',
|
|
||||||
variables,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
columns: options.cellsToExtract.map((cell) => cell.column),
|
columns: options.cellsToExtract.map((cell) => cell.column),
|
||||||
},
|
},
|
||||||
@ -147,7 +145,7 @@ const parseCellValues = (
|
|||||||
? row
|
? row
|
||||||
: {
|
: {
|
||||||
...row,
|
...row,
|
||||||
[cell.column]: parseVariables({ text: cell.value, variables }),
|
[cell.column]: parseVariables(variables)(cell.value),
|
||||||
}
|
}
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
@ -174,3 +172,19 @@ const executeWebhook = async (
|
|||||||
updateVariableValue(varMapping.variableId, value)
|
updateVariableValue(varMapping.variableId, value)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendEmail = async (step: SendEmailStep, variables: Variable[]) => {
|
||||||
|
const { options } = step
|
||||||
|
const { error } = await sendRequest({
|
||||||
|
url: `http://localhost:3001/api/integrations/email`,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
credentialsId: options.credentialsId,
|
||||||
|
recipients: options.recipients.map(parseVariables(variables)),
|
||||||
|
subject: parseVariables(variables)(options.subject ?? ''),
|
||||||
|
body: parseVariables(variables)(options.body ?? ''),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.error(error)
|
||||||
|
return step.outgoingEdgeId
|
||||||
|
}
|
||||||
|
@ -39,7 +39,7 @@ const executeSetVariable = (
|
|||||||
return step.outgoingEdgeId
|
return step.outgoingEdgeId
|
||||||
const expression = step.options.expressionToEvaluate
|
const expression = step.options.expressionToEvaluate
|
||||||
const evaluatedExpression = isMathFormula(expression)
|
const evaluatedExpression = isMathFormula(expression)
|
||||||
? evaluateExpression(parseVariables({ text: expression, variables }))
|
? evaluateExpression(parseVariables(variables)(expression))
|
||||||
: expression
|
: expression
|
||||||
updateVariableValue(step.options.variableId, evaluatedExpression)
|
updateVariableValue(step.options.variableId, evaluatedExpression)
|
||||||
return step.outgoingEdgeId
|
return step.outgoingEdgeId
|
||||||
@ -92,7 +92,7 @@ const executeRedirect = (
|
|||||||
): EdgeId | undefined => {
|
): EdgeId | undefined => {
|
||||||
if (!step.options?.url) return step.outgoingEdgeId
|
if (!step.options?.url) return step.outgoingEdgeId
|
||||||
window.open(
|
window.open(
|
||||||
sanitizeUrl(parseVariables({ text: step.options?.url, variables })),
|
sanitizeUrl(parseVariables(variables)(step.options?.url)),
|
||||||
step.options.isNewTab ? '_blank' : '_self'
|
step.options.isNewTab ? '_blank' : '_self'
|
||||||
)
|
)
|
||||||
return step.outgoingEdgeId
|
return step.outgoingEdgeId
|
||||||
|
@ -6,23 +6,19 @@ const safeEval = eval
|
|||||||
export const stringContainsVariable = (str: string): boolean =>
|
export const stringContainsVariable = (str: string): boolean =>
|
||||||
/\{\{(.*?)\}\}/g.test(str)
|
/\{\{(.*?)\}\}/g.test(str)
|
||||||
|
|
||||||
export const parseVariables = ({
|
export const parseVariables =
|
||||||
text,
|
(variables: Variable[]) =>
|
||||||
variables,
|
(text?: string): string => {
|
||||||
}: {
|
if (!text || text === '') return ''
|
||||||
text?: string
|
return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => {
|
||||||
variables: Variable[]
|
const matchedVarName = fullVariableString.replace(/{{|}}/g, '')
|
||||||
}): string => {
|
return (
|
||||||
if (!text || text === '') return ''
|
variables.find((v) => {
|
||||||
return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => {
|
return matchedVarName === v.name && isDefined(v.value)
|
||||||
const matchedVarName = fullVariableString.replace(/{{|}}/g, '')
|
})?.value ?? ''
|
||||||
return (
|
)
|
||||||
variables.find((v) => {
|
})
|
||||||
return matchedVarName === v.name && isDefined(v.value)
|
}
|
||||||
})?.value ?? ''
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isMathFormula = (str?: string) =>
|
export const isMathFormula = (str?: string) =>
|
||||||
['*', '/', '+', '-'].some((val) => str && str.includes(val))
|
['*', '/', '+', '-'].some((val) => str && str.includes(val))
|
||||||
@ -58,7 +54,7 @@ export const parseVariablesInObject = (
|
|||||||
...newObj,
|
...newObj,
|
||||||
[key]:
|
[key]:
|
||||||
typeof currentValue === 'string'
|
typeof currentValue === 'string'
|
||||||
? parseVariables({ text: currentValue, variables })
|
? parseVariables(variables)(currentValue)
|
||||||
: currentValue,
|
: currentValue,
|
||||||
}
|
}
|
||||||
}, {})
|
}, {})
|
||||||
|
@ -56,17 +56,14 @@ model Credentials {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
ownerId String
|
ownerId String
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
data Json
|
data String // Encrypted data
|
||||||
name String
|
name String
|
||||||
type CredentialsType
|
type String
|
||||||
|
iv String
|
||||||
|
|
||||||
@@unique([name, type, ownerId])
|
@@unique([name, type, ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CredentialsType {
|
|
||||||
GOOGLE_SHEETS
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Plan {
|
enum Plan {
|
||||||
FREE
|
FREE
|
||||||
PRO
|
PRO
|
||||||
|
38
packages/models/src/credentials.ts
Normal file
38
packages/models/src/credentials.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Credentials as CredentialsFromPrisma } from 'db'
|
||||||
|
|
||||||
|
export type Credentials = SmtpCredentials | GoogleSheetsCredentials
|
||||||
|
|
||||||
|
export type CredentialsBase = Omit<CredentialsFromPrisma, 'data' | 'type'>
|
||||||
|
|
||||||
|
export enum CredentialsType {
|
||||||
|
GOOGLE_SHEETS = 'google sheets',
|
||||||
|
SMTP = 'smtp',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SmtpCredentials = CredentialsBase & {
|
||||||
|
type: CredentialsType.SMTP
|
||||||
|
data: SmtpCredentialsData
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GoogleSheetsCredentials = CredentialsBase & {
|
||||||
|
type: CredentialsType.GOOGLE_SHEETS
|
||||||
|
data: GoogleSheetsCredentialsData
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GoogleSheetsCredentialsData = {
|
||||||
|
refresh_token?: string | null
|
||||||
|
expiry_date?: number | null
|
||||||
|
access_token?: string | null
|
||||||
|
token_type?: string | null
|
||||||
|
id_token?: string | null
|
||||||
|
scope?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SmtpCredentialsData = {
|
||||||
|
host?: string
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
isTlsEnabled?: boolean
|
||||||
|
port: number
|
||||||
|
from: { email?: string; name?: string }
|
||||||
|
}
|
@ -3,3 +3,4 @@ export * from './publicTypebot'
|
|||||||
export * from './result'
|
export * from './result'
|
||||||
export * from './answer'
|
export * from './answer'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
export * from './credentials'
|
||||||
|
@ -4,16 +4,19 @@ export type IntegrationStep =
|
|||||||
| GoogleSheetsStep
|
| GoogleSheetsStep
|
||||||
| GoogleAnalyticsStep
|
| GoogleAnalyticsStep
|
||||||
| WebhookStep
|
| WebhookStep
|
||||||
|
| SendEmailStep
|
||||||
|
|
||||||
export type IntegrationStepOptions =
|
export type IntegrationStepOptions =
|
||||||
| GoogleSheetsOptions
|
| GoogleSheetsOptions
|
||||||
| GoogleAnalyticsOptions
|
| GoogleAnalyticsOptions
|
||||||
| WebhookOptions
|
| WebhookOptions
|
||||||
|
| SendEmailOptions
|
||||||
|
|
||||||
export enum IntegrationStepType {
|
export enum IntegrationStepType {
|
||||||
GOOGLE_SHEETS = 'Google Sheets',
|
GOOGLE_SHEETS = 'Google Sheets',
|
||||||
GOOGLE_ANALYTICS = 'Google Analytics',
|
GOOGLE_ANALYTICS = 'Google Analytics',
|
||||||
WEBHOOK = 'Webhook',
|
WEBHOOK = 'Webhook',
|
||||||
|
EMAIL = 'Email',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GoogleSheetsStep = StepBase & {
|
export type GoogleSheetsStep = StepBase & {
|
||||||
@ -32,6 +35,18 @@ export type WebhookStep = StepBase & {
|
|||||||
webhook: Webhook
|
webhook: Webhook
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SendEmailStep = StepBase & {
|
||||||
|
type: IntegrationStepType.EMAIL
|
||||||
|
options: SendEmailOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendEmailOptions = {
|
||||||
|
credentialsId: string | 'default'
|
||||||
|
recipients: string[]
|
||||||
|
subject?: string
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type GoogleAnalyticsOptions = {
|
export type GoogleAnalyticsOptions = {
|
||||||
trackingId?: string
|
trackingId?: string
|
||||||
category?: string
|
category?: string
|
||||||
@ -142,3 +157,8 @@ export const defaultWebhookAttributes: Omit<Webhook, 'id'> = {
|
|||||||
headers: [],
|
headers: [],
|
||||||
queryParams: [],
|
queryParams: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const defaultSendEmailOptions: SendEmailOptions = {
|
||||||
|
credentialsId: 'default',
|
||||||
|
recipients: [],
|
||||||
|
}
|
||||||
|
@ -7,7 +7,6 @@ import {
|
|||||||
RedirectStep,
|
RedirectStep,
|
||||||
SetVariableStep,
|
SetVariableStep,
|
||||||
} from '.'
|
} from '.'
|
||||||
import { Edge } from '..'
|
|
||||||
import { BubbleStep, BubbleStepType } from './bubble'
|
import { BubbleStep, BubbleStepType } from './bubble'
|
||||||
import { InputStep, InputStepType } from './inputs'
|
import { InputStep, InputStepType } from './inputs'
|
||||||
import { IntegrationStep } from './integration'
|
import { IntegrationStep } from './integration'
|
||||||
|
@ -25,7 +25,7 @@ export default [
|
|||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
peerDepsExternal(),
|
peerDepsExternal(),
|
||||||
resolve(),
|
resolve({ preferBuiltins: true }),
|
||||||
commonjs(),
|
commonjs(),
|
||||||
typescript({ tsconfig: './tsconfig.json' }),
|
typescript({ tsconfig: './tsconfig.json' }),
|
||||||
],
|
],
|
||||||
|
36
packages/utils/src/encryption.ts
Normal file
36
packages/utils/src/encryption.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'
|
||||||
|
|
||||||
|
const algorithm = 'aes-256-gcm'
|
||||||
|
const secretKey = process.env.SECRET
|
||||||
|
|
||||||
|
export const encrypt = (
|
||||||
|
data: object
|
||||||
|
): { encryptedData: string; iv: string } => {
|
||||||
|
if (!secretKey) throw new Error(`SECRET is not in environment`)
|
||||||
|
const iv = randomBytes(16)
|
||||||
|
const cipher = createCipheriv(algorithm, secretKey, iv)
|
||||||
|
const dataString = JSON.stringify(data)
|
||||||
|
const encryptedData =
|
||||||
|
cipher.update(dataString, 'utf8', 'hex') + cipher.final('hex')
|
||||||
|
const tag = cipher.getAuthTag()
|
||||||
|
return {
|
||||||
|
encryptedData,
|
||||||
|
iv: iv.toString('hex') + '.' + tag.toString('hex'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decrypt = (encryptedData: string, auth: string): object => {
|
||||||
|
if (!secretKey) throw new Error(`SECRET is not in environment`)
|
||||||
|
const [iv, tag] = auth.split('.')
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
algorithm,
|
||||||
|
secretKey,
|
||||||
|
Buffer.from(iv, 'hex')
|
||||||
|
)
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'hex'))
|
||||||
|
return JSON.parse(
|
||||||
|
(
|
||||||
|
decipher.update(Buffer.from(encryptedData, 'hex')) + decipher.final('hex')
|
||||||
|
).toString()
|
||||||
|
)
|
||||||
|
}
|
@ -1,2 +1,3 @@
|
|||||||
export * from './utils'
|
export * from './utils'
|
||||||
export * from './apiUtils'
|
export * from './apiUtils'
|
||||||
|
export * from './encryption'
|
||||||
|
Reference in New Issue
Block a user