⚡ (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 = {
|
type Props = {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
|
typebotId: string
|
||||||
blockId: string
|
blockId: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GoogleSheetConnectModal = ({
|
export const GoogleSheetConnectModal = ({
|
||||||
|
typebotId,
|
||||||
blockId,
|
blockId,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -38,20 +40,21 @@ export const GoogleSheetConnectModal = ({
|
|||||||
<ModalHeader>Connect Spreadsheets</ModalHeader>
|
<ModalHeader>Connect Spreadsheets</ModalHeader>
|
||||||
<ModalCloseButton />
|
<ModalCloseButton />
|
||||||
<ModalBody as={Stack} spacing="6">
|
<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>
|
<Text>
|
||||||
Make sure to check all the permissions so that the integration works
|
Make sure to check all the permissions so that the integration works
|
||||||
as expected:
|
as expected:
|
||||||
</Text>
|
</Text>
|
||||||
<Image
|
<Image
|
||||||
src="/images/google-spreadsheets-scopes.jpeg"
|
src="/images/google-spreadsheets-scopes.png"
|
||||||
alt="Google Spreadsheets checkboxes"
|
alt="Google Spreadsheets checkboxes"
|
||||||
rounded="md"
|
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>
|
<Flex>
|
||||||
<Button
|
<Button
|
||||||
as={Link}
|
as={Link}
|
||||||
@@ -62,7 +65,8 @@ export const GoogleSheetConnectModal = ({
|
|||||||
href={getGoogleSheetsConsentScreenUrlQuery(
|
href={getGoogleSheetsConsentScreenUrlQuery(
|
||||||
window.location.href,
|
window.location.href,
|
||||||
blockId,
|
blockId,
|
||||||
workspace?.id
|
workspace?.id,
|
||||||
|
typebotId
|
||||||
)}
|
)}
|
||||||
mx="auto"
|
mx="auto"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { isDefined } from '@typebot.io/lib'
|
import { isDefined } from '@typebot.io/lib'
|
||||||
import { SheetsDropdown } from './SheetsDropdown'
|
import { SheetsDropdown } from './SheetsDropdown'
|
||||||
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
|
|
||||||
import { CellWithValueStack } from './CellWithValueStack'
|
import { CellWithValueStack } from './CellWithValueStack'
|
||||||
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
|
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
|
||||||
import { GoogleSheetConnectModal } from './GoogleSheetsConnectModal'
|
import { GoogleSheetConnectModal } from './GoogleSheetsConnectModal'
|
||||||
@@ -37,6 +36,7 @@ import {
|
|||||||
defaultGoogleSheetsOptions,
|
defaultGoogleSheetsOptions,
|
||||||
totalRowsToExtractOptions,
|
totalRowsToExtractOptions,
|
||||||
} from '@typebot.io/schemas/features/blocks/integrations/googleSheets/constants'
|
} from '@typebot.io/schemas/features/blocks/integrations/googleSheets/constants'
|
||||||
|
import { GoogleSpreadsheetPicker } from './GoogleSpreadsheetPicker'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
options: GoogleSheetsBlock['options']
|
options: GoogleSheetsBlock['options']
|
||||||
@@ -50,6 +50,7 @@ export const GoogleSheetsSettings = ({
|
|||||||
blockId,
|
blockId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { workspace } = useWorkspace()
|
const { workspace } = useWorkspace()
|
||||||
|
const { typebot } = useTypebot()
|
||||||
const { save } = useTypebot()
|
const { save } = useTypebot()
|
||||||
const { sheets, isLoading } = useSheets({
|
const { sheets, isLoading } = useSheets({
|
||||||
credentialsId: options?.credentialsId,
|
credentialsId: options?.credentialsId,
|
||||||
@@ -95,16 +96,20 @@ export const GoogleSheetsSettings = ({
|
|||||||
credentialsName="Sheets account"
|
credentialsName="Sheets account"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<GoogleSheetConnectModal
|
{typebot && (
|
||||||
blockId={blockId}
|
<GoogleSheetConnectModal
|
||||||
isOpen={isOpen}
|
typebotId={typebot.id}
|
||||||
onClose={onClose}
|
blockId={blockId}
|
||||||
/>
|
isOpen={isOpen}
|
||||||
{options?.credentialsId && (
|
onClose={onClose}
|
||||||
<SpreadsheetsDropdown
|
/>
|
||||||
credentialsId={options.credentialsId}
|
)}
|
||||||
|
{options?.credentialsId && workspace && (
|
||||||
|
<GoogleSpreadsheetPicker
|
||||||
spreadsheetId={options.spreadsheetId}
|
spreadsheetId={options.spreadsheetId}
|
||||||
onSelectSpreadsheetId={handleSpreadsheetIdChange}
|
workspaceId={workspace.id}
|
||||||
|
credentialsId={options.credentialsId}
|
||||||
|
onSpreadsheetIdSelect={handleSpreadsheetIdChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{options?.spreadsheetId && options.credentialsId && (
|
{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=Select Sheets account')
|
||||||
await page.click('text=pro-user@email.com')
|
await page.click('text=pro-user@email.com')
|
||||||
|
|
||||||
await page.fill('input[placeholder="Search for spreadsheet"]', 'CR')
|
await page.waitForTimeout(1000)
|
||||||
await page.click('text=CRM')
|
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.fill('input[placeholder="Select the sheet"]', 'Sh')
|
||||||
await page.click('text=Sheet1')
|
await page.click('text=Sheet1')
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ import { stringify } from 'qs'
|
|||||||
export const getGoogleSheetsConsentScreenUrlQuery = (
|
export const getGoogleSheetsConsentScreenUrlQuery = (
|
||||||
redirectUrl: string,
|
redirectUrl: string,
|
||||||
blockId: 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}`
|
return `/api/credentials/google-sheets/consent-url?${queryParams}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@/components/icons'
|
import { ChevronLeftIcon, PlusIcon, TrashIcon } from '@/components/icons'
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { useToast } from '../../../hooks/useToast'
|
import { useToast } from '../../../hooks/useToast'
|
||||||
import { Credentials } from '@typebot.io/schemas'
|
import { Credentials } from '@typebot.io/schemas'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { trpc } from '@/lib/trpc'
|
||||||
@@ -37,7 +36,6 @@ export const CredentialsDropdown = ({
|
|||||||
credentialsName,
|
credentialsName,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const router = useRouter()
|
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const { currentRole } = useWorkspace()
|
const { currentRole } = useWorkspace()
|
||||||
const { data, refetch } = trpc.credentials.listCredentials.useQuery({
|
const { data, refetch } = trpc.credentials.listCredentials.useQuery({
|
||||||
@@ -77,25 +75,6 @@ export const CredentialsDropdown = ({
|
|||||||
[onCredentialsSelect]
|
[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 =
|
const deleteCredentials =
|
||||||
(credentialsId: string) => async (e: React.MouseEvent) => {
|
(credentialsId: string) => async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PublicTypebot, PublicTypebotV6, TypebotV6 } from '@typebot.io/schemas'
|
import { PublicTypebot, PublicTypebotV6, TypebotV6 } from '@typebot.io/schemas'
|
||||||
import { Router, useRouter } from 'next/router'
|
import { Router } from 'next/router'
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
@@ -85,7 +85,6 @@ export const TypebotProvider = ({
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
typebotId?: string
|
typebotId?: string
|
||||||
}) => {
|
}) => {
|
||||||
const { push } = useRouter()
|
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [is404, setIs404] = useState(false)
|
const [is404, setIs404] = useState(false)
|
||||||
|
|
||||||
@@ -186,7 +185,6 @@ export const TypebotProvider = ({
|
|||||||
flush,
|
flush,
|
||||||
isFetchingTypebot,
|
isFetchingTypebot,
|
||||||
localTypebot,
|
localTypebot,
|
||||||
push,
|
|
||||||
setLocalTypebot,
|
setLocalTypebot,
|
||||||
showToast,
|
showToast,
|
||||||
typebot,
|
typebot,
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ export const BlockNode = ({
|
|||||||
const bg = useColorModeValue('gray.50', 'gray.850')
|
const bg = useColorModeValue('gray.50', 'gray.850')
|
||||||
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.800')
|
const borderColor = useColorModeValue('gray.200', 'gray.800')
|
||||||
const { pathname } = useRouter()
|
const { pathname, query } = useRouter()
|
||||||
const { query } = useRouter()
|
|
||||||
const {
|
const {
|
||||||
setConnectingIds,
|
setConnectingIds,
|
||||||
connectingIds,
|
connectingIds,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { whatsAppRouter } from '@/features/whatsapp/router'
|
|||||||
import { zemanticAiRouter } from '@/features/blocks/integrations/zemanticAi/api/router'
|
import { zemanticAiRouter } from '@/features/blocks/integrations/zemanticAi/api/router'
|
||||||
import { forgedCredentialsRouter } from '@/features/forge/api/credentials/router'
|
import { forgedCredentialsRouter } from '@/features/forge/api/credentials/router'
|
||||||
import { integrationsRouter } from '@/features/forge/api/router'
|
import { integrationsRouter } from '@/features/forge/api/router'
|
||||||
|
import { googleSheetsRouter } from '@/features/blocks/integrations/googleSheets/api/router'
|
||||||
|
|
||||||
export const internalRouter = router({
|
export const internalRouter = router({
|
||||||
getAppVersionProcedure,
|
getAppVersionProcedure,
|
||||||
@@ -15,6 +16,7 @@ export const internalRouter = router({
|
|||||||
zemanticAI: zemanticAiRouter,
|
zemanticAI: zemanticAiRouter,
|
||||||
integrationCredentials: forgedCredentialsRouter,
|
integrationCredentials: forgedCredentialsRouter,
|
||||||
integrations: integrationsRouter,
|
integrations: integrationsRouter,
|
||||||
|
sheets: googleSheetsRouter,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type InternalRouter = typeof internalRouter
|
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/prisma'
|
||||||
import prisma from '@typebot.io/lib/prisma'
|
import prisma from '@typebot.io/lib/prisma'
|
||||||
import { googleSheetsScopes } from './consent-url'
|
import { googleSheetsScopes } from './consent-url'
|
||||||
import { stringify } from 'querystring'
|
|
||||||
import { badRequest, notAuthenticated } from '@typebot.io/lib/api'
|
import { badRequest, notAuthenticated } from '@typebot.io/lib/api'
|
||||||
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
||||||
import { env } from '@typebot.io/env'
|
import { env } from '@typebot.io/env'
|
||||||
import { encrypt } from '@typebot.io/lib/api/encryption/encrypt'
|
import { encrypt } from '@typebot.io/lib/api/encryption/encrypt'
|
||||||
import { OAuth2Client } from 'google-auth-library'
|
import { OAuth2Client } from 'google-auth-library'
|
||||||
|
import { parseGroups } from '@typebot.io/schemas'
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await getAuthenticatedUser(req, res)
|
const user = await getAuthenticatedUser(req, res)
|
||||||
if (!user) return notAuthenticated(res)
|
if (!user) return notAuthenticated(res)
|
||||||
const state = req.query.state as string | undefined
|
const state = req.query.state as string | undefined
|
||||||
if (!state) return badRequest(res)
|
if (!state) return badRequest(res)
|
||||||
const { redirectUrl, blockId, workspaceId } = JSON.parse(
|
const { typebotId, redirectUrl, blockId, workspaceId } = JSON.parse(
|
||||||
Buffer.from(state, 'base64').toString()
|
Buffer.from(state, 'base64').toString()
|
||||||
)
|
)
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
@@ -55,8 +55,46 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
const { id: credentialsId } = await prisma.credentials.create({
|
const { id: credentialsId } = await prisma.credentials.create({
|
||||||
data: credentials,
|
data: credentials,
|
||||||
})
|
})
|
||||||
const queryParams = stringify({ blockId, credentialsId })
|
const typebot = await prisma.typebot.findFirst({
|
||||||
res.redirect(`${redirectUrl}?${queryParams}` ?? `${env.NEXTAUTH_URL}`)
|
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 = [
|
export const googleSheetsScopes = [
|
||||||
'https://www.googleapis.com/auth/userinfo.email',
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
'https://www.googleapis.com/auth/spreadsheets',
|
'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) => {
|
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { setUser } from '@sentry/nextjs'
|
import { setUser } from '@sentry/nextjs'
|
||||||
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
||||||
|
|
||||||
|
// TODO: Delete now that we use Google Drive Picker
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await getAuthenticatedUser(req, res)
|
const user = await getAuthenticatedUser(req, res)
|
||||||
if (!user) return notAuthenticated(res)
|
if (!user) return notAuthenticated(res)
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ Used for sending email notifications and authentication
|
|||||||
|
|
||||||
## Google (Auth, Sheets, Fonts)
|
## 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
|
Used authentication in the builder and for the Google Sheets integration step.
|
||||||
The Authorization callback URL should be `$NEXTAUTH_URL/api/auth/callback/google`
|
|
||||||
|
|
||||||
| Parameter | Default | Description |
|
| Parameter | Default | Description |
|
||||||
| -------------------- | ------- | --------------------------------------------- |
|
| -------------------- | ------- | --------------------------------------------- |
|
||||||
@@ -57,16 +56,26 @@ Used for Google Fonts (Optional):
|
|||||||
|
|
||||||
### Configuration
|
### 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
|
||||||
|
|
||||||
The "Authorized redirect URIs" used when creating the credentials must include your full domain and end in the callback path:
|
2. Head over the Credentials tab: https://console.developers.google.com/apis/credentials
|
||||||
|
|
||||||
- For production:
|
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`
|
||||||
- https://{YOUR_DOMAIN}/api/auth/callback/google
|
|
||||||
- https://{YOUR_DOMAIN}/api/credentials/google-sheets/callback
|
4. Create a OAuth client ID. This will be your `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
|
||||||
- For development:
|
|
||||||
- http://localhost:3000/api/auth/callback/google
|
Make sure to set the required scopes (`userinfo.email`, `spreadsheets`, `drive.file`) in your console
|
||||||
- http://localhost:3000/api/credentials/google-sheets/callback
|
|
||||||
|
The "Authorized redirect URIs" used when creating the credentials must include your full domain and end in the callback path:
|
||||||
|
|
||||||
|
- For production:
|
||||||
|
- https://{YOUR_DOMAIN}/api/auth/callback/google
|
||||||
|
- https://{YOUR_DOMAIN}/api/credentials/google-sheets/callback
|
||||||
|
- For development:
|
||||||
|
- 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)
|
## GitHub (Auth)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { TRPCError } from '@trpc/server'
|
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 { GoogleSpreadsheet } from 'google-spreadsheet'
|
||||||
import { OAuth2Client, Credentials } from 'google-auth-library'
|
import { getAuthenticatedGoogleClient } from '@typebot.io/lib/google'
|
||||||
import prisma from '@typebot.io/lib/prisma'
|
|
||||||
import { GoogleSheetsCredentials } from '@typebot.io/schemas'
|
|
||||||
|
|
||||||
export const getAuthenticatedGoogleDoc = async ({
|
export const getAuthenticatedGoogleDoc = async ({
|
||||||
credentialsId,
|
credentialsId,
|
||||||
@@ -29,48 +22,3 @@ export const getAuthenticatedGoogleDoc = async ({
|
|||||||
})
|
})
|
||||||
return new GoogleSpreadsheet(spreadsheetId, auth)
|
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",
|
"@sentry/nextjs": "7.77.0",
|
||||||
"@trpc/server": "10.40.0",
|
"@trpc/server": "10.40.0",
|
||||||
"@udecode/plate-common": "21.1.5",
|
"@udecode/plate-common": "21.1.5",
|
||||||
|
"google-auth-library": "8.9.0",
|
||||||
"got": "12.6.0",
|
"got": "12.6.0",
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
"remark-slate": "1.8.6",
|
"remark-slate": "1.8.6",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1375,6 +1375,9 @@ importers:
|
|||||||
'@udecode/plate-common':
|
'@udecode/plate-common':
|
||||||
specifier: 21.1.5
|
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)
|
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:
|
got:
|
||||||
specifier: 12.6.0
|
specifier: 12.6.0
|
||||||
version: 12.6.0
|
version: 12.6.0
|
||||||
|
|||||||
Reference in New Issue
Block a user