⚡ (sheets) Use Google Drive picker and remove sensitive OAuth scope
BREAKING CHANGE: The Google Picker API needs to be enabled in the Google Cloud console. You also need to enable it in your NEXT_PUBLIC_GOOGLE_API_KEY. You also need to add the drive.file OAuth scope.
This commit is contained in:
Binary file not shown.
Before Width: | Height: | Size: 99 KiB |
BIN
apps/builder/public/images/google-spreadsheets-scopes.png
Normal file
BIN
apps/builder/public/images/google-spreadsheets-scopes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 127 KiB |
@ -0,0 +1,60 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
|
||||
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
|
||||
import { GoogleSheetsCredentials } from '@typebot.io/schemas'
|
||||
import { OAuth2Client } from 'google-auth-library'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export const getAccessToken = authenticatedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
credentialsId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { workspaceId, credentialsId }, ctx: { user } }) => {
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
members: true,
|
||||
credentials: {
|
||||
where: {
|
||||
id: credentialsId,
|
||||
},
|
||||
select: {
|
||||
data: true,
|
||||
iv: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!workspace || isReadWorkspaceFobidden(workspace, user))
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
|
||||
|
||||
const credentials = workspace.credentials[0]
|
||||
if (!credentials)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Credentials not found',
|
||||
})
|
||||
const decryptedCredentials = (await decrypt(
|
||||
credentials.data,
|
||||
credentials.iv
|
||||
)) as GoogleSheetsCredentials['data']
|
||||
|
||||
const client = new OAuth2Client({
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
redirectUri: `${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`,
|
||||
})
|
||||
|
||||
client.setCredentials(decryptedCredentials)
|
||||
|
||||
return { accessToken: (await client.getAccessToken()).token }
|
||||
})
|
@ -0,0 +1,72 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
|
||||
import { getAuthenticatedGoogleClient } from '@typebot.io/lib/google'
|
||||
import { GoogleSpreadsheet } from 'google-spreadsheet'
|
||||
|
||||
export const getSpreadsheetName = authenticatedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
workspaceId: z.string(),
|
||||
credentialsId: z.string(),
|
||||
spreadsheetId: z.string(),
|
||||
})
|
||||
)
|
||||
.query(
|
||||
async ({
|
||||
input: { workspaceId, credentialsId, spreadsheetId },
|
||||
ctx: { user },
|
||||
}) => {
|
||||
const workspace = await prisma.workspace.findFirst({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
members: true,
|
||||
credentials: {
|
||||
where: {
|
||||
id: credentialsId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
data: true,
|
||||
iv: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!workspace || isReadWorkspaceFobidden(workspace, user))
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
|
||||
const credentials = workspace.credentials[0]
|
||||
if (!credentials)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Credentials not found',
|
||||
})
|
||||
|
||||
const client = await getAuthenticatedGoogleClient(credentials.id)
|
||||
|
||||
if (!client)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Google client could not be initialized',
|
||||
})
|
||||
|
||||
try {
|
||||
const googleSheet = new GoogleSpreadsheet(spreadsheetId, client)
|
||||
|
||||
await googleSheet.loadInfo()
|
||||
|
||||
return { name: googleSheet.title }
|
||||
} catch (e) {
|
||||
return { name: '' }
|
||||
}
|
||||
}
|
||||
)
|
@ -0,0 +1,8 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { getAccessToken } from './getAccessToken'
|
||||
import { getSpreadsheetName } from './getSpreadsheetName'
|
||||
|
||||
export const googleSheetsRouter = router({
|
||||
getAccessToken,
|
||||
getSpreadsheetName,
|
||||
})
|
@ -21,11 +21,13 @@ import { getGoogleSheetsConsentScreenUrlQuery } from '../queries/getGoogleSheets
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
typebotId: string
|
||||
blockId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const GoogleSheetConnectModal = ({
|
||||
typebotId,
|
||||
blockId,
|
||||
isOpen,
|
||||
onClose,
|
||||
@ -38,20 +40,21 @@ export const GoogleSheetConnectModal = ({
|
||||
<ModalHeader>Connect Spreadsheets</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody as={Stack} spacing="6">
|
||||
<AlertInfo>
|
||||
Typebot needs access to Google Drive in order to list all your
|
||||
spreadsheets. It also needs access to your spreadsheets in order to
|
||||
fetch or inject data in it.
|
||||
</AlertInfo>
|
||||
<Text>
|
||||
Make sure to check all the permissions so that the integration works
|
||||
as expected:
|
||||
</Text>
|
||||
<Image
|
||||
src="/images/google-spreadsheets-scopes.jpeg"
|
||||
src="/images/google-spreadsheets-scopes.png"
|
||||
alt="Google Spreadsheets checkboxes"
|
||||
rounded="md"
|
||||
/>
|
||||
<AlertInfo>
|
||||
Google does not provide more granular permissions than
|
||||
"read" or "write" access. That's why it
|
||||
states that Typebot can also delete your spreadsheets which it
|
||||
won't.
|
||||
</AlertInfo>
|
||||
<Flex>
|
||||
<Button
|
||||
as={Link}
|
||||
@ -62,7 +65,8 @@ export const GoogleSheetConnectModal = ({
|
||||
href={getGoogleSheetsConsentScreenUrlQuery(
|
||||
window.location.href,
|
||||
blockId,
|
||||
workspace?.id
|
||||
workspace?.id,
|
||||
typebotId
|
||||
)}
|
||||
mx="auto"
|
||||
>
|
||||
|
@ -22,7 +22,6 @@ import {
|
||||
import React, { useMemo } from 'react'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { SheetsDropdown } from './SheetsDropdown'
|
||||
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
|
||||
import { CellWithValueStack } from './CellWithValueStack'
|
||||
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
|
||||
import { GoogleSheetConnectModal } from './GoogleSheetsConnectModal'
|
||||
@ -37,6 +36,7 @@ import {
|
||||
defaultGoogleSheetsOptions,
|
||||
totalRowsToExtractOptions,
|
||||
} from '@typebot.io/schemas/features/blocks/integrations/googleSheets/constants'
|
||||
import { GoogleSpreadsheetPicker } from './GoogleSpreadsheetPicker'
|
||||
|
||||
type Props = {
|
||||
options: GoogleSheetsBlock['options']
|
||||
@ -50,6 +50,7 @@ export const GoogleSheetsSettings = ({
|
||||
blockId,
|
||||
}: Props) => {
|
||||
const { workspace } = useWorkspace()
|
||||
const { typebot } = useTypebot()
|
||||
const { save } = useTypebot()
|
||||
const { sheets, isLoading } = useSheets({
|
||||
credentialsId: options?.credentialsId,
|
||||
@ -95,16 +96,20 @@ export const GoogleSheetsSettings = ({
|
||||
credentialsName="Sheets account"
|
||||
/>
|
||||
)}
|
||||
{typebot && (
|
||||
<GoogleSheetConnectModal
|
||||
typebotId={typebot.id}
|
||||
blockId={blockId}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{options?.credentialsId && (
|
||||
<SpreadsheetsDropdown
|
||||
credentialsId={options.credentialsId}
|
||||
)}
|
||||
{options?.credentialsId && workspace && (
|
||||
<GoogleSpreadsheetPicker
|
||||
spreadsheetId={options.spreadsheetId}
|
||||
onSelectSpreadsheetId={handleSpreadsheetIdChange}
|
||||
workspaceId={workspace.id}
|
||||
credentialsId={options.credentialsId}
|
||||
onSpreadsheetIdSelect={handleSpreadsheetIdChange}
|
||||
/>
|
||||
)}
|
||||
{options?.spreadsheetId && options.credentialsId && (
|
||||
|
@ -0,0 +1,118 @@
|
||||
import { FileIcon } from '@/components/icons'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Button, Flex, HStack, IconButton, Text } from '@chakra-ui/react'
|
||||
import { env } from '@typebot.io/env'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { GoogleSheetsLogo } from './GoogleSheetsLogo'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare const window: any
|
||||
|
||||
type Props = {
|
||||
spreadsheetId?: string
|
||||
credentialsId: string
|
||||
workspaceId: string
|
||||
onSpreadsheetIdSelect: (spreadsheetId: string) => void
|
||||
}
|
||||
|
||||
export const GoogleSpreadsheetPicker = ({
|
||||
spreadsheetId,
|
||||
workspaceId,
|
||||
credentialsId,
|
||||
onSpreadsheetIdSelect,
|
||||
}: Props) => {
|
||||
const [isPickerInitialized, setIsPickerInitialized] = useState(false)
|
||||
|
||||
const { data } = trpc.sheets.getAccessToken.useQuery({
|
||||
workspaceId,
|
||||
credentialsId,
|
||||
})
|
||||
const { data: spreadsheetData, status } =
|
||||
trpc.sheets.getSpreadsheetName.useQuery(
|
||||
{
|
||||
workspaceId,
|
||||
credentialsId,
|
||||
spreadsheetId: spreadsheetId as string,
|
||||
},
|
||||
{ enabled: !!spreadsheetId }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
loadScript('gapi', 'https://apis.google.com/js/api.js', () => {
|
||||
window.gapi.load('picker', () => {
|
||||
setIsPickerInitialized(true)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadScript = (
|
||||
id: string,
|
||||
src: string,
|
||||
callback: { (): void; (): void; (): void }
|
||||
) => {
|
||||
const existingScript = document.getElementById(id)
|
||||
if (existingScript) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.type = 'text/javascript'
|
||||
|
||||
script.onload = function () {
|
||||
callback()
|
||||
}
|
||||
|
||||
script.src = src
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
const createPicker = () => {
|
||||
if (!data) return
|
||||
if (!isPickerInitialized) throw new Error('Google Picker not inited')
|
||||
|
||||
const picker = new window.google.picker.PickerBuilder()
|
||||
.addView(window.google.picker.ViewId.SPREADSHEETS)
|
||||
.setOAuthToken(data.accessToken)
|
||||
.setDeveloperKey(env.NEXT_PUBLIC_GOOGLE_API_KEY)
|
||||
.setCallback(pickerCallback)
|
||||
.build()
|
||||
|
||||
picker.setVisible(true)
|
||||
}
|
||||
|
||||
const pickerCallback = (data: { action: string; docs: { id: string }[] }) => {
|
||||
if (data.action !== 'picked') return
|
||||
const spreadsheetId = data.docs[0]?.id
|
||||
if (!spreadsheetId) return
|
||||
onSpreadsheetIdSelect(spreadsheetId)
|
||||
}
|
||||
|
||||
if (spreadsheetData && spreadsheetData.name !== '')
|
||||
return (
|
||||
<Flex justifyContent="space-between">
|
||||
<HStack spacing={2}>
|
||||
<GoogleSheetsLogo />
|
||||
<Text fontWeight="semibold">{spreadsheetData.name}</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={<FileIcon />}
|
||||
onClick={createPicker}
|
||||
isLoading={!isPickerInitialized}
|
||||
aria-label={'Pick another spreadsheet'}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
return (
|
||||
<Button
|
||||
onClick={createPicker}
|
||||
isLoading={
|
||||
!isPickerInitialized ||
|
||||
(isDefined(spreadsheetId) && status === 'loading')
|
||||
}
|
||||
>
|
||||
Pick a spreadsheet
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import { Select } from '@/components/inputs/Select'
|
||||
import { Input, Tooltip } from '@chakra-ui/react'
|
||||
import { useSpreadsheets } from '../hooks/useSpreadsheets'
|
||||
|
||||
type Props = {
|
||||
credentialsId: string
|
||||
spreadsheetId?: string
|
||||
onSelectSpreadsheetId: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
export const SpreadsheetsDropdown = ({
|
||||
credentialsId,
|
||||
spreadsheetId,
|
||||
onSelectSpreadsheetId,
|
||||
}: Props) => {
|
||||
const { spreadsheets, isLoading } = useSpreadsheets({
|
||||
credentialsId,
|
||||
})
|
||||
|
||||
if (isLoading) return <Input value="Loading..." isDisabled />
|
||||
if (!spreadsheets || spreadsheets.length === 0)
|
||||
return (
|
||||
<Tooltip label="No spreadsheets found, make sure you have at least one spreadsheet that contains a header row">
|
||||
<span>
|
||||
<Input value="No spreadsheets found" isDisabled />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
return (
|
||||
<Select
|
||||
selectedItem={spreadsheetId}
|
||||
items={(spreadsheets ?? []).map((spreadsheet) => ({
|
||||
label: spreadsheet.name,
|
||||
value: spreadsheet.id,
|
||||
}))}
|
||||
onSelect={onSelectSpreadsheetId}
|
||||
placeholder={'Search for spreadsheet'}
|
||||
/>
|
||||
)
|
||||
}
|
@ -152,8 +152,16 @@ const fillInSpreadsheetInfo = async (page: Page) => {
|
||||
await page.click('text=Select Sheets account')
|
||||
await page.click('text=pro-user@email.com')
|
||||
|
||||
await page.fill('input[placeholder="Search for spreadsheet"]', 'CR')
|
||||
await page.click('text=CRM')
|
||||
await page.waitForTimeout(1000)
|
||||
await page.getByRole('button', { name: 'Pick a spreadsheet' }).click()
|
||||
await page
|
||||
.frameLocator('.picker-frame')
|
||||
.getByLabel('CRM Google Sheets Not selected')
|
||||
.click()
|
||||
await page
|
||||
.frameLocator('.picker-frame')
|
||||
.getByRole('button', { name: 'Select' })
|
||||
.click()
|
||||
|
||||
await page.fill('input[placeholder="Select the sheet"]', 'Sh')
|
||||
await page.click('text=Sheet1')
|
||||
|
@ -3,8 +3,14 @@ import { stringify } from 'qs'
|
||||
export const getGoogleSheetsConsentScreenUrlQuery = (
|
||||
redirectUrl: string,
|
||||
blockId: string,
|
||||
workspaceId?: string
|
||||
workspaceId?: string,
|
||||
typebotId?: string
|
||||
) => {
|
||||
const queryParams = stringify({ redirectUrl, blockId, workspaceId })
|
||||
const queryParams = stringify({
|
||||
redirectUrl,
|
||||
blockId,
|
||||
workspaceId,
|
||||
typebotId,
|
||||
})
|
||||
return `/api/credentials/google-sheets/consent-url?${queryParams}`
|
||||
}
|
||||
|
@ -10,8 +10,7 @@ import {
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@/components/icons'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useToast } from '../../../hooks/useToast'
|
||||
import { Credentials } from '@typebot.io/schemas'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
@ -37,7 +36,6 @@ export const CredentialsDropdown = ({
|
||||
credentialsName,
|
||||
...props
|
||||
}: Props) => {
|
||||
const router = useRouter()
|
||||
const { showToast } = useToast()
|
||||
const { currentRole } = useWorkspace()
|
||||
const { data, refetch } = trpc.credentials.listCredentials.useQuery({
|
||||
@ -77,25 +75,6 @@ export const CredentialsDropdown = ({
|
||||
[onCredentialsSelect]
|
||||
)
|
||||
|
||||
const clearQueryParams = useCallback(() => {
|
||||
const hasQueryParams = router.asPath.includes('?')
|
||||
if (hasQueryParams)
|
||||
router.push(router.asPath.split('?')[0], undefined, { shallow: true })
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
if (router.query.credentialsId) {
|
||||
handleMenuItemClick(router.query.credentialsId.toString())()
|
||||
clearQueryParams()
|
||||
}
|
||||
}, [
|
||||
clearQueryParams,
|
||||
handleMenuItemClick,
|
||||
router.isReady,
|
||||
router.query.credentialsId,
|
||||
])
|
||||
|
||||
const deleteCredentials =
|
||||
(credentialsId: string) => async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { PublicTypebot, PublicTypebotV6, TypebotV6 } from '@typebot.io/schemas'
|
||||
import { Router, useRouter } from 'next/router'
|
||||
import { Router } from 'next/router'
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
@ -85,7 +85,6 @@ export const TypebotProvider = ({
|
||||
children: ReactNode
|
||||
typebotId?: string
|
||||
}) => {
|
||||
const { push } = useRouter()
|
||||
const { showToast } = useToast()
|
||||
const [is404, setIs404] = useState(false)
|
||||
|
||||
@ -186,7 +185,6 @@ export const TypebotProvider = ({
|
||||
flush,
|
||||
isFetchingTypebot,
|
||||
localTypebot,
|
||||
push,
|
||||
setLocalTypebot,
|
||||
showToast,
|
||||
typebot,
|
||||
|
@ -59,8 +59,7 @@ export const BlockNode = ({
|
||||
const bg = useColorModeValue('gray.50', 'gray.850')
|
||||
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.800')
|
||||
const { pathname } = useRouter()
|
||||
const { query } = useRouter()
|
||||
const { pathname, query } = useRouter()
|
||||
const {
|
||||
setConnectingIds,
|
||||
connectingIds,
|
||||
|
@ -6,6 +6,7 @@ import { whatsAppRouter } from '@/features/whatsapp/router'
|
||||
import { zemanticAiRouter } from '@/features/blocks/integrations/zemanticAi/api/router'
|
||||
import { forgedCredentialsRouter } from '@/features/forge/api/credentials/router'
|
||||
import { integrationsRouter } from '@/features/forge/api/router'
|
||||
import { googleSheetsRouter } from '@/features/blocks/integrations/googleSheets/api/router'
|
||||
|
||||
export const internalRouter = router({
|
||||
getAppVersionProcedure,
|
||||
@ -15,6 +16,7 @@ export const internalRouter = router({
|
||||
zemanticAI: zemanticAiRouter,
|
||||
integrationCredentials: forgedCredentialsRouter,
|
||||
integrations: integrationsRouter,
|
||||
sheets: googleSheetsRouter,
|
||||
})
|
||||
|
||||
export type InternalRouter = typeof internalRouter
|
||||
|
@ -2,19 +2,19 @@ import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { Prisma } from '@typebot.io/prisma'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { googleSheetsScopes } from './consent-url'
|
||||
import { stringify } from 'querystring'
|
||||
import { badRequest, notAuthenticated } from '@typebot.io/lib/api'
|
||||
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { encrypt } from '@typebot.io/lib/api/encryption/encrypt'
|
||||
import { OAuth2Client } from 'google-auth-library'
|
||||
import { parseGroups } from '@typebot.io/schemas'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req, res)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const state = req.query.state as string | undefined
|
||||
if (!state) return badRequest(res)
|
||||
const { redirectUrl, blockId, workspaceId } = JSON.parse(
|
||||
const { typebotId, redirectUrl, blockId, workspaceId } = JSON.parse(
|
||||
Buffer.from(state, 'base64').toString()
|
||||
)
|
||||
if (req.method === 'GET') {
|
||||
@ -55,8 +55,46 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id: credentialsId } = await prisma.credentials.create({
|
||||
data: credentials,
|
||||
})
|
||||
const queryParams = stringify({ blockId, credentialsId })
|
||||
res.redirect(`${redirectUrl}?${queryParams}` ?? `${env.NEXTAUTH_URL}`)
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: {
|
||||
id: typebotId,
|
||||
},
|
||||
select: {
|
||||
version: true,
|
||||
groups: true,
|
||||
},
|
||||
})
|
||||
if (!typebot) return res.status(404).send({ message: 'Typebot not found' })
|
||||
const groups = parseGroups(typebot.groups, {
|
||||
typebotVersion: typebot.version,
|
||||
}).map((group) => {
|
||||
const block = group.blocks.find((block) => block.id === blockId)
|
||||
if (!block) return group
|
||||
return {
|
||||
...group,
|
||||
blocks: group.blocks.map((block) => {
|
||||
if (block.id !== blockId || !('options' in block)) return block
|
||||
return {
|
||||
...block,
|
||||
options: {
|
||||
...block.options,
|
||||
credentialsId,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
await prisma.typebot.updateMany({
|
||||
where: {
|
||||
id: typebotId,
|
||||
},
|
||||
data: {
|
||||
groups,
|
||||
},
|
||||
})
|
||||
res.redirect(
|
||||
`${redirectUrl.split('?')[0]}?blockId=${blockId}` ?? `${env.NEXTAUTH_URL}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
|
||||
export const googleSheetsScopes = [
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'https://www.googleapis.com/auth/drive.readonly',
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
]
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
import { setUser } from '@sentry/nextjs'
|
||||
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
||||
|
||||
// TODO: Delete now that we use Google Drive Picker
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req, res)
|
||||
if (!user) return notAuthenticated(res)
|
||||
|
@ -41,8 +41,7 @@ Used for sending email notifications and authentication
|
||||
|
||||
## Google (Auth, Sheets, Fonts)
|
||||
|
||||
Used authentication in the builder and for the Google Sheets integration step. Make sure to set the required scopes (`userinfo.email`, `spreadsheets`, `drive.readonly`) in your console
|
||||
The Authorization callback URL should be `$NEXTAUTH_URL/api/auth/callback/google`
|
||||
Used authentication in the builder and for the Google Sheets integration step.
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| -------------------- | ------- | --------------------------------------------- |
|
||||
@ -57,7 +56,15 @@ Used for Google Fonts (Optional):
|
||||
|
||||
### Configuration
|
||||
|
||||
https://console.developers.google.com/apis/credentials
|
||||
1. Enable the APIs you want: Google Sheets API, Google Picker API (Used for the Google Sheets integration to pick a spreadsheet), Web Fonts Developer API
|
||||
|
||||
2. Head over the Credentials tab: https://console.developers.google.com/apis/credentials
|
||||
|
||||
3. Create an API key with access to the Google Picker API and Web Fonts Developer API (optionnal). This will be your `NEXT_PUBLIC_GOOGLE_API_KEY`
|
||||
|
||||
4. Create a OAuth client ID. This will be your `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
Make sure to set the required scopes (`userinfo.email`, `spreadsheets`, `drive.file`) in your console
|
||||
|
||||
The "Authorized redirect URIs" used when creating the credentials must include your full domain and end in the callback path:
|
||||
|
||||
@ -68,6 +75,8 @@ The "Authorized redirect URIs" used when creating the credentials must include y
|
||||
- http://localhost:3000/api/auth/callback/google
|
||||
- http://localhost:3000/api/credentials/google-sheets/callback
|
||||
|
||||
5. To avoid having to always reconnect a Google Sheets credentials every 7 days, you need to promote your OAuth client to production (https://developers.google.com/nest/device-access/reference/errors/authorization#refresh_token_keeps_expiring)
|
||||
|
||||
## GitHub (Auth)
|
||||
|
||||
Used for authenticating with GitHub. By default, it uses the credentials of a Typebot-dev app.
|
||||
|
@ -1,13 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { encrypt } from '@typebot.io/lib/api/encryption/encrypt'
|
||||
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
|
||||
import { isDefined } from '@typebot.io/lib/utils'
|
||||
import { Credentials as CredentialsFromDb } from '@typebot.io/prisma'
|
||||
import { GoogleSpreadsheet } from 'google-spreadsheet'
|
||||
import { OAuth2Client, Credentials } from 'google-auth-library'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { GoogleSheetsCredentials } from '@typebot.io/schemas'
|
||||
import { getAuthenticatedGoogleClient } from '@typebot.io/lib/google'
|
||||
|
||||
export const getAuthenticatedGoogleDoc = async ({
|
||||
credentialsId,
|
||||
@ -29,48 +22,3 @@ export const getAuthenticatedGoogleDoc = async ({
|
||||
})
|
||||
return new GoogleSpreadsheet(spreadsheetId, auth)
|
||||
}
|
||||
|
||||
const getAuthenticatedGoogleClient = async (
|
||||
credentialsId: string
|
||||
): Promise<OAuth2Client | undefined> => {
|
||||
const credentials = (await prisma.credentials.findFirst({
|
||||
where: { id: credentialsId },
|
||||
})) as CredentialsFromDb | undefined
|
||||
if (!credentials) return
|
||||
const data = (await decrypt(
|
||||
credentials.data,
|
||||
credentials.iv
|
||||
)) as GoogleSheetsCredentials['data']
|
||||
|
||||
const oauth2Client = new OAuth2Client(
|
||||
env.GOOGLE_CLIENT_ID,
|
||||
env.GOOGLE_CLIENT_SECRET,
|
||||
`${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`
|
||||
)
|
||||
oauth2Client.setCredentials(data)
|
||||
oauth2Client.on('tokens', updateTokens(credentialsId, data))
|
||||
return oauth2Client
|
||||
}
|
||||
|
||||
const updateTokens =
|
||||
(
|
||||
credentialsId: string,
|
||||
existingCredentials: GoogleSheetsCredentials['data']
|
||||
) =>
|
||||
async (credentials: Credentials) => {
|
||||
if (
|
||||
isDefined(existingCredentials.id_token) &&
|
||||
credentials.id_token !== existingCredentials.id_token
|
||||
)
|
||||
return
|
||||
const newCredentials: GoogleSheetsCredentials['data'] = {
|
||||
...existingCredentials,
|
||||
expiry_date: credentials.expiry_date,
|
||||
access_token: credentials.access_token,
|
||||
}
|
||||
const { encryptedData, iv } = await encrypt(newCredentials)
|
||||
await prisma.credentials.updateMany({
|
||||
where: { id: credentialsId },
|
||||
data: { data: encryptedData, iv },
|
||||
})
|
||||
}
|
||||
|
53
packages/lib/google.ts
Normal file
53
packages/lib/google.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { env } from '@typebot.io/env'
|
||||
import { Credentials as CredentialsFromDb } from '@typebot.io/prisma'
|
||||
import { GoogleSheetsCredentials } from '@typebot.io/schemas'
|
||||
import { decrypt } from './api/encryption/decrypt'
|
||||
import { encrypt } from './api/encryption/encrypt'
|
||||
import prisma from './prisma'
|
||||
import { isDefined } from './utils'
|
||||
import { OAuth2Client, Credentials } from 'google-auth-library'
|
||||
|
||||
export const getAuthenticatedGoogleClient = async (
|
||||
credentialsId: string
|
||||
): Promise<OAuth2Client | undefined> => {
|
||||
const credentials = (await prisma.credentials.findFirst({
|
||||
where: { id: credentialsId },
|
||||
})) as CredentialsFromDb | undefined
|
||||
if (!credentials) return
|
||||
const data = (await decrypt(
|
||||
credentials.data,
|
||||
credentials.iv
|
||||
)) as GoogleSheetsCredentials['data']
|
||||
|
||||
const oauth2Client = new OAuth2Client(
|
||||
env.GOOGLE_CLIENT_ID,
|
||||
env.GOOGLE_CLIENT_SECRET,
|
||||
`${env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`
|
||||
)
|
||||
oauth2Client.setCredentials(data)
|
||||
oauth2Client.on('tokens', updateTokens(credentialsId, data))
|
||||
return oauth2Client
|
||||
}
|
||||
|
||||
const updateTokens =
|
||||
(
|
||||
credentialsId: string,
|
||||
existingCredentials: GoogleSheetsCredentials['data']
|
||||
) =>
|
||||
async (credentials: Credentials) => {
|
||||
if (
|
||||
isDefined(existingCredentials.id_token) &&
|
||||
credentials.id_token !== existingCredentials.id_token
|
||||
)
|
||||
return
|
||||
const newCredentials: GoogleSheetsCredentials['data'] = {
|
||||
...existingCredentials,
|
||||
expiry_date: credentials.expiry_date,
|
||||
access_token: credentials.access_token,
|
||||
}
|
||||
const { encryptedData, iv } = await encrypt(newCredentials)
|
||||
await prisma.credentials.updateMany({
|
||||
where: { id: credentialsId },
|
||||
data: { data: encryptedData, iv },
|
||||
})
|
||||
}
|
@ -26,6 +26,7 @@
|
||||
"@sentry/nextjs": "7.77.0",
|
||||
"@trpc/server": "10.40.0",
|
||||
"@udecode/plate-common": "21.1.5",
|
||||
"google-auth-library": "8.9.0",
|
||||
"got": "12.6.0",
|
||||
"minio": "7.1.3",
|
||||
"remark-slate": "1.8.6",
|
||||
|
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -1375,6 +1375,9 @@ importers:
|
||||
'@udecode/plate-common':
|
||||
specifier: 21.1.5
|
||||
version: 21.1.5(@babel/core@7.22.9)(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.93.0)(slate-react@0.94.2)(slate@0.94.1)
|
||||
google-auth-library:
|
||||
specifier: 8.9.0
|
||||
version: 8.9.0
|
||||
got:
|
||||
specifier: 12.6.0
|
||||
version: 12.6.0
|
||||
|
Reference in New Issue
Block a user