feat(editor): ✨ Add cc & bcc + Deletable credentials
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuButtonProps,
|
||||
@ -7,19 +8,22 @@ import {
|
||||
MenuList,
|
||||
Stack,
|
||||
Text,
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon, PlusIcon } from 'assets/icons'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import { CredentialsType } from 'models'
|
||||
import { deleteCredentials, useCredentials } from 'services/credentials'
|
||||
|
||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||
type: CredentialsType
|
||||
currentCredentialsId?: string
|
||||
onCredentialsSelect: (credentialId: string) => void
|
||||
onCredentialsSelect: (credentialId?: string) => void
|
||||
onCreateNewClick: () => void
|
||||
defaultCredentialLabel?: string
|
||||
refreshDropdownKey?: number
|
||||
}
|
||||
|
||||
export const CredentialsDropdown = ({
|
||||
@ -28,10 +32,21 @@ export const CredentialsDropdown = ({
|
||||
onCredentialsSelect,
|
||||
onCreateNewClick,
|
||||
defaultCredentialLabel,
|
||||
refreshDropdownKey,
|
||||
...props
|
||||
}: Props) => {
|
||||
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`
|
||||
|
||||
@ -44,10 +59,15 @@ export const CredentialsDropdown = ({
|
||||
[currentCredentialsId, credentials]
|
||||
)
|
||||
|
||||
const handleMenuItemClick = (credentialId: string) => () => {
|
||||
onCredentialsSelect(credentialId)
|
||||
const handleMenuItemClick = (credentialsId: string) => () => {
|
||||
onCredentialsSelect(credentialsId)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if ((refreshDropdownKey ?? 0) > 0) mutate({ credentials })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshDropdownKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
if (router.query.credentialsId) {
|
||||
@ -63,6 +83,16 @@ export const CredentialsDropdown = ({
|
||||
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 (
|
||||
<Menu isLazy placement="bottom-end" matchWidth>
|
||||
<MenuButton
|
||||
@ -91,16 +121,27 @@ export const CredentialsDropdown = ({
|
||||
</MenuItem>
|
||||
)}
|
||||
{credentialsList.map((credentials) => (
|
||||
<MenuItem
|
||||
<Button
|
||||
role="menuitem"
|
||||
minH="40px"
|
||||
key={credentials.id}
|
||||
maxW="500px"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
onClick={handleMenuItemClick(credentials.id)}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
variant="ghost"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{credentials.name}
|
||||
</MenuItem>
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove credentials"
|
||||
size="xs"
|
||||
onClick={handleDeleteDomainClick(credentials.id)}
|
||||
isLoading={isDeleting === credentials.id}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
<MenuItem
|
||||
maxW="500px"
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Divider, Stack, Text } from '@chakra-ui/react'
|
||||
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
|
||||
import { DropdownList } from 'components/shared/DropdownList'
|
||||
import { TableList, TableListItemProps } from 'components/shared/TableList'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
@ -24,6 +23,8 @@ import { SheetsDropdown } from './SheetsDropdown'
|
||||
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
|
||||
import { CellWithValueStack } from './CellWithValueStack'
|
||||
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
|
||||
import { omit } from 'services/utils'
|
||||
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
|
||||
|
||||
type Props = {
|
||||
options: GoogleSheetsOptions
|
||||
@ -45,8 +46,8 @@ export const GoogleSheetsSettingsBody = ({
|
||||
() => sheets?.find((s) => s.id === options?.sheetId),
|
||||
[sheets, options?.sheetId]
|
||||
)
|
||||
const handleCredentialsIdChange = (credentialsId: string) =>
|
||||
onOptionsChange({ ...options, credentialsId })
|
||||
const handleCredentialsIdChange = (credentialsId?: string) =>
|
||||
onOptionsChange({ ...omit(options, 'credentialsId'), credentialsId })
|
||||
const handleSpreadsheetIdChange = (spreadsheetId: string) =>
|
||||
onOptionsChange({ ...options, spreadsheetId })
|
||||
const handleSheetIdChange = (sheetId: string) =>
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
TextareaWithVariableButton,
|
||||
} from 'components/shared/TextboxWithVariableButton'
|
||||
import { CredentialsType, SendEmailOptions } from 'models'
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { SmtpConfigModal } from './SmtpConfigModal'
|
||||
|
||||
type Props = {
|
||||
@ -15,11 +15,15 @@ type Props = {
|
||||
|
||||
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const handleCredentialsSelect = (credentialsId: string) =>
|
||||
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
|
||||
|
||||
const handleCredentialsSelect = (credentialsId?: string) => {
|
||||
setRefreshCredentialsKey(refreshCredentialsKey + 1)
|
||||
onOptionsChange({
|
||||
...options,
|
||||
credentialsId,
|
||||
credentialsId: credentialsId === undefined ? 'default' : credentialsId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleToChange = (recipientsStr: string) => {
|
||||
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) =>
|
||||
onOptionsChange({
|
||||
...options,
|
||||
@ -55,6 +75,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
||||
defaultCredentialLabel={
|
||||
process.env.NEXT_PUBLIC_EMAIL_NOTIFICATIONS_FROM_EMAIL
|
||||
}
|
||||
refreshDropdownKey={refreshCredentialsKey}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
@ -65,6 +86,22 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
||||
placeholder="email1@gmail.com, email2@gmail.com"
|
||||
/>
|
||||
</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>
|
||||
<Text>Subject: </Text>
|
||||
<InputWithVariableButton
|
||||
|
@ -13,6 +13,7 @@ import { useUser } from 'contexts/UserContext'
|
||||
import { CredentialsType, SmtpCredentialsData } from 'models'
|
||||
import React, { useState } from 'react'
|
||||
import { createCredentials } from 'services/credentials'
|
||||
import { testSmtpConfig } from 'services/integrations'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { SmtpConfigForm } from './SmtpConfigForm'
|
||||
|
||||
@ -27,7 +28,7 @@ export const SmtpConfigModal = ({
|
||||
onNewCredentials,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const { user, mutateCredentials } = useUser()
|
||||
const { user } = useUser()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@ -39,14 +40,24 @@ export const SmtpConfigModal = ({
|
||||
})
|
||||
|
||||
const handleCreateClick = async () => {
|
||||
if (!user) return
|
||||
if (!user?.email) return
|
||||
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, {
|
||||
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)
|
||||
|
@ -12,10 +12,7 @@ import { isDefined, isNotDefined } from 'utils'
|
||||
import { updateUser as updateUserInDb } from 'services/user'
|
||||
import { useToast } from '@chakra-ui/react'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { useCredentials } from 'services/credentials'
|
||||
import { User } from 'db'
|
||||
import { KeyedMutator } from 'swr'
|
||||
import { Credentials } from 'models'
|
||||
|
||||
const userContext = createContext<{
|
||||
user?: User
|
||||
@ -23,12 +20,8 @@ const userContext = createContext<{
|
||||
isSaving: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
isOAuthProvider: boolean
|
||||
credentials: Credentials[]
|
||||
updateUser: (newUser: Partial<User>) => void
|
||||
saveUser: () => void
|
||||
mutateCredentials: KeyedMutator<{
|
||||
credentials: Credentials[]
|
||||
}>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
@ -41,11 +34,6 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
position: 'top-right',
|
||||
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 isOAuthProvider = useMemo(
|
||||
@ -96,8 +84,6 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
isLoading: status === 'loading',
|
||||
hasUnsavedChanges,
|
||||
isOAuthProvider,
|
||||
credentials: credentials ?? [],
|
||||
mutateCredentials,
|
||||
updateUser,
|
||||
saveUser,
|
||||
}}
|
||||
|
37
apps/builder/pages/api/integrations/email/test-config.ts
Normal file
37
apps/builder/pages/api/integrations/email/test-config.ts
Normal 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
|
@ -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)
|
@ -16,7 +16,7 @@ export const useCredentials = ({
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
credentials: data?.credentials,
|
||||
credentials: data?.credentials ?? [],
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
@ -33,3 +33,14 @@ export const createCredentials = async (
|
||||
method: 'POST',
|
||||
body: credentials,
|
||||
})
|
||||
|
||||
export const deleteCredentials = async (
|
||||
userId: string,
|
||||
credentialsId: string
|
||||
) =>
|
||||
sendRequest<{
|
||||
credentials: Credentials
|
||||
}>({
|
||||
url: `/api/users/${userId}/credentials/${credentialsId}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
@ -2,7 +2,12 @@ import { sendRequest } from 'utils'
|
||||
import { stringify } from 'qs'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from './utils'
|
||||
import { Variable, VariableForTest, WebhookResponse } from 'models'
|
||||
import {
|
||||
SmtpCredentialsData,
|
||||
Variable,
|
||||
VariableForTest,
|
||||
WebhookResponse,
|
||||
} from 'models'
|
||||
|
||||
export const getGoogleSheetsConsentScreenUrl = (
|
||||
redirectUrl: string,
|
||||
@ -123,3 +128,13 @@ export const getDeepKeys = (obj: any): string[] => {
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
export const testSmtpConfig = (smtpData: SmtpCredentialsData, to: string) =>
|
||||
sendRequest({
|
||||
method: 'POST',
|
||||
url: '/api/integrations/email/test-config',
|
||||
body: {
|
||||
...smtpData,
|
||||
to,
|
||||
},
|
||||
})
|
||||
|
@ -27,7 +27,7 @@ const defaultFrom = {
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await cors(req, res)
|
||||
if (req.method === 'POST') {
|
||||
const { credentialsId, recipients, body, subject } = JSON.parse(
|
||||
const { credentialsId, recipients, body, subject, cc, bcc } = JSON.parse(
|
||||
req.body
|
||||
) as SendEmailOptions
|
||||
|
||||
@ -47,6 +47,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
})
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${from.name}" <${from.email}>`,
|
||||
cc: cc?.join(''),
|
||||
bcc: bcc?.join(''),
|
||||
to: recipients.join(', '),
|
||||
subject,
|
||||
text: body,
|
||||
|
Reference in New Issue
Block a user