From 40884d5df9d5a3faf5f138fe9dc5f6f2a3cc2795 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Mon, 21 Jul 2025 16:13:42 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20Gmail=20block=20(#2253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/builder/next.config.mjs | 3 + apps/builder/package.json | 2 +- .../api/[blockType]/oauth/authorize/route.ts | 50 ++ .../googleSheets/api/getSpreadsheetName.ts | 6 +- .../queries/createSheetsCredentialQuery.ts | 10 - .../credentials/api/createOAuthCredentials.ts | 192 +++++++ .../src/features/credentials/api/router.ts | 4 + .../credentials/api/updateOAuthCredentials.ts | 169 ++++++ .../components/CredentialsCreateModal.tsx | 60 +- .../components/CredentialsUpdateModal.tsx | 68 ++- .../features/editor/api/generateGroupTitle.ts | 2 +- .../features/forge/api/fetchSelectItems.ts | 17 +- .../forge/components/ForgedBlockSettings.tsx | 26 +- .../CreateForgedCredentialsModal.tsx | 11 +- .../CreateForgedOAuthCredentialsModal.tsx | 226 ++++++++ .../UpdateForgedCredentialsModalContent.tsx | 2 +- ...dateForgedOAuthCredentialsModalContent.tsx | 217 +++++++ .../components/zodLayouts/ZodFieldLayout.tsx | 37 +- .../src/features/forge/helpers/getFetchers.ts | 2 +- .../google-sheets/spreadsheets/[id]/sheets.ts | 6 +- apps/builder/src/pages/oauth/redirect.tsx | 14 + apps/docs/contribute/the-forge/auth.mdx | 74 ++- .../docs/editor/blocks/integrations/gmail.mdx | 45 ++ apps/docs/mint.json | 3 +- apps/docs/openapi/builder.json | 167 +++++- apps/docs/openapi/viewer.json | 167 +++++- apps/docs/self-hosting/configuration.mdx | 31 +- .../src/features/homepage/hero/TopBar.tsx | 2 +- .../pricing/PlanComparisonsTables.tsx | 6 +- .../src/features/pricing/pro-plan-card.tsx | 6 +- apps/viewer/next.config.mjs | 3 + apps/viewer/playwright.config.ts | 2 +- .../api/integrations/openai/streamer/route.ts | 2 +- .../[spreadsheetId]/sheets/[sheetId].ts | 30 +- apps/viewer/src/test/payment.spec.ts | 3 +- apps/viewer/src/test/sendEmail.spec.ts | 8 +- apps/viewer/src/test/setVariable.spec.ts | 2 +- apps/viewer/src/test/settings.spec.ts | 4 +- bun.lock | 535 ++++++++++-------- .../src/apiHandlers/getMessageStream.ts | 2 +- .../helpers/getAuthenticatedGoogleDoc.ts | 14 +- .../src/forge/executeForgedBlock.ts | 82 ++- packages/credentials/package.json | 6 +- .../src/decryptAndRefreshCredentials.ts | 105 ++++ packages/forge/blocks/anthropic/src/auth.ts | 7 +- .../forge/blocks/anthropic/src/schemas.ts | 2 +- packages/forge/blocks/blink/src/auth.ts | 7 +- packages/forge/blocks/blink/src/schemas.ts | 2 +- packages/forge/blocks/chatNode/src/auth.ts | 7 +- packages/forge/blocks/chatNode/src/schemas.ts | 2 +- packages/forge/blocks/deepseek/src/auth.ts | 3 +- packages/forge/blocks/deepseek/src/schemas.ts | 2 +- packages/forge/blocks/difyAi/src/auth.ts | 7 +- packages/forge/blocks/difyAi/src/schemas.ts | 2 +- packages/forge/blocks/elevenlabs/src/auth.ts | 7 +- .../forge/blocks/elevenlabs/src/schemas.ts | 2 +- packages/forge/blocks/general.mdc | 8 + packages/forge/blocks/gmail/package.json | 20 + .../blocks/gmail/src/actions/sendEmail.ts | 184 ++++++ packages/forge/blocks/gmail/src/auth.ts | 24 + .../blocks/gmail/src/helpers/buildEmail.ts | 51 ++ .../blocks/gmail/src/helpers/getUserName.ts | 17 + .../blocks/gmail/src/helpers/parseFrom.ts | 15 + packages/forge/blocks/gmail/src/index.ts | 13 + packages/forge/blocks/gmail/src/logo.tsx | 26 + packages/forge/blocks/gmail/src/schemas.ts | 10 + packages/forge/blocks/gmail/tsconfig.json | 4 + packages/forge/blocks/groq/src/auth.ts | 7 +- packages/forge/blocks/groq/src/schemas.ts | 5 +- packages/forge/blocks/mistral/src/auth.ts | 7 +- packages/forge/blocks/mistral/src/schemas.ts | 2 +- packages/forge/blocks/nocodb/src/auth.ts | 7 +- .../src/helpers/linkRelationUpdatesIfAny.ts | 1 - packages/forge/blocks/nocodb/src/schemas.ts | 2 +- packages/forge/blocks/openRouter/src/auth.ts | 7 +- .../forge/blocks/openRouter/src/schemas.ts | 2 +- packages/forge/blocks/openai/src/schemas.ts | 3 +- packages/forge/blocks/perplexity/src/auth.ts | 3 +- .../forge/blocks/perplexity/src/schemas.ts | 2 +- packages/forge/blocks/posthog/src/auth.ts | 7 +- packages/forge/blocks/posthog/src/schemas.ts | 2 +- packages/forge/blocks/segment/src/auth.ts | 7 +- packages/forge/blocks/segment/src/schemas.ts | 2 +- packages/forge/blocks/togetherAi/src/auth.ts | 7 +- .../forge/blocks/togetherAi/src/schemas.ts | 2 +- packages/forge/blocks/zendesk/src/auth.ts | 7 +- packages/forge/blocks/zendesk/src/schemas.ts | 2 +- packages/forge/cli/src/index.ts | 123 +++- packages/forge/core/src/index.ts | 39 +- packages/forge/core/src/types.ts | 82 ++- packages/forge/repository/package.json | 3 +- packages/forge/repository/src/constants.ts | 1 + packages/forge/repository/src/credentials.ts | 3 + packages/forge/repository/src/definitions.ts | 2 + packages/forge/repository/src/schemas.ts | 4 + packages/lib/src/parseUnknownError.ts | 4 +- packages/ui/src/components/Button.tsx | 79 ++- 97 files changed, 2689 insertions(+), 580 deletions(-) create mode 100644 apps/builder/src/app/api/[blockType]/oauth/authorize/route.ts delete mode 100644 apps/builder/src/features/blocks/integrations/googleSheets/queries/createSheetsCredentialQuery.ts create mode 100644 apps/builder/src/features/credentials/api/createOAuthCredentials.ts create mode 100644 apps/builder/src/features/credentials/api/updateOAuthCredentials.ts create mode 100644 apps/builder/src/features/forge/components/credentials/CreateForgedOAuthCredentialsModal.tsx create mode 100644 apps/builder/src/features/forge/components/credentials/UpdateForgedOAuthCredentialsModalContent.tsx create mode 100644 apps/builder/src/pages/oauth/redirect.tsx create mode 100644 apps/docs/editor/blocks/integrations/gmail.mdx create mode 100644 packages/credentials/src/decryptAndRefreshCredentials.ts create mode 100644 packages/forge/blocks/general.mdc create mode 100644 packages/forge/blocks/gmail/package.json create mode 100644 packages/forge/blocks/gmail/src/actions/sendEmail.ts create mode 100644 packages/forge/blocks/gmail/src/auth.ts create mode 100644 packages/forge/blocks/gmail/src/helpers/buildEmail.ts create mode 100644 packages/forge/blocks/gmail/src/helpers/getUserName.ts create mode 100644 packages/forge/blocks/gmail/src/helpers/parseFrom.ts create mode 100644 packages/forge/blocks/gmail/src/index.ts create mode 100644 packages/forge/blocks/gmail/src/logo.tsx create mode 100644 packages/forge/blocks/gmail/src/schemas.ts create mode 100644 packages/forge/blocks/gmail/tsconfig.json diff --git a/apps/builder/next.config.mjs b/apps/builder/next.config.mjs index 0d4b872e6c..725740d9d2 100644 --- a/apps/builder/next.config.mjs +++ b/apps/builder/next.config.mjs @@ -66,6 +66,9 @@ const nextConfig = { config.resolve.alias["minio"] = false; config.resolve.alias["qrcode"] = false; config.resolve.alias["isolated-vm"] = false; + config.resolve.alias["@googleapis/gmail"] = false; + config.resolve.alias["nodemailer"] = false; + config.resolve.alias["google-auth-library"] = false; return config; }, headers: async () => { diff --git a/apps/builder/package.json b/apps/builder/package.json index bd0e8cc267..59deb3a4ac 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -78,7 +78,7 @@ "dequal": "2.0.3", "emojilib": "3.0.10", "framer-motion": "11.1.7", - "google-auth-library": "9.15.1", + "google-auth-library": "10.1.0", "google-spreadsheet": "4.1.4", "immer": "10.0.2", "ioredis": "5.4.1", diff --git a/apps/builder/src/app/api/[blockType]/oauth/authorize/route.ts b/apps/builder/src/app/api/[blockType]/oauth/authorize/route.ts new file mode 100644 index 0000000000..9129dd13dc --- /dev/null +++ b/apps/builder/src/app/api/[blockType]/oauth/authorize/route.ts @@ -0,0 +1,50 @@ +import { env } from "@typebot.io/env"; +import { forgedBlocks } from "@typebot.io/forge-repository/definitions"; +import type { AuthDefinition, OAuthDefinition } from "@typebot.io/forge/types"; +import { z } from "@typebot.io/zod"; +import { type NextRequest, NextResponse } from "next/server"; + +const searchParamsSchema = z.object({ + clientId: z.string().optional(), +}); + +export const GET = async ( + req: NextRequest, + { params }: { params: Promise<{ blockType: string }> }, +) => { + const { blockType } = await params; + const authConfig = forgedBlocks[blockType as keyof typeof forgedBlocks].auth; + if (!isOAuthDefinition(authConfig)) + return NextResponse.json({ error: "Invalid block type" }, { status: 400 }); + const { success, data, error } = searchParamsSchema.safeParse( + Object.fromEntries(req.nextUrl.searchParams), + ); + if (!success) + return NextResponse.json({ error: error.message }, { status: 400 }); + const clientId = + data.clientId || + (authConfig.defaultClientEnvKeys + ? process.env[authConfig.defaultClientEnvKeys.id] + : undefined); + if (!clientId) + return NextResponse.json( + { error: "Client ID is required" }, + { status: 400 }, + ); + const url = new URL(authConfig.authUrl); + const urlParams = { + response_type: "code", + client_id: clientId, + redirect_uri: `${env.NEXTAUTH_URL}/oauth/redirect`, + scope: authConfig.scopes.join(" "), + ...authConfig.extraAuthParams, + }; + Object.entries(urlParams).forEach(([k, v]) => url.searchParams.append(k, v)); + return NextResponse.redirect(url); +}; + +const isOAuthDefinition = ( + authConfig: AuthDefinition | undefined, +): authConfig is OAuthDefinition => { + return !!authConfig && authConfig.type === "oauth"; +}; diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/api/getSpreadsheetName.ts b/apps/builder/src/features/blocks/integrations/googleSheets/api/getSpreadsheetName.ts index 3788b72f15..e349d9b07b 100644 --- a/apps/builder/src/features/blocks/integrations/googleSheets/api/getSpreadsheetName.ts +++ b/apps/builder/src/features/blocks/integrations/googleSheets/api/getSpreadsheetName.ts @@ -56,14 +56,16 @@ export const getSpreadsheetName = authenticatedProcedure workspaceId, ); - if (!client) + if (!client?.credentials.access_token) throw new TRPCError({ code: "NOT_FOUND", message: "Google client could not be initialized", }); try { - const googleSheet = new GoogleSpreadsheet(spreadsheetId, client); + const googleSheet = new GoogleSpreadsheet(spreadsheetId, { + token: client.credentials.access_token, + }); await googleSheet.loadInfo(); diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/queries/createSheetsCredentialQuery.ts b/apps/builder/src/features/blocks/integrations/googleSheets/queries/createSheetsCredentialQuery.ts deleted file mode 100644 index fd36aab809..0000000000 --- a/apps/builder/src/features/blocks/integrations/googleSheets/queries/createSheetsCredentialQuery.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { sendRequest } from "@typebot.io/lib/utils"; -import { stringify } from "qs"; - -export const createSheetsCredentialQuery = async (code: string) => { - const queryParams = stringify({ code }); - return sendRequest({ - url: `/api/credentials/google-sheets/callback?${queryParams}`, - method: "GET", - }); -}; diff --git a/apps/builder/src/features/credentials/api/createOAuthCredentials.ts b/apps/builder/src/features/credentials/api/createOAuthCredentials.ts new file mode 100644 index 0000000000..44b656bacb --- /dev/null +++ b/apps/builder/src/features/credentials/api/createOAuthCredentials.ts @@ -0,0 +1,192 @@ +import { isWriteWorkspaceForbidden } from "@/features/workspace/helpers/isWriteWorkspaceForbidden"; +import { authenticatedProcedure } from "@/helpers/server/trpc"; +import { TRPCError } from "@trpc/server"; +import { encrypt } from "@typebot.io/credentials/encrypt"; +import { env } from "@typebot.io/env"; +import { forgedBlocks } from "@typebot.io/forge-repository/definitions"; +import type { OAuthDefinition } from "@typebot.io/forge/types"; +import { parseUnknownError } from "@typebot.io/lib/parseUnknownError"; +import prisma from "@typebot.io/prisma"; +import { z } from "@typebot.io/zod"; +import ky from "ky"; + +const commonInput = z.object({ + name: z.string(), + blockType: z.string(), + code: z.string(), + customClient: z + .object({ + id: z.string(), + secret: z.string(), + }) + .optional(), +}); + +export const createOAuthCredentials = authenticatedProcedure + .input( + z.discriminatedUnion("scope", [ + z + .object({ + scope: z.literal("workspace"), + workspaceId: z.string(), + }) + .merge(commonInput), + z + .object({ + scope: z.literal("user"), + }) + .merge(commonInput), + ]), + ) + .output( + z.object({ + credentialsId: z.string(), + }), + ) + .mutation(async ({ input, ctx: { user } }) => { + const blockDef = forgedBlocks[input.blockType as keyof typeof forgedBlocks]; + if (!blockDef || blockDef.auth?.type !== "oauth") + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Block is not an OAuth block", + }); + + const client = getClient(input.customClient, blockDef.auth); + + if (!client) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No client ID or secret provided or default client not set", + }); + + const tokens = await exchangeCodeForTokens({ + tokenUrl: blockDef.auth.tokenUrl, + client, + code: input.code, + }); + + const { encryptedData, iv } = await encrypt({ + ...tokens, + client: input.customClient, + }); + + if (input.scope === "user") { + const createdCredentials = await prisma.userCredentials.create({ + data: { + name: input.name, + type: input.blockType, + userId: user.id, + data: encryptedData, + iv, + }, + select: { + id: true, + }, + }); + + return { credentialsId: createdCredentials.id }; + } + const workspace = await prisma.workspace.findFirst({ + where: { + id: input.workspaceId, + }, + select: { id: true, members: { select: { userId: true, role: true } } }, + }); + if (!workspace || isWriteWorkspaceForbidden(workspace, user)) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + + const createdCredentials = await prisma.credentials.create({ + data: { + name: input.name, + type: input.blockType, + workspaceId: input.workspaceId, + data: encryptedData, + iv, + }, + select: { + id: true, + }, + }); + + return { credentialsId: createdCredentials.id }; + }); + +const exchangeCodeForTokens = async ({ + tokenUrl, + client, + code, +}: { + tokenUrl: string; + client: { id: string; secret: string }; + code: string; +}) => { + try { + const tokens = await ky + .post(tokenUrl, { + json: { + grant_type: "authorization_code", + code: code, + client_id: client.id, + client_secret: client.secret, + redirect_uri: `${env.NEXTAUTH_URL}/oauth/redirect`, + }, + }) + .json(); + + if ( + !tokens || + typeof tokens !== "object" || + !("access_token" in tokens) || + !("refresh_token" in tokens) || + !("expires_in" in tokens) || + typeof tokens.access_token !== "string" || + typeof tokens.refresh_token !== "string" || + typeof tokens.expires_in !== "number" + ) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid tokens returned from the auth provider", + }); + + if (!tokens.refresh_token) { + return { + error: { + context: "token exchange", + description: "No refresh_token returned", + }, + }; + } + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiryDate: Date.now() + (tokens.expires_in ?? 0) * 1000, + }; + } catch (err) { + const parsedError = await parseUnknownError({ + err, + context: "token exchange", + }); + console.error(parsedError); + throw new TRPCError({ + code: "BAD_REQUEST", + message: parsedError.description, + }); + } +}; + +const getClient = ( + customClient: { id: string; secret: string } | undefined, + authDef: OAuthDefinition, +) => { + if (customClient) return customClient; + if (authDef.defaultClientEnvKeys) { + const id = process.env[authDef.defaultClientEnvKeys.id]; + const secret = process.env[authDef.defaultClientEnvKeys.secret]; + if (id && secret) return { id, secret }; + } + return undefined; +}; diff --git a/apps/builder/src/features/credentials/api/router.ts b/apps/builder/src/features/credentials/api/router.ts index 6fea35564e..45677a6b14 100644 --- a/apps/builder/src/features/credentials/api/router.ts +++ b/apps/builder/src/features/credentials/api/router.ts @@ -1,12 +1,16 @@ import { router } from "@/helpers/server/trpc"; import { createCredentials } from "./createCredentials"; +import { createOAuthCredentials } from "./createOAuthCredentials"; import { deleteCredentials } from "./deleteCredentials"; import { getCredentials } from "./getCredentials"; import { listCredentials } from "./listCredentials"; import { updateCredentials } from "./updateCredentials"; +import { updateOAuthCredentials } from "./updateOAuthCredentials"; export const credentialsRouter = router({ createCredentials, + createOAuthCredentials, + updateOAuthCredentials, listCredentials, getCredentials, deleteCredentials, diff --git a/apps/builder/src/features/credentials/api/updateOAuthCredentials.ts b/apps/builder/src/features/credentials/api/updateOAuthCredentials.ts new file mode 100644 index 0000000000..ffdb3f8b8c --- /dev/null +++ b/apps/builder/src/features/credentials/api/updateOAuthCredentials.ts @@ -0,0 +1,169 @@ +import { isWriteWorkspaceForbidden } from "@/features/workspace/helpers/isWriteWorkspaceForbidden"; +import { authenticatedProcedure } from "@/helpers/server/trpc"; +import { TRPCError } from "@trpc/server"; +import { encrypt } from "@typebot.io/credentials/encrypt"; +import { env } from "@typebot.io/env"; +import { forgedBlocks } from "@typebot.io/forge-repository/definitions"; +import type { OAuthDefinition } from "@typebot.io/forge/types"; +import { parseUnknownError } from "@typebot.io/lib/parseUnknownError"; +import prisma from "@typebot.io/prisma"; +import { z } from "@typebot.io/zod"; +import ky from "ky"; + +export const updateOAuthCredentials = authenticatedProcedure + .input( + z.object({ + name: z.string(), + blockType: z.string(), + workspaceId: z.string(), + customClient: z + .object({ + id: z.string(), + secret: z.string(), + }) + .optional(), + credentialsId: z.string(), + code: z.string(), + }), + ) + .output( + z.object({ + credentialsId: z.string(), + }), + ) + .mutation(async ({ input, ctx: { user } }) => { + const workspace = await prisma.workspace.findFirst({ + where: { + id: input.workspaceId, + }, + select: { id: true, members: { select: { userId: true, role: true } } }, + }); + if (!workspace || isWriteWorkspaceForbidden(workspace, user)) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + + const blockDef = forgedBlocks[input.blockType as keyof typeof forgedBlocks]; + if (!blockDef || blockDef.auth?.type !== "oauth") + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Block is not an OAuth block", + }); + + const client = getClient(input.customClient, blockDef.auth); + + if (!client) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No client ID or secret provided or default client not set", + }); + + const tokens = await exchangeCodeForTokens({ + tokenUrl: blockDef.auth.tokenUrl, + client, + code: input.code, + blockType: input.blockType, + }); + + const { encryptedData, iv } = await encrypt({ + ...tokens, + client: input.customClient, + }); + const updatedCredentials = await prisma.credentials.update({ + where: { + id: input.credentialsId, + }, + data: { + name: input.name, + type: input.blockType, + workspaceId: input.workspaceId, + data: encryptedData, + iv, + }, + select: { + id: true, + }, + }); + + return { credentialsId: updatedCredentials.id }; + }); + +const exchangeCodeForTokens = async ({ + tokenUrl, + client, + code, + blockType, +}: { + tokenUrl: string; + client: { id: string; secret: string }; + code: string; + blockType: string; +}) => { + try { + const tokens = await ky + .post(tokenUrl, { + json: { + grant_type: "authorization_code", + code: code, + client_id: client.id, + client_secret: client.secret, + redirect_uri: `${env.NEXTAUTH_URL}/oauth/redirect`, + }, + }) + .json(); + + if ( + !tokens || + typeof tokens !== "object" || + !("access_token" in tokens) || + !("refresh_token" in tokens) || + !("expires_in" in tokens) || + typeof tokens.access_token !== "string" || + typeof tokens.refresh_token !== "string" || + typeof tokens.expires_in !== "number" + ) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid tokens returned from the auth provider", + }); + + if (!tokens.refresh_token) { + return { + error: { + context: "token exchange", + description: "No refresh_token returned", + }, + }; + } + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiryDate: Date.now() + (tokens.expires_in ?? 0) * 1000, + }; + } catch (err) { + const parsedError = await parseUnknownError({ + err, + context: "token exchange", + }); + console.error(parsedError); + throw new TRPCError({ + code: "BAD_REQUEST", + message: parsedError.description, + }); + } +}; + +const getClient = ( + customClient: { id: string; secret: string } | undefined, + authDef: OAuthDefinition, +) => { + if (customClient) return customClient; + if (authDef.defaultClientEnvKeys) { + const id = process.env[authDef.defaultClientEnvKeys.id]; + const secret = process.env[authDef.defaultClientEnvKeys.secret]; + if (id && secret) return { id, secret }; + } + return undefined; +}; diff --git a/apps/builder/src/features/credentials/components/CredentialsCreateModal.tsx b/apps/builder/src/features/credentials/components/CredentialsCreateModal.tsx index 6cc62a9266..285e1467bc 100644 --- a/apps/builder/src/features/credentials/components/CredentialsCreateModal.tsx +++ b/apps/builder/src/features/credentials/components/CredentialsCreateModal.tsx @@ -2,6 +2,7 @@ import { StripeCreateModalContent } from "@/features/blocks/inputs/payment/compo import { GoogleSheetConnectModalContent } from "@/features/blocks/integrations/googleSheets/components/GoogleSheetsConnectModal"; import { SmtpCreateModalContent } from "@/features/blocks/integrations/sendEmail/components/SmtpConfigModal"; import { CreateForgedCredentialsModalContent } from "@/features/forge/components/credentials/CreateForgedCredentialsModal"; +import { CreateForgedOAuthCredentialsModalContent } from "@/features/forge/components/credentials/CreateForgedOAuthCredentialsModal"; import { WhatsAppCreateModalContent } from "@/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppCredentialsModal"; import { Modal, ModalOverlay } from "@chakra-ui/react"; import type { Credentials } from "@typebot.io/credentials/schemas"; @@ -48,34 +49,37 @@ const CredentialsCreateModalContent = ({ onClose: () => void; onSubmit: () => void; }) => { - switch (type) { - case "google sheets": - return ; - case "smtp": - return ; - case "stripe": - return ( - - ); - case "whatsApp": - return ( - - ); - default: - return ( - - ); - } + if (type === "google sheets") return ; + if (type === "smtp") + return ; + if (type === "stripe") + return ( + + ); + if (type === "whatsApp") + return ( + + ); + + if (forgedBlocks[type].auth?.type === "oauth") + return ( + + ); + + return ( + + ); }; const parseModalSize = (type?: Credentials["type"]) => { diff --git a/apps/builder/src/features/credentials/components/CredentialsUpdateModal.tsx b/apps/builder/src/features/credentials/components/CredentialsUpdateModal.tsx index ba64b7b539..102ae287bb 100644 --- a/apps/builder/src/features/credentials/components/CredentialsUpdateModal.tsx +++ b/apps/builder/src/features/credentials/components/CredentialsUpdateModal.tsx @@ -1,6 +1,7 @@ import { UpdateStripeCredentialsModalContent } from "@/features/blocks/inputs/payment/components/UpdateStripeCredentialsModalContent"; import { SmtpUpdateModalContent } from "@/features/blocks/integrations/sendEmail/components/SmtpUpdateModalContent"; import { UpdateForgedCredentialsModalContent } from "@/features/forge/components/credentials/UpdateForgedCredentialsModalContent"; +import { UpdateForgedOAuthCredentialsModalContent } from "@/features/forge/components/credentials/UpdateForgedOAuthCredentialsModalContent"; import { Modal, ModalOverlay } from "@chakra-ui/react"; import type { Credentials } from "@typebot.io/credentials/schemas"; import { forgedBlocks } from "@typebot.io/forge-repository/definitions"; @@ -45,33 +46,42 @@ const CredentialsUpdateModalContent = ({ scope: "workspace" | "user"; onSubmit: () => void; }) => { - switch (editingCredentials.type) { - case "google sheets": - return null; - case "smtp": - return ( - - ); - case "stripe": - return ( - - ); - case "whatsApp": - return null; - default: - return ( - - ); - } + if (editingCredentials.type === "google sheets") return null; + + if (editingCredentials.type === "smtp") + return ( + + ); + + if (editingCredentials.type === "stripe") + return ( + + ); + + if (editingCredentials.type === "whatsApp") return null; + + if (forgedBlocks[editingCredentials.type].auth?.type === "oauth") + return ( + + ); + + return ( + + ); }; diff --git a/apps/builder/src/features/editor/api/generateGroupTitle.ts b/apps/builder/src/features/editor/api/generateGroupTitle.ts index 065dd3e684..1e2d33f221 100644 --- a/apps/builder/src/features/editor/api/generateGroupTitle.ts +++ b/apps/builder/src/features/editor/api/generateGroupTitle.ts @@ -111,7 +111,7 @@ export const generateGroupTitle = authenticatedProcedure const aiModel = action?.aiGenerate?.getModel?.({ credentials: { apiKey, - }, + } as any, model, }); if (!aiModel) diff --git a/apps/builder/src/features/forge/api/fetchSelectItems.ts b/apps/builder/src/features/forge/api/fetchSelectItems.ts index 0fb2894fd6..d3bbefcf19 100644 --- a/apps/builder/src/features/forge/api/fetchSelectItems.ts +++ b/apps/builder/src/features/forge/api/fetchSelectItems.ts @@ -2,7 +2,8 @@ import { isReadWorkspaceFobidden } from "@/features/workspace/helpers/isReadWork import { authenticatedProcedure } from "@/helpers/server/trpc"; import { ClientToastError } from "@/lib/ClientToastError"; import { TRPCError } from "@trpc/server"; -import { decrypt } from "@typebot.io/credentials/decrypt"; +import { decryptAndRefreshCredentialsData } from "@typebot.io/credentials/decryptAndRefreshCredentials"; +import type { Credentials } from "@typebot.io/credentials/schemas"; import { forgedBlockIds } from "@typebot.io/forge-repository/constants"; import { forgedBlocks } from "@typebot.io/forge-repository/definitions"; import prisma from "@typebot.io/prisma"; @@ -78,12 +79,20 @@ export const fetchSelectItems = authenticatedProcedure credentials = workspace.credentials?.at(0); } + const blockDef = forgedBlocks[input.integrationId]; + const credentialsData = credentials - ? await decrypt(credentials.data, credentials.iv) + ? await decryptAndRefreshCredentialsData( + { + ...credentials, + type: input.integrationId as Credentials["type"], + }, + blockDef.auth && "defaultClientEnvKeys" in blockDef.auth + ? blockDef.auth.defaultClientEnvKeys + : undefined, + ) : undefined; - const blockDef = forgedBlocks[input.integrationId]; - const fetcher = getFetchers(blockDef).find( (fetcher) => fetcher.id === input.fetcherId, ); diff --git a/apps/builder/src/features/forge/components/ForgedBlockSettings.tsx b/apps/builder/src/features/forge/components/ForgedBlockSettings.tsx index 7d74fed980..fba2349fee 100644 --- a/apps/builder/src/features/forge/components/ForgedBlockSettings.tsx +++ b/apps/builder/src/features/forge/components/ForgedBlockSettings.tsx @@ -4,6 +4,7 @@ import type { ForgedBlock } from "@typebot.io/forge-repository/schemas"; import { useState } from "react"; import { useForgedBlock } from "../hooks/useForgedBlock"; import { CreateForgedCredentialsModal } from "./credentials/CreateForgedCredentialsModal"; +import { CreateForgedOAuthCredentialsModal } from "./credentials/CreateForgedOAuthCredentialsModal"; import { ForgedCredentialsDropdown } from "./credentials/ForgedCredentialsDropdown"; import { ZodActionDiscriminatedUnion } from "./zodLayouts/ZodActionDiscriminatedUnion"; import { ZodObjectLayout } from "./zodLayouts/ZodObjectLayout"; @@ -62,13 +63,24 @@ export const ForgedBlockSettings = ({ block, onOptionsChange }: Props) => { {blockDef.auth && ( <> - + {blockDef.auth.type === "oauth" ? ( + + ) : ( + + )} + { + const createForgedCredentials = async (e: React.FormEvent) => { e.preventDefault(); if (!workspace || !blockDef.auth) return; mutate( @@ -112,7 +113,7 @@ export const CreateForgedCredentialsModalContent = ({ Add {blockDef.auth.name} -
+ diff --git a/apps/builder/src/features/forge/components/credentials/CreateForgedOAuthCredentialsModal.tsx b/apps/builder/src/features/forge/components/credentials/CreateForgedOAuthCredentialsModal.tsx new file mode 100644 index 0000000000..26a02f4eb6 --- /dev/null +++ b/apps/builder/src/features/forge/components/credentials/CreateForgedOAuthCredentialsModal.tsx @@ -0,0 +1,226 @@ +import { stringify } from "querystring"; +import { CopyButton } from "@/components/CopyButton"; +import { TextInput } from "@/components/inputs/TextInput"; +import { useWorkspace } from "@/features/workspace/WorkspaceProvider"; +import { queryClient, trpc } from "@/lib/queryClient"; +import { toast } from "@/lib/toast"; +import { + Input, + InputGroup, + InputRightElement, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Stack, +} from "@chakra-ui/react"; +import { useMutation } from "@tanstack/react-query"; +import type { ForgedBlockDefinition } from "@typebot.io/forge-repository/definitions"; +import { Button } from "@typebot.io/ui/components/Button"; +import { useState } from "react"; + +type Props = { + blockDef: ForgedBlockDefinition; + isOpen: boolean; + defaultData?: any; + scope: "workspace" | "user"; + editorContext?: { + typebotId: string; + blockId: string; + }; + onClose: () => void; + onNewCredentials: (id: string) => void; +}; + +export const CreateForgedOAuthCredentialsModal = ({ + blockDef, + isOpen, + scope, + defaultData, + onClose, + onNewCredentials, +}: Props) => { + if (blockDef.auth?.type !== "oauth") return null; + return ( + + + { + onClose(); + onNewCredentials(id); + }} + /> + + ); +}; + +export const CreateForgedOAuthCredentialsModalContent = ({ + blockDef, + scope, + onNewCredentials, +}: Pick< + Props, + "blockDef" | "onNewCredentials" | "defaultData" | "editorContext" | "scope" +>) => { + const { workspace } = useWorkspace(); + const [isAuthorizing, setIsAuthorizing] = useState(false); + const [name, setName] = useState(""); + const [tab, setTab] = useState<"default" | "your-app">( + blockDef.auth && "defaultClientEnvKeys" in blockDef.auth + ? "default" + : "your-app", + ); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + + const { mutate, isPending } = useMutation( + trpc.credentials.createOAuthCredentials.mutationOptions({ + onError: (err) => { + toast({ + description: err.message, + }); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: trpc.credentials.listCredentials.queryKey(), + }); + onNewCredentials(data.credentialsId); + }, + }), + ); + + const openOAuthPopup = async () => { + if (!workspace) return; + + setIsAuthorizing(true); + + window.open( + `/api/${blockDef.id}/oauth/authorize?${stringify({ + clientId: clientId, + })}`, + "oauthPopup", + "width=500,height=700", + ); + + const handleOAuthResponse = (event: MessageEvent) => { + if (event.data?.type === "oauth") { + window.removeEventListener("message", handleOAuthResponse); + setIsAuthorizing(false); + const { code } = event.data; + const credentials = { + name, + blockType: blockDef.id, + code, + customClient: + tab === "your-app" + ? { + id: clientId, + secret: clientSecret, + } + : undefined, + }; + mutate( + scope === "workspace" + ? { + ...credentials, + scope: "workspace", + workspaceId: workspace.id, + } + : { + ...credentials, + scope: "user", + }, + ); + } + }; + + window.addEventListener("message", handleOAuthResponse); + }; + + if (!blockDef.auth) return null; + return ( + + Add {blockDef.auth.name} + + + + {"defaultClientEnvKeys" in blockDef.auth ? ( +
+

OAuth app

+
+ + +
+
+ ) : null} + {tab === "your-app" ? ( +
+ Redirect URL + + + +
+ ) : null} +
+ + + + +
+ ); +}; + +const ReadOnlyInput = ({ value }: { value: string }) => ( + + + + + + +); diff --git a/apps/builder/src/features/forge/components/credentials/UpdateForgedCredentialsModalContent.tsx b/apps/builder/src/features/forge/components/credentials/UpdateForgedCredentialsModalContent.tsx index dd08f61cb8..dadf9b36ef 100644 --- a/apps/builder/src/features/forge/components/credentials/UpdateForgedCredentialsModalContent.tsx +++ b/apps/builder/src/features/forge/components/credentials/UpdateForgedCredentialsModalContent.tsx @@ -123,7 +123,7 @@ export const UpdateForgedCredentialsModalContent = ({ withVariableButton={false} debounceTimeout={0} /> - {data && ( + {data && blockDef.auth.type === "encryptedCredentials" && ( void; +}; + +export const UpdateForgedOAuthCredentialsModalContent = ({ + credentialsId, + blockDef, + scope, + onUpdate, +}: Props) => { + const { workspace } = useWorkspace(); + + const [name, setName] = useState(""); + const [tab, setTab] = useState<"default" | "your-app">( + blockDef.auth && "defaultClientEnvKeys" in blockDef.auth + ? "default" + : "your-app", + ); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + + const { data: existingCredentials, refetch: refetchCredentials } = useQuery( + trpc.credentials.getCredentials.queryOptions( + scope === "workspace" + ? { + scope: "workspace", + workspaceId: workspace?.id as string, + credentialsId, + } + : { + scope: "user", + credentialsId, + }, + { + enabled: !!workspace?.id, + }, + ), + ); + + useEffect(() => { + if (!existingCredentials) return; + if (name !== "" || clientId !== "" || clientSecret !== "") return; + setName(existingCredentials.name); + if ( + "client" in existingCredentials.data && + existingCredentials.data.client + ) { + const client = existingCredentials.data.client as { + id: string; + secret: string; + }; + setClientId(client.id); + setClientSecret(client.secret); + setTab("your-app"); + } + }, [existingCredentials, clientId, clientSecret, name]); + + const { mutate, isPending } = useMutation( + trpc.credentials.updateOAuthCredentials.mutationOptions({ + onError: (err) => { + toast({ + description: err.message, + }); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: trpc.credentials.listCredentials.queryKey(), + }); + onUpdate(); + }, + }), + ); + + const openOAuthPopup = async () => { + if (!workspace) return; + + window.open( + `/api/${blockDef.id}/oauth/authorize?${stringify({ + clientId: clientId, + })}`, + "oauthPopup", + "width=500,height=700", + ); + + const handleOAuthResponse = (event: MessageEvent) => { + if (event.data?.type === "oauth") { + window.removeEventListener("message", handleOAuthResponse); + const { code } = event.data; + mutate({ + name, + blockType: blockDef.id, + workspaceId: workspace.id, + credentialsId, + code, + customClient: + tab === "your-app" + ? { + id: clientId, + secret: clientSecret, + } + : undefined, + }); + } + }; + + window.removeEventListener("message", handleOAuthResponse); + window.addEventListener("message", handleOAuthResponse); + }; + + if (!blockDef.auth) return null; + return ( + + Add {blockDef.auth.name} + + + + {"defaultClientEnvKeys" in blockDef.auth ? ( +
+

OAuth app

+
+ + +
+
+ ) : null} + {tab === "your-app" ? ( +
+ Redirect URL + + + +
+ ) : null} +
+ + + + +
+ ); +}; + +const ReadOnlyInput = ({ value }: { value: string }) => ( + + + + + + +); diff --git a/apps/builder/src/features/forge/components/zodLayouts/ZodFieldLayout.tsx b/apps/builder/src/features/forge/components/zodLayouts/ZodFieldLayout.tsx index 87d75808e3..043f664d94 100644 --- a/apps/builder/src/features/forge/components/zodLayouts/ZodFieldLayout.tsx +++ b/apps/builder/src/features/forge/components/zodLayouts/ZodFieldLayout.tsx @@ -74,6 +74,24 @@ export const ZodFieldLayout = ({ if (evaluateIsHidden(layout?.isHidden, blockOptions)) return null; + if (layout?.inputType === "variableDropdown") { + return ( + onDataChange(variable?.id)} + placeholder={layout?.placeholder} + label={layout?.label} + moreInfoTooltip={layout.moreInfoTooltip} + helperText={ + layout?.helperText ? ( + {layout.helperText} + ) : undefined + } + width={width} + /> + ); + } + switch (innerSchema._def.typeName) { case "ZodObject": return ( @@ -233,25 +251,6 @@ export const ZodFieldLayout = ({ /> ); } - if (layout?.inputType === "variableDropdown") { - return ( - onDataChange(variable?.id)} - placeholder={layout?.placeholder} - label={layout?.label} - moreInfoTooltip={layout.moreInfoTooltip} - helperText={ - layout?.helperText ? ( - - {layout.helperText} - - ) : undefined - } - width={width} - /> - ); - } if (layout?.inputType === "textarea") { return (