2
0

(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:
Baptiste Arnaud
2023-12-18 15:43:58 +01:00
parent 2dec0b88c2
commit deab1a12e9
23 changed files with 428 additions and 156 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View File

@ -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 }
})

View File

@ -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: '' }
}
}
)

View File

@ -0,0 +1,8 @@
import { router } from '@/helpers/server/trpc'
import { getAccessToken } from './getAccessToken'
import { getSpreadsheetName } from './getSpreadsheetName'
export const googleSheetsRouter = router({
getAccessToken,
getSpreadsheetName,
})

View File

@ -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
&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}
@ -62,7 +65,8 @@ export const GoogleSheetConnectModal = ({
href={getGoogleSheetsConsentScreenUrlQuery(
window.location.href,
blockId,
workspace?.id
workspace?.id,
typebotId
)}
mx="auto"
>

View File

@ -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"
/>
)}
<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 && (

View File

@ -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>
)
}

View File

@ -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'}
/>
)
}

View File

@ -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')

View File

@ -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}`
}

View File

@ -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()

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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}`
)
}
}

View File

@ -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) => {

View File

@ -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)

View File

@ -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,16 +56,26 @@ 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
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:
- 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
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:
- 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)

View File

@ -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
View 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 },
})
}

View File

@ -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
View File

@ -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