Skip to content

Commit

Permalink
⚡ (sheets) Use Google Drive picker and remove sensitive OAuth scope
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
baptisteArno committed Dec 18, 2023
1 parent 2dec0b8 commit deab1a1
Show file tree
Hide file tree
Showing 23 changed files with 428 additions and 156 deletions.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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 }
})
Original file line number Diff line number Diff line change
@@ -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: '' }
}
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { router } from '@/helpers/server/trpc'
import { getAccessToken } from './getAccessToken'
import { getSpreadsheetName } from './getSpreadsheetName'

export const googleSheetsRouter = router({
getAccessToken,
getSpreadsheetName,
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
&quot;read&quot; or &quot;write&quot; access. That&apos;s why it
states that Typebot can also delete your spreadsheets which it
won&apos;t.
</AlertInfo>
<Flex>
<Button
as={Link}
Expand All @@ -62,7 +65,8 @@ export const GoogleSheetConnectModal = ({
href={getGoogleSheetsConsentScreenUrlQuery(
window.location.href,
blockId,
workspace?.id
workspace?.id,
typebotId
)}
mx="auto"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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']
Expand All @@ -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,
Expand Down Expand Up @@ -95,16 +96,20 @@ export const GoogleSheetsSettings = ({
credentialsName="Sheets account"
/>
)}
<GoogleSheetConnectModal
blockId={blockId}
isOpen={isOpen}
onClose={onClose}
/>
{options?.credentialsId && (
<SpreadsheetsDropdown
credentialsId={options.credentialsId}
{typebot && (
<GoogleSheetConnectModal
typebotId={typebot.id}
blockId={blockId}
isOpen={isOpen}
onClose={onClose}
/>
)}
{options?.credentialsId && workspace && (
<GoogleSpreadsheetPicker
spreadsheetId={options.spreadsheetId}
onSelectSpreadsheetId={handleSpreadsheetIdChange}
workspaceId={workspace.id}
credentialsId={options.credentialsId}
onSpreadsheetIdSelect={handleSpreadsheetIdChange}
/>
)}
{options?.spreadsheetId && options.credentialsId && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
)
}
Loading

4 comments on commit deab1a1

@vercel
Copy link

@vercel vercel bot commented on deab1a1 Dec 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

docs – ./apps/docs

docs-typebot-io.vercel.app
docs-git-main-typebot-io.vercel.app
docs.typebot.io

@vercel
Copy link

@vercel vercel bot commented on deab1a1 Dec 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

viewer-v2 – ./apps/viewer

saquegov.site
shop.mexwa.my
signup.cr8.ai
start.taxt.co
thegymgame.it
theusm.com.br
turkey.cr8.ai
vhpage.cr8.ai
vitamyway.com
webwhats.chat
whatchat.site
www.wiccom.it
acessovip.shop
adsgrow.com.br
am.nigerias.io
an.nigerias.io
app.yvon.earth
ar.nigerias.io
bot.enreso.org
bot.mail2wa.me
bot.rslabs.pro
bot.share5.net
bot.sussmeg.hu
bot.wachap.app
bots.bng.tools
bots.bridge.ai
chad.gocto.com
chat.ftplay.me
chat.hayuri.id
chat.uprize.hu
chatgpt.lam.ee
chicken.cr8.ai
debitozero.com
drayumi.social
gollum.riku.ai
gsbulletin.com
journey.cr8.ai
kopibayane.com
panther.cr7.ai
panther.cr8.ai
pay.sifuim.com
penguin.cr8.ai
petaikorea.com
privetvplus.me
segredomeu.com
semaknilai.com
talk.gocare.io
ticketfute.com
unicorn.cr8.ai
whats-app.chat
survey.collab.day
test.eqfeqfeq.com
titan.jetdigi.com
viewer.typebot.io
welcome.triplo.ai
www.thegymgame.it
zeropendencia.com
1988.bouclidom.com
a.onewebcenter.com
acordorenovado.com
amostra-safe.click
andreimayer.com.br
bebesemcolicas.com
beneficiobr.online
bot.codefree.space
bot.flowmattic.xyz
bot.innovacion.fun
bot.jogodospix.com
bot.jogomilion.com
bot.lucide.contact
bot.mailerflow.com
bot.neferlopez.com
bot.newaiguide.com
bot.photonative.de
bot.rajatanjak.com
bot.samplehunt.com
bot.sinalcerto.com
bot.smoothmenu.com
bot.wphelpchat.com
bots.robomotion.io
brandingmkt.com.br
bt.chatgptlabs.org
cadu.uninta.edu.br
chat-do-cidadao.me
chat.daftarjer.com
chat.foxbot.online
chat.hand-made.one
chat.thausdisc.com
chat.tuanpakya.com
chat.webisharp.com
chatbotforthat.com
cibellyprof.com.br
descobrindotudo.me
dicanatural.online
digitalhelp.com.au
draraquelnutri.com
drcarlosyoshi.site
facilitebid.online
goalsettingbot.com
golpenuncamais.com

@vercel
Copy link

@vercel vercel bot commented on deab1a1 Dec 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

builder-v2 – ./apps/builder

builder-v2-typebot-io.vercel.app
builder-v2-git-main-typebot-io.vercel.app
app.typebot.io

@vercel
Copy link

@vercel vercel bot commented on deab1a1 Dec 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.