2
0

feat(editor): Add cc & bcc + Deletable credentials

This commit is contained in:
Baptiste Arnaud
2022-02-19 10:58:56 +01:00
parent c5972ec91b
commit b89e9b1b82
12 changed files with 210 additions and 38 deletions

View File

@@ -1,5 +1,6 @@
import { import {
Button, Button,
IconButton,
Menu, Menu,
MenuButton, MenuButton,
MenuButtonProps, MenuButtonProps,
@@ -7,19 +8,22 @@ import {
MenuList, MenuList,
Stack, Stack,
Text, Text,
useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ChevronLeftIcon, PlusIcon } from 'assets/icons' import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
import React, { useEffect, useMemo } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { useUser } from 'contexts/UserContext' import { useUser } from 'contexts/UserContext'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { CredentialsType } from 'models' import { CredentialsType } from 'models'
import { deleteCredentials, useCredentials } from 'services/credentials'
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 defaultCredentialLabel?: string
refreshDropdownKey?: number
} }
export const CredentialsDropdown = ({ export const CredentialsDropdown = ({
@@ -28,10 +32,21 @@ export const CredentialsDropdown = ({
onCredentialsSelect, onCredentialsSelect,
onCreateNewClick, onCreateNewClick,
defaultCredentialLabel, defaultCredentialLabel,
refreshDropdownKey,
...props ...props
}: Props) => { }: Props) => {
const router = useRouter() const router = useRouter()
const { credentials } = useUser() const { user } = useUser()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { credentials, mutate } = useCredentials({
userId: user?.id,
onError: (error) =>
toast({ title: error.name, description: error.message }),
})
const [isDeleting, setIsDeleting] = useState<string>()
const defaultCredentialsLabel = defaultCredentialLabel ?? `Select an account` const defaultCredentialsLabel = defaultCredentialLabel ?? `Select an account`
@@ -44,10 +59,15 @@ export const CredentialsDropdown = ({
[currentCredentialsId, credentials] [currentCredentialsId, credentials]
) )
const handleMenuItemClick = (credentialId: string) => () => { const handleMenuItemClick = (credentialsId: string) => () => {
onCredentialsSelect(credentialId) onCredentialsSelect(credentialsId)
} }
useEffect(() => {
if ((refreshDropdownKey ?? 0) > 0) mutate({ credentials })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshDropdownKey])
useEffect(() => { useEffect(() => {
if (!router.isReady) return if (!router.isReady) return
if (router.query.credentialsId) { if (router.query.credentialsId) {
@@ -63,6 +83,16 @@ export const CredentialsDropdown = ({
router.push(router.asPath.split('?')[0], undefined, { shallow: true }) router.push(router.asPath.split('?')[0], undefined, { shallow: true })
} }
const handleDeleteDomainClick = (credentialsId: string) => async () => {
if (!user?.id) return
setIsDeleting(credentialsId)
const { error } = await deleteCredentials(user?.id, credentialsId)
setIsDeleting(undefined)
if (error) return toast({ title: error.name, description: error.message })
onCredentialsSelect(undefined)
mutate({ credentials: credentials.filter((c) => c.id !== credentialsId) })
}
return ( return (
<Menu isLazy placement="bottom-end" matchWidth> <Menu isLazy placement="bottom-end" matchWidth>
<MenuButton <MenuButton
@@ -91,16 +121,27 @@ export const CredentialsDropdown = ({
</MenuItem> </MenuItem>
)} )}
{credentialsList.map((credentials) => ( {credentialsList.map((credentials) => (
<MenuItem <Button
role="menuitem"
minH="40px"
key={credentials.id} key={credentials.id}
maxW="500px"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
onClick={handleMenuItemClick(credentials.id)} onClick={handleMenuItemClick(credentials.id)}
fontSize="16px"
fontWeight="normal"
rounded="none"
colorScheme="gray"
variant="ghost"
justifyContent="space-between"
> >
{credentials.name} {credentials.name}
</MenuItem> <IconButton
icon={<TrashIcon />}
aria-label="Remove credentials"
size="xs"
onClick={handleDeleteDomainClick(credentials.id)}
isLoading={isDeleting === credentials.id}
/>
</Button>
))} ))}
<MenuItem <MenuItem
maxW="500px" maxW="500px"

View File

@@ -1,5 +1,4 @@
import { Divider, Stack, Text } from '@chakra-ui/react' import { Divider, Stack, Text } from '@chakra-ui/react'
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'
@@ -24,6 +23,8 @@ import { SheetsDropdown } from './SheetsDropdown'
import { SpreadsheetsDropdown } from './SpreadsheetDropdown' import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
import { CellWithValueStack } from './CellWithValueStack' import { CellWithValueStack } from './CellWithValueStack'
import { CellWithVariableIdStack } from './CellWithVariableIdStack' import { CellWithVariableIdStack } from './CellWithVariableIdStack'
import { omit } from 'services/utils'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
type Props = { type Props = {
options: GoogleSheetsOptions options: GoogleSheetsOptions
@@ -45,8 +46,8 @@ export const GoogleSheetsSettingsBody = ({
() => sheets?.find((s) => s.id === options?.sheetId), () => sheets?.find((s) => s.id === options?.sheetId),
[sheets, options?.sheetId] [sheets, options?.sheetId]
) )
const handleCredentialsIdChange = (credentialsId: string) => const handleCredentialsIdChange = (credentialsId?: string) =>
onOptionsChange({ ...options, credentialsId }) onOptionsChange({ ...omit(options, 'credentialsId'), credentialsId })
const handleSpreadsheetIdChange = (spreadsheetId: string) => const handleSpreadsheetIdChange = (spreadsheetId: string) =>
onOptionsChange({ ...options, spreadsheetId }) onOptionsChange({ ...options, spreadsheetId })
const handleSheetIdChange = (sheetId: string) => const handleSheetIdChange = (sheetId: string) =>

View File

@@ -5,7 +5,7 @@ import {
TextareaWithVariableButton, TextareaWithVariableButton,
} from 'components/shared/TextboxWithVariableButton' } from 'components/shared/TextboxWithVariableButton'
import { CredentialsType, SendEmailOptions } from 'models' import { CredentialsType, SendEmailOptions } from 'models'
import React from 'react' import React, { useState } from 'react'
import { SmtpConfigModal } from './SmtpConfigModal' import { SmtpConfigModal } from './SmtpConfigModal'
type Props = { type Props = {
@@ -15,11 +15,15 @@ type Props = {
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => { export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const handleCredentialsSelect = (credentialsId: string) => const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({ onOptionsChange({
...options, ...options,
credentialsId, credentialsId: credentialsId === undefined ? 'default' : credentialsId,
}) })
}
const handleToChange = (recipientsStr: string) => { const handleToChange = (recipientsStr: string) => {
const recipients: string[] = recipientsStr const recipients: string[] = recipientsStr
@@ -31,6 +35,22 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
}) })
} }
const handleCcChange = (ccStr: string) => {
const cc: string[] = ccStr.split(',').map((str) => str.trim())
onOptionsChange({
...options,
cc,
})
}
const handleBccChange = (bccStr: string) => {
const bcc: string[] = bccStr.split(',').map((str) => str.trim())
onOptionsChange({
...options,
bcc,
})
}
const handleSubjectChange = (subject: string) => const handleSubjectChange = (subject: string) =>
onOptionsChange({ onOptionsChange({
...options, ...options,
@@ -55,6 +75,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
defaultCredentialLabel={ defaultCredentialLabel={
process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL
} }
refreshDropdownKey={refreshCredentialsKey}
/> />
</Stack> </Stack>
<Stack> <Stack>
@@ -65,6 +86,22 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
placeholder="email1@gmail.com, email2@gmail.com" placeholder="email1@gmail.com, email2@gmail.com"
/> />
</Stack> </Stack>
<Stack>
<Text>Cc: </Text>
<InputWithVariableButton
onChange={handleCcChange}
initialValue={options.cc?.join(', ') ?? ''}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack>
<Text>Bcc: </Text>
<InputWithVariableButton
onChange={handleBccChange}
initialValue={options.bcc?.join(', ') ?? ''}
placeholder="email1@gmail.com, email2@gmail.com"
/>
</Stack>
<Stack> <Stack>
<Text>Subject: </Text> <Text>Subject: </Text>
<InputWithVariableButton <InputWithVariableButton

View File

@@ -13,6 +13,7 @@ import { useUser } from 'contexts/UserContext'
import { CredentialsType, SmtpCredentialsData } from 'models' import { CredentialsType, SmtpCredentialsData } from 'models'
import React, { useState } from 'react' import React, { useState } from 'react'
import { createCredentials } from 'services/credentials' import { createCredentials } from 'services/credentials'
import { testSmtpConfig } from 'services/integrations'
import { isNotDefined } from 'utils' import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm' import { SmtpConfigForm } from './SmtpConfigForm'
@@ -27,7 +28,7 @@ export const SmtpConfigModal = ({
onNewCredentials, onNewCredentials,
onClose, onClose,
}: Props) => { }: Props) => {
const { user, mutateCredentials } = useUser() const { user } = useUser()
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const toast = useToast({ const toast = useToast({
position: 'top-right', position: 'top-right',
@@ -39,14 +40,24 @@ export const SmtpConfigModal = ({
}) })
const handleCreateClick = async () => { const handleCreateClick = async () => {
if (!user) return if (!user?.email) return
setIsCreating(true) setIsCreating(true)
const { error: testSmtpError } = await testSmtpConfig(
smtpConfig,
user.email
)
if (testSmtpError) {
setIsCreating(false)
return toast({
title: 'Invalid configuration',
description: "We couldn't send the test email with your configuration",
})
}
const { data, error } = await createCredentials(user.id, { const { data, error } = await createCredentials(user.id, {
data: smtpConfig, data: smtpConfig,
name: smtpConfig.from.email as string, name: smtpConfig.from.email as string,
type: CredentialsType.SMTP, type: CredentialsType.SMTP,
}) })
await mutateCredentials()
setIsCreating(false) setIsCreating(false)
if (error) return toast({ title: error.name, description: error.message }) if (error) return toast({ title: error.name, description: error.message })
if (!data?.credentials) if (!data?.credentials)

View File

@@ -12,10 +12,7 @@ import { isDefined, isNotDefined } from 'utils'
import { updateUser as updateUserInDb } from 'services/user' 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 { 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
@@ -23,12 +20,8 @@ const userContext = createContext<{
isSaving: boolean isSaving: boolean
hasUnsavedChanges: boolean hasUnsavedChanges: boolean
isOAuthProvider: boolean isOAuthProvider: boolean
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
}>({}) }>({})
@@ -41,11 +34,6 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
position: 'top-right', position: 'top-right',
status: 'error', status: 'error',
}) })
const { credentials, mutate: mutateCredentials } = useCredentials({
userId: user?.id,
onError: (error) =>
toast({ title: error.name, description: error.message }),
})
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const isOAuthProvider = useMemo( const isOAuthProvider = useMemo(
@@ -96,8 +84,6 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
isLoading: status === 'loading', isLoading: status === 'loading',
hasUnsavedChanges, hasUnsavedChanges,
isOAuthProvider, isOAuthProvider,
credentials: credentials ?? [],
mutateCredentials,
updateUser, updateUser,
saveUser, saveUser,
}} }}

View File

@@ -0,0 +1,37 @@
import { SmtpCredentialsData } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { createTransport } from 'nodemailer'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const { from, port, isTlsEnabled, username, password, host, to } =
JSON.parse(req.body) as SmtpCredentialsData & { to: string }
console.log({
host,
port,
secure: isTlsEnabled ?? undefined,
auth: {
user: username,
pass: password,
},
})
const transporter = createTransport({
host,
port,
secure: isTlsEnabled ?? undefined,
auth: {
user: username,
pass: password,
},
})
const info = await transporter.sendMail({
from: `"${from.name}" <${from.email}>`,
to,
subject: 'Your SMTP configuration is working 🤩',
text: 'This email has been sent to test out your SMTP config.\n\nIf your read this then it has been successful.🚀',
})
res.status(200).send({ message: 'Email sent!', info })
}
}
export default handler

View File

@@ -0,0 +1,27 @@
import { withSentry } from '@sentry/nextjs'
import { User } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (!session?.user)
return res.status(401).json({ message: 'Not authenticated' })
const user = session.user as User
const id = req.query.id.toString()
if (user.id !== id) return res.status(401).send({ message: 'Forbidden' })
if (req.method === 'DELETE') {
const credentialsId = req.query.credentialsId.toString()
const credentials = await prisma.credentials.delete({
where: { id: credentialsId },
})
return res.send({ credentials })
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -16,7 +16,7 @@ export const useCredentials = ({
) )
if (error) onError(error) if (error) onError(error)
return { return {
credentials: data?.credentials, credentials: data?.credentials ?? [],
isLoading: !error && !data, isLoading: !error && !data,
mutate, mutate,
} }
@@ -33,3 +33,14 @@ export const createCredentials = async (
method: 'POST', method: 'POST',
body: credentials, body: credentials,
}) })
export const deleteCredentials = async (
userId: string,
credentialsId: string
) =>
sendRequest<{
credentials: Credentials
}>({
url: `/api/users/${userId}/credentials/${credentialsId}`,
method: 'DELETE',
})

View File

@@ -2,7 +2,12 @@ import { sendRequest } from 'utils'
import { stringify } from 'qs' import { stringify } from 'qs'
import useSWR from 'swr' import useSWR from 'swr'
import { fetcher } from './utils' import { fetcher } from './utils'
import { Variable, VariableForTest, WebhookResponse } from 'models' import {
SmtpCredentialsData,
Variable,
VariableForTest,
WebhookResponse,
} from 'models'
export const getGoogleSheetsConsentScreenUrl = ( export const getGoogleSheetsConsentScreenUrl = (
redirectUrl: string, redirectUrl: string,
@@ -123,3 +128,13 @@ export const getDeepKeys = (obj: any): string[] => {
} }
return keys return keys
} }
export const testSmtpConfig = (smtpData: SmtpCredentialsData, to: string) =>
sendRequest({
method: 'POST',
url: '/api/integrations/email/test-config',
body: {
...smtpData,
to,
},
})

View File

@@ -27,7 +27,7 @@ const defaultFrom = {
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res) await cors(req, res)
if (req.method === 'POST') { if (req.method === 'POST') {
const { credentialsId, recipients, body, subject } = JSON.parse( const { credentialsId, recipients, body, subject, cc, bcc } = JSON.parse(
req.body req.body
) as SendEmailOptions ) as SendEmailOptions
@@ -47,6 +47,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}) })
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: `"${from.name}" <${from.email}>`, from: `"${from.name}" <${from.email}>`,
cc: cc?.join(''),
bcc: bcc?.join(''),
to: recipients.join(', '), to: recipients.join(', '),
subject, subject,
text: body, text: body,

View File

@@ -192,6 +192,8 @@ const sendEmail = async (
recipients: options.recipients.map(parseVariables(variables)), recipients: options.recipients.map(parseVariables(variables)),
subject: parseVariables(variables)(options.subject ?? ''), subject: parseVariables(variables)(options.subject ?? ''),
body: parseVariables(variables)(options.body ?? ''), body: parseVariables(variables)(options.body ?? ''),
cc: (options.cc ?? []).map(parseVariables(variables)),
bcc: (options.bcc ?? []).map(parseVariables(variables)),
}, },
}) })
console.error(error) console.error(error)

View File

@@ -43,6 +43,8 @@ export type SendEmailStep = StepBase & {
export type SendEmailOptions = { export type SendEmailOptions = {
credentialsId: string | 'default' credentialsId: string | 'default'
recipients: string[] recipients: string[]
cc?: string[]
bcc?: string[]
subject?: string subject?: string
body?: string body?: string
} }