From c6005c49a207918ba0e9fcbc0e02f3ac0f485231 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Tue, 16 Jul 2024 15:11:48 +0200 Subject: [PATCH] :sparkles: (credentials) Add credentials management menu in workspace settings Closes #1567 --- .eslintignore | 2 +- .prettierignore | 1 + apps/builder/.eslintignore | 1 + apps/builder/package.json | 2 +- apps/builder/playwright.config.ts | 6 +- apps/builder/src/components/icons.tsx | 8 + .../src/components/inputs/NumberInput.tsx | 7 + .../src/components/inputs/SwitchWithLabel.tsx | 2 +- .../src/components/logos/StripeLogo.tsx | 16 + .../payment/components/StripeConfigModal.tsx | 40 +- .../UpdateStripeCredentialsModalContent.tsx | 236 ++++ .../blocks/inputs/payment/payment.spec.ts | 3 +- .../components/GoogleSheetsConnectModal.tsx | 74 +- .../components/GoogleSheetsLogo.tsx | 104 +- .../getGoogleSheetsConsentScreenUrlQuery.ts | 4 +- .../sendEmail/components/SmtpConfigForm.tsx | 46 +- .../sendEmail/components/SmtpConfigModal.tsx | 37 +- .../components/SmtpUpdateModalContent.tsx | 113 ++ .../integrations/webhook/webhook.spec.ts | 264 ++-- .../zemanticAi/ZemanticAiCredentialsModal.tsx | 125 -- .../zemanticAi/ZemanticAiSettings.tsx | 38 - .../credentials/api/createCredentials.ts | 17 +- .../credentials/api/deleteCredentials.ts | 5 +- .../credentials/api/getCredentials.ts | 66 + .../credentials/api/listCredentials.ts | 36 +- .../src/features/credentials/api/router.ts | 4 + .../credentials/api/updateCredentials.ts | 81 ++ .../components/CredentialsCreateModal.tsx | 82 ++ .../components/CredentialsSettingsForm.tsx | 336 +++++ .../components/CredentialsUpdateModal.tsx | 71 + .../features/credentials/credentials.spec.ts | 39 + .../dashboard/components/DashboardHeader.tsx | 11 +- .../features/editor/components/BlockIcon.tsx | 83 +- .../features/editor/components/BlockLabel.tsx | 204 ++- .../src/features/forge/ForgedBlockIcon.tsx | 21 +- .../src/features/forge/ForgedBlockLabel.tsx | 13 +- .../api/credentials/createCredentials.ts | 54 - .../api/credentials/deleteCredentials.ts | 38 - .../forge/api/credentials/listCredentials.ts | 38 - apps/builder/src/features/forge/api/router.ts | 6 - .../forge/components/ForgedBlockSettings.tsx | 4 +- ...l.tsx => CreateForgedCredentialsModal.tsx} | 101 +- .../credentials/ForgedCredentialsDropdown.tsx | 16 +- .../UpdateForgedCredentialsModalContent.tsx | 119 ++ .../WhatsAppCredentialsModal.tsx | 157 +-- .../components/WorkspaceSettingsModal.tsx | 21 +- .../api/credentials/google-sheets/callback.ts | 8 +- .../builder/src/test/assets/typebots/api.json | 264 ++-- .../integrations/easyConfigWebhook.json | 3 +- .../assets/typebots/integrations/webhook.json | 117 +- apps/docs/openapi/builder.json | 1153 +++++++++++++++-- apps/viewer/.eslintignore | 1 + apps/viewer/package.json | 2 +- apps/viewer/playwright.config.ts | 6 +- .../test/assets/typebots/chat/linkedBot.json | 86 +- .../src/test/assets/typebots/chat/main.json | 329 ++--- .../typebots/chat/startingWithInput.json | 36 +- .../src/test/assets/typebots/webhook.json | 224 ++-- apps/viewer/src/test/chat.spec.ts | 65 +- apps/viewer/src/test/webhook.spec.ts | 42 +- .../webhook/executeWebhookBlock.ts | 4 +- packages/forge/blocks/anthropic/schemas.ts | 6 +- packages/forge/blocks/calCom/schemas.ts | 3 +- packages/forge/blocks/chatNode/schemas.ts | 6 +- packages/forge/blocks/difyAi/schemas.ts | 6 +- packages/forge/blocks/elevenlabs/schemas.ts | 7 +- packages/forge/blocks/mistral/schemas.ts | 6 +- packages/forge/blocks/nocodb/schemas.ts | 6 +- packages/forge/blocks/openRouter/schemas.ts | 7 +- packages/forge/blocks/openai/schemas.ts | 6 +- packages/forge/blocks/qrcode/schemas.ts | 3 +- packages/forge/blocks/togetherAi/schemas.ts | 7 +- packages/forge/blocks/zemanticAi/schemas.ts | 7 +- packages/forge/cli/index.ts | 15 +- packages/forge/core/index.ts | 14 +- packages/forge/repository/credentials.ts | 6 - packages/lib/package.json | 4 +- packages/playwright/databaseActions.ts | 19 - packages/playwright/package.json | 2 +- packages/schemas/features/credentials.ts | 20 +- pnpm-lock.yaml | 44 +- 81 files changed, 3582 insertions(+), 1704 deletions(-) create mode 100644 apps/builder/.eslintignore create mode 100644 apps/builder/src/components/logos/StripeLogo.tsx create mode 100644 apps/builder/src/features/blocks/inputs/payment/components/UpdateStripeCredentialsModalContent.tsx create mode 100644 apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpUpdateModalContent.tsx delete mode 100644 apps/builder/src/features/blocks/integrations/zemanticAi/ZemanticAiCredentialsModal.tsx create mode 100644 apps/builder/src/features/credentials/api/getCredentials.ts create mode 100644 apps/builder/src/features/credentials/api/updateCredentials.ts create mode 100644 apps/builder/src/features/credentials/components/CredentialsCreateModal.tsx create mode 100644 apps/builder/src/features/credentials/components/CredentialsSettingsForm.tsx create mode 100644 apps/builder/src/features/credentials/components/CredentialsUpdateModal.tsx create mode 100644 apps/builder/src/features/credentials/credentials.spec.ts delete mode 100644 apps/builder/src/features/forge/api/credentials/createCredentials.ts delete mode 100644 apps/builder/src/features/forge/api/credentials/deleteCredentials.ts delete mode 100644 apps/builder/src/features/forge/api/credentials/listCredentials.ts rename apps/builder/src/features/forge/components/credentials/{ForgedCredentialsModal.tsx => CreateForgedCredentialsModal.tsx} (50%) create mode 100644 apps/builder/src/features/forge/components/credentials/UpdateForgedCredentialsModalContent.tsx create mode 100644 apps/viewer/.eslintignore diff --git a/.eslintignore b/.eslintignore index b512c09d4..3c3629e64 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1 @@ -node_modules \ No newline at end of file +node_modules diff --git a/.prettierignore b/.prettierignore index 561504a01..c6fdd0e10 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ emojiList.json iconNames.ts reporters +.last-run.json diff --git a/apps/builder/.eslintignore b/apps/builder/.eslintignore new file mode 100644 index 000000000..ebd9b0fa7 --- /dev/null +++ b/apps/builder/.eslintignore @@ -0,0 +1 @@ +src/test/reporters \ No newline at end of file diff --git a/apps/builder/package.json b/apps/builder/package.json index 9b357cfc0..344128904 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -100,7 +100,7 @@ }, "devDependencies": { "@chakra-ui/styled-system": "2.9.2", - "@playwright/test": "1.43.1", + "@playwright/test": "1.45.2", "@typebot.io/billing": "workspace:*", "@typebot.io/forge": "workspace:*", "@typebot.io/forge-repository": "workspace:*", diff --git a/apps/builder/playwright.config.ts b/apps/builder/playwright.config.ts index 91b2463ed..72fe4e447 100644 --- a/apps/builder/playwright.config.ts +++ b/apps/builder/playwright.config.ts @@ -10,13 +10,13 @@ export default defineConfig({ timeout: process.env.CI ? 10 * 1000 : 5 * 1000, }, forbidOnly: !!process.env.CI, - workers: process.env.CI ? 1 : 3, - retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : 4, + retries: process.env.CI ? 2 : 1, reporter: [ [process.env.CI ? 'github' : 'list'], ['html', { outputFolder: 'src/test/reporters' }], ], - maxFailures: process.env.CI ? 10 : undefined, + maxFailures: 10, webServer: process.env.CI ? { command: 'pnpm run start', diff --git a/apps/builder/src/components/icons.tsx b/apps/builder/src/components/icons.tsx index aeeecc9ba..bad82bc2e 100644 --- a/apps/builder/src/components/icons.tsx +++ b/apps/builder/src/components/icons.tsx @@ -695,3 +695,11 @@ export const VideoPopoverIcon = (props: IconProps) => ( /> ) + +export const WalletIcon = (props: IconProps) => ( + + + + + +) diff --git a/apps/builder/src/components/inputs/NumberInput.tsx b/apps/builder/src/components/inputs/NumberInput.tsx index 82bb2c688..8b7d7027d 100644 --- a/apps/builder/src/components/inputs/NumberInput.tsx +++ b/apps/builder/src/components/inputs/NumberInput.tsx @@ -49,6 +49,7 @@ export const NumberInput = ({ helperText, ...props }: Props) => { + const [isTouched, setIsTouched] = useState(false) const [value, setValue] = useState(defaultValue?.toString() ?? '') const onValueChangeDebounced = useDebouncedCallback( @@ -56,6 +57,11 @@ export const NumberInput = ({ env.NEXT_PUBLIC_E2E_TEST ? 0 : debounceTimeout ) + useEffect(() => { + if (isTouched || value !== '' || !defaultValue) return + setValue(defaultValue?.toString() ?? '') + }, [defaultValue, isTouched, value]) + useEffect( () => () => { onValueChangeDebounced.flush() @@ -64,6 +70,7 @@ export const NumberInput = ({ ) const handleValueChange = (newValue: string) => { + if (!isTouched) setIsTouched(true) if (value.startsWith('{{') && value.endsWith('}}') && newValue !== '') return setValue(newValue) diff --git a/apps/builder/src/components/inputs/SwitchWithLabel.tsx b/apps/builder/src/components/inputs/SwitchWithLabel.tsx index 4813108a0..da6cb0c64 100644 --- a/apps/builder/src/components/inputs/SwitchWithLabel.tsx +++ b/apps/builder/src/components/inputs/SwitchWithLabel.tsx @@ -12,7 +12,7 @@ import { isDefined } from '@typebot.io/lib' export type SwitchWithLabelProps = { label: string - initialValue: boolean + initialValue: boolean | undefined moreInfoContent?: string onCheckChange?: (isChecked: boolean) => void justifyContent?: FormControlProps['justifyContent'] diff --git a/apps/builder/src/components/logos/StripeLogo.tsx b/apps/builder/src/components/logos/StripeLogo.tsx new file mode 100644 index 000000000..880e69fa3 --- /dev/null +++ b/apps/builder/src/components/logos/StripeLogo.tsx @@ -0,0 +1,16 @@ +import { Icon, IconProps } from '@chakra-ui/react' + +export const StripeLogo = (props: IconProps) => { + return ( + + + + + ) +} diff --git a/apps/builder/src/features/blocks/inputs/payment/components/StripeConfigModal.tsx b/apps/builder/src/features/blocks/inputs/payment/components/StripeConfigModal.tsx index 14931e119..b283f8ea6 100644 --- a/apps/builder/src/features/blocks/inputs/payment/components/StripeConfigModal.tsx +++ b/apps/builder/src/features/blocks/inputs/payment/components/StripeConfigModal.tsx @@ -36,6 +36,21 @@ export const StripeConfigModal = ({ onNewCredentials, onClose, }: Props) => { + return ( + + + + + ) +} + +export const StripeCreateModalContent = ({ + onNewCredentials, + onClose, +}: Pick) => { const { t } = useTranslate() const { user } = useUser() const { workspace } = useWorkspace() @@ -99,7 +114,8 @@ export const StripeConfigModal = ({ test: { ...stripeConfig.test, secretKey }, }) - const createCredentials = async () => { + const createCredentials = async (e: React.FormEvent) => { + e.preventDefault() if (!user?.email || !workspace?.id) return mutate({ credentials: { @@ -120,16 +136,16 @@ export const StripeConfigModal = ({ }, }) } + return ( - - - - - {t('blocks.inputs.payment.settings.stripeConfig.title.label')} - - + + + {t('blocks.inputs.payment.settings.stripeConfig.title.label')} + + +
- + + + +
+ ) +} diff --git a/apps/builder/src/features/blocks/inputs/payment/payment.spec.ts b/apps/builder/src/features/blocks/inputs/payment/payment.spec.ts index 4b3a3a41c..49a8cc2a7 100644 --- a/apps/builder/src/features/blocks/inputs/payment/payment.spec.ts +++ b/apps/builder/src/features/blocks/inputs/payment/payment.spec.ts @@ -20,7 +20,8 @@ test.describe('Payment input block', () => { await page.goto(`/typebots/${typebotId}/edit`) await page.click('text=Configure...') - await page.getByRole('button', { name: 'Add Stripe account' }).click() + await page.getByRole('button', { name: 'Select Stripe account' }).click() + await page.getByRole('menuitem', { name: 'Connect new' }).click() await page.fill('[placeholder="Typebot"]', 'My Stripe Account') await page.fill('[placeholder="sk_test_..."]', env.STRIPE_SECRET_KEY ?? '') await page.fill('[placeholder="sk_live_..."]', env.STRIPE_SECRET_KEY ?? '') diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/components/GoogleSheetsConnectModal.tsx b/apps/builder/src/features/blocks/integrations/googleSheets/components/GoogleSheetsConnectModal.tsx index 606a636d5..35d790510 100644 --- a/apps/builder/src/features/blocks/integrations/googleSheets/components/GoogleSheetsConnectModal.tsx +++ b/apps/builder/src/features/blocks/integrations/googleSheets/components/GoogleSheetsConnectModal.tsx @@ -21,8 +21,8 @@ import { getGoogleSheetsConsentScreenUrlQuery } from '../queries/getGoogleSheets type Props = { isOpen: boolean - typebotId: string - blockId: string + typebotId?: string + blockId?: string onClose: () => void } @@ -32,30 +32,45 @@ export const GoogleSheetConnectModal = ({ isOpen, onClose, }: Props) => { - const { workspace } = useWorkspace() return ( - - Connect Spreadsheets - - - - Make sure to check all the permissions so that the integration works - as expected: - - Google Spreadsheets checkboxes - - 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. - - + + + ) +} + +export const GoogleSheetConnectModalContent = ({ + typebotId, + blockId, +}: { + typebotId?: string + blockId?: string +}) => { + const { workspace } = useWorkspace() + + return ( + + Connect Spreadsheets + + + + Make sure to check all the permissions so that the integration works + as expected: + + Google Spreadsheets checkboxes + + 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. + + + {workspace?.id && ( - - - - - -
+ )} + + + + ) } diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/components/GoogleSheetsLogo.tsx b/apps/builder/src/features/blocks/integrations/googleSheets/components/GoogleSheetsLogo.tsx index 65eda1a21..db05f2ff1 100644 --- a/apps/builder/src/features/blocks/integrations/googleSheets/components/GoogleSheetsLogo.tsx +++ b/apps/builder/src/features/blocks/integrations/googleSheets/components/GoogleSheetsLogo.tsx @@ -5,44 +5,22 @@ export const GoogleSheetsLogo = (props: IconProps) => ( Sheets-icon Created with Sketch. - - - + + + - - - - + + + + ( fy="2.71744318%" r="161.248516%" gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)" - id="radialGradient-16" > - - - - - - - - + + + + + + + + - + - - + + - + - - + + - + - - + + - - + + - - + + - + - - + + - + - - + + - + ( diff --git a/apps/builder/src/features/blocks/integrations/googleSheets/queries/getGoogleSheetsConsentScreenUrlQuery.ts b/apps/builder/src/features/blocks/integrations/googleSheets/queries/getGoogleSheetsConsentScreenUrlQuery.ts index fdcfcc480..39991c172 100644 --- a/apps/builder/src/features/blocks/integrations/googleSheets/queries/getGoogleSheetsConsentScreenUrlQuery.ts +++ b/apps/builder/src/features/blocks/integrations/googleSheets/queries/getGoogleSheetsConsentScreenUrlQuery.ts @@ -2,8 +2,8 @@ import { stringify } from 'qs' export const getGoogleSheetsConsentScreenUrlQuery = ( redirectUrl: string, - blockId: string, - workspaceId?: string, + workspaceId: string, + blockId?: string, typebotId?: string ) => { const queryParams = stringify({ diff --git a/apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpConfigForm.tsx b/apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpConfigForm.tsx index c117025df..ec5ed986a 100644 --- a/apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpConfigForm.tsx +++ b/apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpConfigForm.tsx @@ -6,79 +6,93 @@ import { SmtpCredentials } from '@typebot.io/schemas' import React from 'react' type Props = { - config: SmtpCredentials['data'] + config: SmtpCredentials['data'] | undefined onConfigChange: (config: SmtpCredentials['data']) => void } export const SmtpConfigForm = ({ config, onConfigChange }: Props) => { const handleFromEmailChange = (email: string) => - onConfigChange({ ...config, from: { ...config.from, email } }) + config && onConfigChange({ ...config, from: { ...config.from, email } }) + const handleFromNameChange = (name: string) => - onConfigChange({ ...config, from: { ...config.from, name } }) - const handleHostChange = (host: string) => onConfigChange({ ...config, host }) + config && onConfigChange({ ...config, from: { ...config.from, name } }) + + const handleHostChange = (host: string) => + config && onConfigChange({ ...config, host }) + const handleUsernameChange = (username: string) => - onConfigChange({ ...config, username }) + config && onConfigChange({ ...config, username }) + const handlePasswordChange = (password: string) => - onConfigChange({ ...config, password }) + config && onConfigChange({ ...config, password }) + const handleTlsCheck = (isTlsEnabled: boolean) => - onConfigChange({ ...config, isTlsEnabled }) + config && onConfigChange({ ...config, isTlsEnabled }) + const handlePortNumberChange = (port?: number) => - isDefined(port) && onConfigChange({ ...config, port }) + config && isDefined(port) && onConfigChange({ ...config, port }) return ( - + ) diff --git a/apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpConfigModal.tsx b/apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpConfigModal.tsx index 48d3b8e69..b1fad022e 100644 --- a/apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpConfigModal.tsx +++ b/apps/builder/src/features/blocks/integrations/sendEmail/components/SmtpConfigModal.tsx @@ -26,9 +26,25 @@ type Props = { export const SmtpConfigModal = ({ isOpen, - onNewCredentials, onClose, + onNewCredentials, }: Props) => { + return ( + + + { + onNewCredentials(id) + onClose() + }} + /> + + ) +} + +export const SmtpCreateModalContent = ({ + onNewCredentials, +}: Pick) => { const { user } = useUser() const { workspace } = useWorkspace() const [isCreating, setIsCreating] = useState(false) @@ -53,11 +69,11 @@ export const SmtpConfigModal = ({ onSuccess: (data) => { refetchCredentials() onNewCredentials(data.credentialsId) - onClose() }, }) - const handleCreateClick = async () => { + const handleCreateClick = async (e: React.FormEvent) => { + e.preventDefault() if (!user?.email || !workspace?.id) return setIsCreating(true) const { error: testSmtpError } = await testSmtpConfig( @@ -82,19 +98,18 @@ export const SmtpConfigModal = ({ }) } return ( - - - - Create SMTP config - + + Create SMTP config + +
+
+ +
+ ) +} diff --git a/apps/builder/src/features/blocks/integrations/webhook/webhook.spec.ts b/apps/builder/src/features/blocks/integrations/webhook/webhook.spec.ts index fb8f8bc6e..ca28f83a4 100644 --- a/apps/builder/src/features/blocks/integrations/webhook/webhook.spec.ts +++ b/apps/builder/src/features/blocks/integrations/webhook/webhook.spec.ts @@ -1,98 +1,77 @@ import test, { expect, Page } from '@playwright/test' -import { - createWebhook, - importTypebotInDatabase, -} from '@typebot.io/playwright/databaseActions' +import { importTypebotInDatabase } from '@typebot.io/playwright/databaseActions' import { createId } from '@paralleldrive/cuid2' import { getTestAsset } from '@/test/utils/playwright' import { apiToken } from '@typebot.io/playwright/databaseSetup' import { env } from '@typebot.io/env' -import { HttpMethod } from '@typebot.io/schemas/features/blocks/integrations/webhook/constants' +import { omit } from '@typebot.io/lib/utils' -test.describe('Builder', () => { - test('easy configuration should work', async ({ page }) => { - const typebotId = createId() - await importTypebotInDatabase( - getTestAsset('typebots/integrations/easyConfigWebhook.json'), - { - id: typebotId, - } - ) - await createWebhook(typebotId, { method: HttpMethod.POST }) - await page.goto(`/typebots/${typebotId}/edit`) - await page.click('text=Configure...') - await page.fill( - 'input[placeholder="Paste URL..."]', - `${env.NEXTAUTH_URL}/api/mock/webhook-easy-config` - ) - await page.click('text=Test the request') - await expect(page.locator('div[role="textbox"] >> nth=-1')).toContainText( - `"Group #1": "answer value", "Group #2": "20", "Group #2 (1)": "Yes"`, - { timeout: 10000 } - ) - }) +test.describe.configure({ mode: 'parallel' }) - test('its configuration should work', async ({ page }) => { - const typebotId = createId() - await importTypebotInDatabase( - getTestAsset('typebots/integrations/webhook.json'), - { - id: typebotId, - } - ) - await createWebhook(typebotId) +test('editor configuration should work', async ({ page }) => { + const typebotId = createId() + await importTypebotInDatabase( + getTestAsset('typebots/integrations/webhook.json'), + { + id: typebotId, + } + ) - await page.goto(`/typebots/${typebotId}/edit`) - await page.click('text=Configure...') - await page.fill( - 'input[placeholder="Paste URL..."]', - `${env.NEXTAUTH_URL}/api/mock/webhook` - ) - await page.click('text=Advanced configuration') - await page.getByRole('button', { name: 'GET' }).click() - await page.click('text=POST') + await page.goto(`/typebots/${typebotId}/edit`) + await page.click('text=Configure...') + await page.fill( + 'input[placeholder="Paste URL..."]', + `${env.NEXTAUTH_URL}/api/mock/webhook-easy-config` + ) + await page.click('text=Test the request') + await expect(page.locator('div[role="textbox"] >> nth=-1')).toContainText( + `"Group #1": "Go", "secret 1": "content"`, + { timeout: 10000 } + ) - await page.click('text=Query params') - await page.click('text=Add a param') - await page.fill('input[placeholder="e.g. email"]', 'firstParam') - await page.fill('input[placeholder="e.g. {{Email}}"]', '{{secret 1}}') + await page.fill( + 'input[placeholder="Paste URL..."]', + `${env.NEXTAUTH_URL}/api/mock/webhook` + ) + await page.click('text=Advanced configuration') - await page.click('text=Add a param') - await page.fill('input[placeholder="e.g. email"] >> nth=1', 'secondParam') - await page.fill( - 'input[placeholder="e.g. {{Email}}"] >> nth=1', - '{{secret 2}}' - ) + await page.click('text=Query params') + await page.click('text=Add a param') + await page.fill('input[placeholder="e.g. email"]', 'firstParam') + await page.fill('input[placeholder="e.g. {{Email}}"]', '{{secret 1}}') - await page.click('text=Headers') - await page.waitForTimeout(200) - await page.getByRole('button', { name: 'Add a value' }).click() - await page.fill('input[placeholder="e.g. Content-Type"]', 'Custom-Typebot') - await page.fill( - 'input[placeholder="e.g. application/json"]', - '{{secret 3}}' - ) + await page.click('text=Add a param') + await page.fill('input[placeholder="e.g. email"] >> nth=1', 'secondParam') + await page.fill( + 'input[placeholder="e.g. {{Email}}"] >> nth=1', + '{{secret 2}}' + ) - await page.click('text=Body') - await page.click('text=Custom body') - await page.fill('div[role="textbox"]', '{ "customField": "{{secret 4}}" }') + await page.click('text=Headers') + await page.waitForTimeout(200) + await page.getByRole('button', { name: 'Add a value' }).click() + await page.fill('input[placeholder="e.g. Content-Type"]', 'Custom-Typebot') + await page.fill('input[placeholder="e.g. application/json"]', '{{secret 3}}') - await page.click('text=Variable values for test') - await addTestVariable(page, 'secret 1', 'secret1') - await addTestVariable(page, 'secret 2', 'secret2') - await addTestVariable(page, 'secret 3', 'secret3') - await addTestVariable(page, 'secret 4', 'secret4') + await page.click('text=Body') + await page.click('text=Custom body') + await page.fill('div[role="textbox"]', '{ "customField": "{{secret 4}}" }') - await page.click('text=Test the request') - await expect(page.locator('div[role="textbox"] >> nth=-1')).toContainText( - '"statusCode": 200' - ) + await page.click('text=Variable values for test') + await addTestVariable(page, 'secret 1', 'secret1') + await addTestVariable(page, 'secret 2', 'secret2') + await addTestVariable(page, 'secret 3', 'secret3') + await addTestVariable(page, 'secret 4', 'secret4') - await page.click('text=Save in variables') - await page.click('text=Add an entry >> nth=-1') - await page.click('input[placeholder="Select the data"]') - await page.click('text=data.flatMap(item => item.name)') - }) + await page.click('text=Test the request') + await expect(page.locator('div[role="textbox"] >> nth=-1')).toContainText( + '"statusCode": 200' + ) + + await page.click('text=Save in variables') + await page.click('text=Add an entry >> nth=-1') + await page.click('input[placeholder="Select the data"]') + await page.click('text=data.flatMap(item => item.name)') }) const addTestVariable = async (page: Page, name: string, value: string) => { @@ -102,83 +81,70 @@ const addTestVariable = async (page: Page, name: string, value: string) => { await page.fill('input >> nth=-1', value) } -test.describe('API', () => { - const typebotId = 'webhook-flow' +test('Webhook API endpoints should work', async ({ request }) => { + const typebotId = createId() + await importTypebotInDatabase(getTestAsset('typebots/api.json'), { + id: typebotId, + }) - test.beforeAll(async () => { - try { - await importTypebotInDatabase(getTestAsset('typebots/api.json'), { - id: typebotId, - }) - await createWebhook(typebotId) - } catch (err) { - console.log(err) + // GET webhook blocks + const getResponse = await request.get( + `/api/v1/typebots/${typebotId}/webhookBlocks`, + { + headers: { Authorization: `Bearer ${apiToken}` }, } + ) + const { webhookBlocks } = await getResponse.json() + expect(webhookBlocks).toHaveLength(1) + expect(webhookBlocks[0]).toEqual({ + id: 'webhookBlock', + label: 'Webhook > webhookBlock', + type: 'Webhook', }) - test('can get webhook blocks', async ({ request }) => { - const response = await request.get( - `/api/v1/typebots/${typebotId}/webhookBlocks`, - { - headers: { Authorization: `Bearer ${apiToken}` }, - } - ) - const { webhookBlocks } = await response.json() - expect(webhookBlocks).toHaveLength(1) - expect(webhookBlocks[0]).toEqual({ - id: 'webhookBlock', - label: 'Webhook > webhookBlock', - type: 'Webhook', - }) + // Subscribe webhook + const url = 'https://test.com' + const subscribeResponse = await request.post( + `/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/subscribe`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + }, + data: { url }, + } + ) + expect(await subscribeResponse.json()).toEqual({ + id: 'webhookBlock', + url, }) - test('can subscribe webhook', async ({ request }) => { - const url = 'https://test.com' - const response = await request.post( - `/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/subscribe`, - { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - data: { url }, - } - ) - const body = await response.json() - expect(body).toEqual({ - id: 'webhookBlock', - url, - }) + // Unsubscribe webhook + const unsubResponse = await request.post( + `/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/unsubscribe`, + { + headers: { Authorization: `Bearer ${apiToken}` }, + } + ) + expect(await unsubResponse.json()).toEqual({ + id: 'webhookBlock', + url: null, }) - test('can unsubscribe webhook', async ({ request }) => { - const response = await request.post( - `/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/unsubscribe`, - { - headers: { Authorization: `Bearer ${apiToken}` }, - } - ) - const body = await response.json() - expect(body).toEqual({ - id: 'webhookBlock', - url: null, - }) - }) + // Get sample result + const sampleResponse = await request.get( + `/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/getResultExample`, + { + headers: { Authorization: `Bearer ${apiToken}` }, + } + ) + const sample = await sampleResponse.json() - test('can get a sample result', async ({ request }) => { - const response = await request.get( - `/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/getResultExample`, - { - headers: { Authorization: `Bearer ${apiToken}` }, - } - ) - const data = await response.json() - expect(data.resultExample).toMatchObject({ - message: 'This is a sample result, it has been generated ⬇️', - Welcome: 'Hi!', - Email: 'user@email.com', - Name: 'answer value', - Services: 'Website dev, Content Marketing, Social Media, UI / UX Design', - 'Additional information': 'answer value', - }) + expect(omit(sample.resultExample, 'submittedAt')).toMatchObject({ + message: 'This is a sample result, it has been generated ⬇️', + Welcome: 'Hi!', + Email: 'user@email.com', + Name: 'answer value', + Services: 'Website dev, Content Marketing, Social Media, UI / UX Design', + 'Additional information': 'answer value', }) }) diff --git a/apps/builder/src/features/blocks/integrations/zemanticAi/ZemanticAiCredentialsModal.tsx b/apps/builder/src/features/blocks/integrations/zemanticAi/ZemanticAiCredentialsModal.tsx deleted file mode 100644 index 60c14dcc1..000000000 --- a/apps/builder/src/features/blocks/integrations/zemanticAi/ZemanticAiCredentialsModal.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { TextInput } from '@/components/inputs/TextInput' -import { TextLink } from '@/components/TextLink' -import { useWorkspace } from '@/features/workspace/WorkspaceProvider' -import { useToast } from '@/hooks/useToast' -import { trpc } from '@/lib/trpc' -import { - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalCloseButton, - ModalBody, - Stack, - ModalFooter, - Button, -} from '@chakra-ui/react' -import React, { useState } from 'react' - -const zemanticAIDashboardPage = 'https://zemantic.ai/dashboard/settings' - -type Props = { - isOpen: boolean - onClose: () => void - onNewCredentials: (id: string) => void -} - -export const ZemanticAiCredentialsModal = ({ - isOpen, - onClose, - onNewCredentials, -}: Props) => { - const { workspace } = useWorkspace() - const { showToast } = useToast() - const [apiKey, setApiKey] = useState('') - const [name, setName] = useState('') - - const [isCreating, setIsCreating] = useState(false) - - const { - credentials: { - listCredentials: { refetch: refetchCredentials }, - }, - } = trpc.useContext() - const { mutate } = trpc.credentials.createCredentials.useMutation({ - onMutate: () => setIsCreating(true), - onSettled: () => setIsCreating(false), - onError: (err) => { - showToast({ - description: err.message, - status: 'error', - }) - }, - onSuccess: (data) => { - refetchCredentials() - onNewCredentials(data.credentialsId) - onClose() - }, - }) - - const createZemanticAiCredentials = async (e: React.FormEvent) => { - e.preventDefault() - if (!workspace) return - mutate({ - credentials: { - type: 'zemanticAi', - workspaceId: workspace.id, - name, - data: { - apiKey, - }, - }, - }) - } - - return ( - - - - Add Zemantic AI account - -
- - - - You can generate an API key{' '} - - here - - . - - } - onChange={setApiKey} - placeholder="ze..." - withVariableButton={false} - debounceTimeout={0} - /> - - - - - -
-
-
- ) -} diff --git a/apps/builder/src/features/blocks/integrations/zemanticAi/ZemanticAiSettings.tsx b/apps/builder/src/features/blocks/integrations/zemanticAi/ZemanticAiSettings.tsx index c6442ecfa..47695d832 100644 --- a/apps/builder/src/features/blocks/integrations/zemanticAi/ZemanticAiSettings.tsx +++ b/apps/builder/src/features/blocks/integrations/zemanticAi/ZemanticAiSettings.tsx @@ -1,6 +1,4 @@ import { TextInput, Textarea, NumberInput } from '@/components/inputs' -import { CredentialsDropdown } from '@/features/credentials/components/CredentialsDropdown' -import { useWorkspace } from '@/features/workspace/WorkspaceProvider' import { Accordion, AccordionButton, @@ -9,15 +7,12 @@ import { AccordionPanel, Stack, Text, - useDisclosure, } from '@chakra-ui/react' import { isEmpty } from '@typebot.io/lib' import { ZemanticAiBlock } from '@typebot.io/schemas' -import { ZemanticAiCredentialsModal } from './ZemanticAiCredentialsModal' import { ProjectsDropdown } from './ProjectsDropdown' import { SearchResponseItem } from './SearchResponseItem' import { TableList } from '@/components/TableList' -import { createId } from '@paralleldrive/cuid2' type Props = { block: ZemanticAiBlock @@ -28,22 +23,6 @@ export const ZemanticAiSettings = ({ block: { id: blockId, options }, onOptionsChange, }: Props) => { - const { workspace } = useWorkspace() - const { isOpen, onOpen, onClose } = useDisclosure() - - const updateCredentialsId = (credentialsId: string | undefined) => { - onOptionsChange({ - ...options, - credentialsId, - responseMapping: [ - { - id: createId(), - valueToExtract: 'Summary', - }, - ], - }) - } - const updateProjectId = (projectId: string | undefined) => { onOptionsChange({ ...options, @@ -92,23 +71,6 @@ export const ZemanticAiSettings = ({ return ( - {workspace && ( - <> - - - - )} {options?.credentialsId && ( <> + i.pick(inputShape) + ), ]) .and(z.object({ id: z.string().cuid2().optional() })), }) @@ -53,7 +53,12 @@ export const createCredentials = authenticatedProcedure }) ) .mutation(async ({ input: { credentials }, ctx: { user } }) => { - if (await isNotAvailable(credentials.name, credentials.type)) + if ( + await isNotAvailable( + credentials.name, + credentials.type as Credentials['type'] + ) + ) throw new TRPCError({ code: 'CONFLICT', message: 'Credentials already exist.', @@ -62,7 +67,7 @@ export const createCredentials = authenticatedProcedure where: { id: credentials.workspaceId, }, - select: { id: true, members: true }, + select: { id: true, members: { select: { userId: true, role: true } } }, }) if (!workspace || isWriteWorkspaceForbidden(workspace, user)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' }) diff --git a/apps/builder/src/features/credentials/api/deleteCredentials.ts b/apps/builder/src/features/credentials/api/deleteCredentials.ts index 7d542a9fe..71e0808bb 100644 --- a/apps/builder/src/features/credentials/api/deleteCredentials.ts +++ b/apps/builder/src/features/credentials/api/deleteCredentials.ts @@ -30,11 +30,8 @@ export const deleteCredentials = authenticatedProcedure const workspace = await prisma.workspace.findFirst({ where: { id: workspaceId, - members: { - some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } }, - }, }, - select: { id: true, members: true }, + select: { id: true, members: { select: { userId: true, role: true } } }, }) if (!workspace || isWriteWorkspaceForbidden(workspace, user)) throw new TRPCError({ diff --git a/apps/builder/src/features/credentials/api/getCredentials.ts b/apps/builder/src/features/credentials/api/getCredentials.ts new file mode 100644 index 000000000..c40a62cef --- /dev/null +++ b/apps/builder/src/features/credentials/api/getCredentials.ts @@ -0,0 +1,66 @@ +import prisma from '@typebot.io/lib/prisma' +import { authenticatedProcedure } from '@/helpers/server/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden' +import { decrypt } from '@typebot.io/lib/api/encryption/decrypt' + +export const getCredentials = authenticatedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/v1/credentials/{credentialsId}', + protect: true, + summary: 'Get credentials data', + tags: ['Credentials'], + }, + }) + .input( + z.object({ + workspaceId: z.string(), + credentialsId: z.string(), + }) + ) + .output( + z.object({ + name: z.string(), + data: z.any(), + }) + ) + .query(async ({ input: { workspaceId, credentialsId }, ctx: { user } }) => { + const workspace = await prisma.workspace.findFirst({ + where: { + id: workspaceId, + }, + select: { + id: true, + members: true, + }, + }) + if (!workspace || isWriteWorkspaceForbidden(workspace, user)) + throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' }) + + const credentials = await prisma.credentials.findFirst({ + where: { + id: credentialsId, + }, + select: { + data: true, + iv: true, + name: true, + }, + }) + + if (!credentials) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Credentials not found', + }) + + const credentialsData = await decrypt(credentials.data, credentials.iv) + + return { + name: credentials.name, + data: credentialsData, + } + }) diff --git a/apps/builder/src/features/credentials/api/listCredentials.ts b/apps/builder/src/features/credentials/api/listCredentials.ts index 9db21aa3e..2b11d84d4 100644 --- a/apps/builder/src/features/credentials/api/listCredentials.ts +++ b/apps/builder/src/features/credentials/api/listCredentials.ts @@ -1,16 +1,18 @@ import prisma from '@typebot.io/lib/prisma' import { authenticatedProcedure } from '@/helpers/server/trpc' import { TRPCError } from '@trpc/server' -import { openAICredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/openai' -import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/sendEmail' import { z } from 'zod' import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' -import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp' -import { zemanticAiCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/zemanticAi' -import { - googleSheetsCredentialsSchema, - stripeCredentialsSchema, -} from '@typebot.io/schemas' +import { credentialsTypeSchema } from '@typebot.io/schemas' +import { isDefined } from '@udecode/plate-common' + +const outputCredentialsSchema = z.array( + z.object({ + id: z.string(), + type: credentialsTypeSchema, + name: z.string(), + }) +) export const listCredentials = authenticatedProcedure .meta({ @@ -25,17 +27,12 @@ export const listCredentials = authenticatedProcedure .input( z.object({ workspaceId: z.string(), - type: stripeCredentialsSchema.shape.type - .or(smtpCredentialsSchema.shape.type) - .or(googleSheetsCredentialsSchema.shape.type) - .or(openAICredentialsSchema.shape.type) - .or(whatsAppCredentialsSchema.shape.type) - .or(zemanticAiCredentialsSchema.shape.type), + type: credentialsTypeSchema.optional(), }) ) .output( z.object({ - credentials: z.array(z.object({ id: z.string(), name: z.string() })), + credentials: outputCredentialsSchema, }) ) .query(async ({ input: { workspaceId, type }, ctx: { user } }) => { @@ -52,6 +49,7 @@ export const listCredentials = authenticatedProcedure }, select: { id: true, + type: true, name: true, }, }, @@ -60,5 +58,11 @@ export const listCredentials = authenticatedProcedure if (!workspace || isReadWorkspaceFobidden(workspace, user)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' }) - return { credentials: workspace.credentials } + return { + credentials: outputCredentialsSchema.parse( + isDefined(type) + ? workspace.credentials + : workspace.credentials.sort((a, b) => a.type.localeCompare(b.type)) + ), + } }) diff --git a/apps/builder/src/features/credentials/api/router.ts b/apps/builder/src/features/credentials/api/router.ts index e52fced93..d1f90d09a 100644 --- a/apps/builder/src/features/credentials/api/router.ts +++ b/apps/builder/src/features/credentials/api/router.ts @@ -2,9 +2,13 @@ import { router } from '@/helpers/server/trpc' import { createCredentials } from './createCredentials' import { deleteCredentials } from './deleteCredentials' import { listCredentials } from './listCredentials' +import { updateCredentials } from './updateCredentials' +import { getCredentials } from './getCredentials' export const credentialsRouter = router({ createCredentials, listCredentials, + getCredentials, deleteCredentials, + updateCredentials, }) diff --git a/apps/builder/src/features/credentials/api/updateCredentials.ts b/apps/builder/src/features/credentials/api/updateCredentials.ts new file mode 100644 index 000000000..6ddf18fa5 --- /dev/null +++ b/apps/builder/src/features/credentials/api/updateCredentials.ts @@ -0,0 +1,81 @@ +import prisma from '@typebot.io/lib/prisma' +import { authenticatedProcedure } from '@/helpers/server/trpc' +import { TRPCError } from '@trpc/server' +import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/sendEmail' +import { encrypt } from '@typebot.io/lib/api/encryption/encrypt' +import { z } from 'zod' +import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp' +import { + googleSheetsCredentialsSchema, + stripeCredentialsSchema, +} from '@typebot.io/schemas' +import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden' +import { forgedCredentialsSchemas } from '../../../../../../packages/forge/repository/credentials' + +const inputShape = { + name: true, + data: true, + type: true, + workspaceId: true, +} as const + +export const updateCredentials = authenticatedProcedure + .meta({ + openapi: { + method: 'PATCH', + path: '/v1/credentials/{credentialsId}', + protect: true, + summary: 'Create credentials', + tags: ['Credentials'], + }, + }) + .input( + z.object({ + credentialsId: z.string(), + credentials: z.discriminatedUnion('type', [ + stripeCredentialsSchema.pick(inputShape), + smtpCredentialsSchema.pick(inputShape), + googleSheetsCredentialsSchema.pick(inputShape), + whatsAppCredentialsSchema.pick(inputShape), + ...Object.values(forgedCredentialsSchemas).map((i) => + i.pick(inputShape) + ), + ]), + }) + ) + .output( + z.object({ + credentialsId: z.string(), + }) + ) + .mutation( + async ({ input: { credentialsId, credentials }, ctx: { user } }) => { + const workspace = await prisma.workspace.findFirst({ + where: { + id: credentials.workspaceId, + }, + select: { + id: true, + members: true, + }, + }) + if (!workspace || isWriteWorkspaceForbidden(workspace, user)) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Workspace not found', + }) + + const { encryptedData, iv } = await encrypt(credentials.data) + const createdCredentials = await prisma.credentials.update({ + where: { + id: credentialsId, + }, + data: { + name: credentials.name, + data: encryptedData, + iv, + }, + }) + return { credentialsId: createdCredentials.id } + } + ) diff --git a/apps/builder/src/features/credentials/components/CredentialsCreateModal.tsx b/apps/builder/src/features/credentials/components/CredentialsCreateModal.tsx new file mode 100644 index 000000000..41e19e0d4 --- /dev/null +++ b/apps/builder/src/features/credentials/components/CredentialsCreateModal.tsx @@ -0,0 +1,82 @@ +import { StripeCreateModalContent } from '@/features/blocks/inputs/payment/components/StripeConfigModal' +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 { WhatsAppCreateModalContent } from '@/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppCredentialsModal' +import { Modal, ModalOverlay } from '@chakra-ui/react' +import { forgedBlocks } from '@typebot.io/forge-repository/definitions' +import { Credentials } from '@typebot.io/schemas/features/credentials' + +export const CredentialsCreateModal = ({ + creatingType, + onSubmit, + onClose, +}: { + creatingType?: Credentials['type'] + onClose: () => void + onSubmit: () => void +}) => { + return ( + + + {creatingType && ( + + )} + + ) +} + +const CredentialsCreateModalContent = ({ + type, + onSubmit, + onClose, +}: { + type: Credentials['type'] + onClose: () => void + onSubmit: () => void +}) => { + switch (type) { + case 'google sheets': + return + case 'smtp': + return + case 'stripe': + return ( + + ) + case 'whatsApp': + return ( + + ) + default: + return ( + + ) + } +} + +const parseModalSize = (type?: Credentials['type']) => { + switch (type) { + case 'whatsApp': + return '3xl' + default: + return 'lg' + } +} diff --git a/apps/builder/src/features/credentials/components/CredentialsSettingsForm.tsx b/apps/builder/src/features/credentials/components/CredentialsSettingsForm.tsx new file mode 100644 index 000000000..cc5804060 --- /dev/null +++ b/apps/builder/src/features/credentials/components/CredentialsSettingsForm.tsx @@ -0,0 +1,336 @@ +import { trpc } from '@/lib/trpc' +import { + Heading, + HStack, + Stack, + IconButton, + Divider, + Button, + Menu, + MenuButton, + MenuList, + MenuItem, + IconProps, + TextProps, + Popover, + PopoverTrigger, + PopoverFooter, + PopoverArrow, + PopoverBody, + PopoverContent, + Flex, + Skeleton, + SkeletonCircle, +} from '@chakra-ui/react' +import React, { useMemo, useRef, useState } from 'react' +import { Credentials, credentialsTypes } from '@typebot.io/schemas' +import { BlockIcon } from '@/features/editor/components/BlockIcon' +import { BlockLabel } from '@/features/editor/components/BlockLabel' +import { Text } from '@chakra-ui/react' +import { WhatsAppLogo } from '@/components/logos/WhatsAppLogo' +import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants' +import { StripeLogo } from '@/components/logos/StripeLogo' +import { EditIcon, PlusIcon, TrashIcon } from '@/components/icons' +import { useWorkspace } from '@/features/workspace/WorkspaceProvider' +import { toast } from 'sonner' +import { CredentialsCreateModal } from './CredentialsCreateModal' +import { CredentialsUpdateModal } from './CredentialsUpdateModal' + +const nonEditableTypes = ['whatsApp', 'google sheets'] as const + +type CredentialsInfo = Pick + +export const CredentialsSettingsForm = () => { + const [creatingType, setCreatingType] = useState() + const [editingCredentials, setEditingCredentials] = useState<{ + id: string + type: Credentials['type'] + }>() + const [deletingCredentialsId, setDeletingCredentialsId] = useState() + const { workspace } = useWorkspace() + const { data, isLoading, refetch } = + trpc.credentials.listCredentials.useQuery( + { + workspaceId: workspace!.id, + }, + { + enabled: !!workspace?.id, + } + ) + + const { mutate: deleteCredentials } = + trpc.credentials.deleteCredentials.useMutation({ + onMutate: ({ credentialsId }) => + setDeletingCredentialsId(credentialsId as string), + onSettled: () => { + setDeletingCredentialsId(undefined) + }, + onError: (error) => { + toast.error(error.message) + }, + onSuccess: () => { + refetch() + }, + }) + + const credentials = useMemo( + () => + data?.credentials ? groupCredentialsByType(data.credentials) : undefined, + [data?.credentials] + ) + + return ( + + { + refetch() + setCreatingType(undefined) + }} + onClose={() => setCreatingType(undefined)} + /> + { + refetch() + setEditingCredentials(undefined) + }} + onClose={() => setEditingCredentials(undefined)} + /> + + Credentials + + }> + Create new + + + {credentialsTypes.map((type) => ( + } + onClick={() => setCreatingType(type)} + > + + + ))} + + + + + {credentials && !isLoading ? ( + (Object.keys(credentials) as Credentials['type'][]).map((type) => ( + + + + + + + {credentials[type].map((cred) => ( + + + setEditingCredentials({ + id: cred.id, + type: cred.type, + }) + } + onDeleteClick={() => + deleteCredentials({ + workspaceId: workspace!.id, + credentialsId: cred.id, + }) + } + /> + + + ))} + + + )) + ) : ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ) +} + +const CredentialsIcon = ({ + type, + ...props +}: { type: Credentials['type'] } & IconProps) => { + switch (type) { + case 'google sheets': + return + case 'smtp': + return + case 'stripe': + return + case 'whatsApp': + return + default: + return + } +} + +const CredentialsLabel = ({ + type, + ...props +}: { type: Credentials['type'] } & TextProps) => { + switch (type) { + case 'google sheets': + return ( + + Google Sheets + + ) + case 'smtp': + return ( + + SMTP + + ) + case 'stripe': + return ( + + Stripe + + ) + case 'whatsApp': + return ( + + WhatsApp + + ) + default: + return + } +} + +const CredentialsItem = ({ + isDeleting, + onEditClick, + onDeleteClick, + ...cred +}: Pick & { + isDeleting: boolean + onEditClick?: () => void + onDeleteClick: () => void +}) => { + const initialFocusRef = useRef(null) + + return ( + + {cred.name} + + {onEditClick && ( + } + size="xs" + onClick={onEditClick} + /> + )} + + {({ onClose }) => ( + <> + + } + size="xs" + /> + + + + + + + Are you sure? + + + Make sure this credentials is not used in any of your + published bot before proceeding. + + + + + + + + + + + + )} + + + + ) +} + +const groupCredentialsByType = ( + credentials: CredentialsInfo[] +): Record => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const groupedCredentials: any = {} + credentials.forEach((cred) => { + if (!groupedCredentials[cred.type]) { + groupedCredentials[cred.type] = [] + } + groupedCredentials[cred.type].push(cred) + }) + return groupedCredentials +} diff --git a/apps/builder/src/features/credentials/components/CredentialsUpdateModal.tsx b/apps/builder/src/features/credentials/components/CredentialsUpdateModal.tsx new file mode 100644 index 000000000..c2e2d5d83 --- /dev/null +++ b/apps/builder/src/features/credentials/components/CredentialsUpdateModal.tsx @@ -0,0 +1,71 @@ +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 { Modal, ModalOverlay } from '@chakra-ui/react' +import { forgedBlocks } from '@typebot.io/forge-repository/definitions' +import { Credentials } from '@typebot.io/schemas/features/credentials' + +export const CredentialsUpdateModal = ({ + editingCredentials, + onSubmit, + onClose, +}: { + editingCredentials?: { + id: string + type: Credentials['type'] + } + onClose: () => void + onSubmit: () => void +}) => { + return ( + + + {editingCredentials && ( + + )} + + ) +} + +const CredentialsUpdateModalContent = ({ + editingCredentials, + onSubmit, +}: { + editingCredentials: { + id: string + type: Credentials['type'] + } + onSubmit: () => void +}) => { + switch (editingCredentials.type) { + case 'google sheets': + return null + case 'smtp': + return ( + + ) + case 'stripe': + return ( + + ) + case 'whatsApp': + return null + default: + return ( + + ) + } +} diff --git a/apps/builder/src/features/credentials/credentials.spec.ts b/apps/builder/src/features/credentials/credentials.spec.ts new file mode 100644 index 000000000..6ca113274 --- /dev/null +++ b/apps/builder/src/features/credentials/credentials.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test' + +test('should be able to create, update and delete credentials', async ({ + page, +}) => { + await page.goto('/typebots') + await page.click('text=Settings & Members') + await page.click('text=Credentials') + + // Create + await page.getByRole('button', { name: 'Create new' }).click() + await page.getByRole('menuitem', { name: 'OpenAI' }).click() + await page.getByPlaceholder('My account').fill('Typebot') + await page.getByPlaceholder('sk-').fill('sk-test') + await page.getByRole('button', { name: 'Create' }).click() + await expect(page.getByTestId('openai').getByText('Typebot')).toBeVisible() + + // Edit + await page.pause() + await page.getByTestId('openai').getByRole('button', { name: 'Edit' }).click() + await expect(page.getByPlaceholder('My account')).toHaveValue('Typebot') + await expect(page.getByPlaceholder('sk-')).toHaveValue('sk-test') + await page.getByPlaceholder('sk-').fill('sk-test-2') + await page.getByPlaceholder('My account').fill('Typebot 2') + await page.getByRole('button', { name: 'Update' }).click() + await expect(page.getByTestId('openai').getByText('Typebot 2')).toBeVisible() + + // Delete + await page + .getByTestId('openai') + .getByRole('button', { name: 'Delete' }) + .click() + await page + .getByTestId('openai') + .getByRole('button', { name: 'Delete' }) + .nth(1) + .click() + await expect(page.getByTestId('openai').getByText('Typebot 2')).toBeHidden() +}) diff --git a/apps/builder/src/features/dashboard/components/DashboardHeader.tsx b/apps/builder/src/features/dashboard/components/DashboardHeader.tsx index 6f21a88b3..21b62750d 100644 --- a/apps/builder/src/features/dashboard/components/DashboardHeader.tsx +++ b/apps/builder/src/features/dashboard/components/DashboardHeader.tsx @@ -10,13 +10,19 @@ import { useWorkspace } from '@/features/workspace/WorkspaceProvider' import { WorkspaceDropdown } from '@/features/workspace/components/WorkspaceDropdown' import { WorkspaceSettingsModal } from '@/features/workspace/components/WorkspaceSettingsModal' import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider' +import { useRouter } from 'next/router' export const DashboardHeader = () => { const { t } = useTranslate() const { user, logOut } = useUser() const { workspace, switchWorkspace, createWorkspace } = useWorkspace() + const { asPath } = useRouter() - const { isOpen, onOpen, onClose } = useDisclosure() + const isRedirectFromCredentialsCreation = asPath.includes('credentials') + + const { isOpen, onOpen, onClose } = useDisclosure({ + defaultIsOpen: isRedirectFromCredentialsCreation, + }) const handleCreateNewWorkspace = () => createWorkspace(user?.name ?? undefined) @@ -45,6 +51,9 @@ export const DashboardHeader = () => { onClose={onClose} user={user} workspace={workspace} + defaultTab={ + isRedirectFromCredentialsCreation ? 'credentials' : undefined + } /> )} diff --git a/apps/builder/src/features/editor/components/BlockIcon.tsx b/apps/builder/src/features/editor/components/BlockIcon.tsx index e9e3d9f56..ac82fe6e2 100644 --- a/apps/builder/src/features/editor/components/BlockIcon.tsx +++ b/apps/builder/src/features/editor/components/BlockIcon.tsx @@ -1,6 +1,5 @@ -import { useColorModeValue } from '@chakra-ui/react' +import { IconProps, useColorModeValue } from '@chakra-ui/react' import React from 'react' -import { FlagIcon, SendEmailIcon, ThunderIcon } from '@/components/icons' import { WaitIcon } from '@/features/blocks/logic/wait/components/WaitIcon' import { ScriptIcon } from '@/features/blocks/logic/script/components/ScriptIcon' import { JumpIcon } from '@/features/blocks/logic/jump/components/JumpIcon' @@ -40,10 +39,12 @@ import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/consta import { Block } from '@typebot.io/schemas' import { OpenAILogo } from '@/features/blocks/integrations/openai/components/OpenAILogo' import { ForgedBlockIcon } from '@/features/forge/ForgedBlockIcon' +import { SendEmailIcon } from '@/features/blocks/integrations/sendEmail/components/SendEmailIcon' +import { FlagIcon, ThunderIcon } from '@/components/icons' -type BlockIconProps = { type: Block['type']; mt?: string } +type BlockIconProps = { type: Block['type'] } & IconProps -export const BlockIcon = ({ type, mt }: BlockIconProps): JSX.Element => { +export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => { const blue = useColorModeValue('blue.500', 'blue.300') const orange = useColorModeValue('orange.500', 'orange.300') const purple = useColorModeValue('purple.500', 'purple.300') @@ -51,78 +52,78 @@ export const BlockIcon = ({ type, mt }: BlockIconProps): JSX.Element => { switch (type) { case BubbleBlockType.TEXT: - return + return case BubbleBlockType.IMAGE: - return + return case BubbleBlockType.VIDEO: - return + return case BubbleBlockType.EMBED: - return + return case BubbleBlockType.AUDIO: - return + return case InputBlockType.TEXT: - return + return case InputBlockType.NUMBER: - return + return case InputBlockType.EMAIL: - return + return case InputBlockType.URL: - return + return case InputBlockType.DATE: - return + return case InputBlockType.PHONE: - return + return case InputBlockType.CHOICE: - return + return case InputBlockType.PICTURE_CHOICE: - return + return case InputBlockType.PAYMENT: - return + return case InputBlockType.RATING: - return + return case InputBlockType.FILE: - return + return case LogicBlockType.SET_VARIABLE: - return + return case LogicBlockType.CONDITION: - return + return case LogicBlockType.REDIRECT: - return + return case LogicBlockType.SCRIPT: - return + return case LogicBlockType.WAIT: - return + return case LogicBlockType.JUMP: - return + return case LogicBlockType.TYPEBOT_LINK: - return + return case LogicBlockType.AB_TEST: - return + return case IntegrationBlockType.GOOGLE_SHEETS: - return + return case IntegrationBlockType.GOOGLE_ANALYTICS: - return + return case IntegrationBlockType.WEBHOOK: - return + return case IntegrationBlockType.ZAPIER: - return + return case IntegrationBlockType.MAKE_COM: - return + return case IntegrationBlockType.PABBLY_CONNECT: - return + return case IntegrationBlockType.EMAIL: - return + return case IntegrationBlockType.CHATWOOT: - return + return case IntegrationBlockType.PIXEL: - return + return case IntegrationBlockType.ZEMANTIC_AI: - return + return case 'start': - return + return case IntegrationBlockType.OPEN_AI: - return + return default: - return + return } } diff --git a/apps/builder/src/features/editor/components/BlockLabel.tsx b/apps/builder/src/features/editor/components/BlockLabel.tsx index 97a3c0564..b648e9045 100644 --- a/apps/builder/src/features/editor/components/BlockLabel.tsx +++ b/apps/builder/src/features/editor/components/BlockLabel.tsx @@ -1,4 +1,4 @@ -import { Text } from '@chakra-ui/react' +import { Text, TextProps } from '@chakra-ui/react' import React from 'react' import { useTranslate } from '@tolgee/react' import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants' @@ -8,98 +8,224 @@ import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/consta import { Block } from '@typebot.io/schemas' import { ForgedBlockLabel } from '@/features/forge/ForgedBlockLabel' -type Props = { type: Block['type'] } +type Props = { type: Block['type'] } & TextProps -export const BlockLabel = ({ type }: Props): JSX.Element => { +export const BlockLabel = ({ type, ...props }: Props): JSX.Element => { const { t } = useTranslate() switch (type) { case 'start': - return {t('editor.sidebarBlock.start.label')} + return ( + + {t('editor.sidebarBlock.start.label')} + + ) case BubbleBlockType.TEXT: case InputBlockType.TEXT: - return {t('editor.sidebarBlock.text.label')} + return ( + + {t('editor.sidebarBlock.text.label')} + + ) case BubbleBlockType.IMAGE: - return {t('editor.sidebarBlock.image.label')} + return ( + + {t('editor.sidebarBlock.image.label')} + + ) case BubbleBlockType.VIDEO: - return {t('editor.sidebarBlock.video.label')} + return ( + + {t('editor.sidebarBlock.video.label')} + + ) case BubbleBlockType.EMBED: - return {t('editor.sidebarBlock.embed.label')} + return ( + + {t('editor.sidebarBlock.embed.label')} + + ) case BubbleBlockType.AUDIO: - return {t('editor.sidebarBlock.audio.label')} + return ( + + {t('editor.sidebarBlock.audio.label')} + + ) case InputBlockType.NUMBER: - return {t('editor.sidebarBlock.number.label')} + return ( + + {t('editor.sidebarBlock.number.label')} + + ) case InputBlockType.EMAIL: - return {t('editor.sidebarBlock.email.label')} + return ( + + {t('editor.sidebarBlock.email.label')} + + ) case InputBlockType.URL: - return {t('editor.sidebarBlock.website.label')} + return ( + + {t('editor.sidebarBlock.website.label')} + + ) case InputBlockType.DATE: - return {t('editor.sidebarBlock.date.label')} + return ( + + {t('editor.sidebarBlock.date.label')} + + ) case InputBlockType.PHONE: - return {t('editor.sidebarBlock.phone.label')} + return ( + + {t('editor.sidebarBlock.phone.label')} + + ) case InputBlockType.CHOICE: - return {t('editor.sidebarBlock.button.label')} + return ( + + {t('editor.sidebarBlock.button.label')} + + ) case InputBlockType.PICTURE_CHOICE: return ( - {t('editor.sidebarBlock.picChoice.label')} + + {t('editor.sidebarBlock.picChoice.label')} + ) case InputBlockType.PAYMENT: - return {t('editor.sidebarBlock.payment.label')} + return ( + + {t('editor.sidebarBlock.payment.label')} + + ) case InputBlockType.RATING: - return {t('editor.sidebarBlock.rating.label')} + return ( + + {t('editor.sidebarBlock.rating.label')} + + ) case InputBlockType.FILE: - return {t('editor.sidebarBlock.file.label')} + return ( + + {t('editor.sidebarBlock.file.label')} + + ) case LogicBlockType.SET_VARIABLE: return ( - {t('editor.sidebarBlock.setVariable.label')} + + {t('editor.sidebarBlock.setVariable.label')} + ) case LogicBlockType.CONDITION: return ( - {t('editor.sidebarBlock.condition.label')} + + {t('editor.sidebarBlock.condition.label')} + ) case LogicBlockType.REDIRECT: return ( - {t('editor.sidebarBlock.redirect.label')} + + {t('editor.sidebarBlock.redirect.label')} + ) case LogicBlockType.SCRIPT: - return {t('editor.sidebarBlock.script.label')} + return ( + + {t('editor.sidebarBlock.script.label')} + + ) case LogicBlockType.TYPEBOT_LINK: - return {t('editor.sidebarBlock.typebot.label')} + return ( + + {t('editor.sidebarBlock.typebot.label')} + + ) case LogicBlockType.WAIT: - return {t('editor.sidebarBlock.wait.label')} + return ( + + {t('editor.sidebarBlock.wait.label')} + + ) case LogicBlockType.JUMP: - return {t('editor.sidebarBlock.jump.label')} + return ( + + {t('editor.sidebarBlock.jump.label')} + + ) case LogicBlockType.AB_TEST: - return {t('editor.sidebarBlock.abTest.label')} + return ( + + {t('editor.sidebarBlock.abTest.label')} + + ) case IntegrationBlockType.GOOGLE_SHEETS: - return {t('editor.sidebarBlock.sheets.label')} + return ( + + {t('editor.sidebarBlock.sheets.label')} + + ) case IntegrationBlockType.GOOGLE_ANALYTICS: return ( - {t('editor.sidebarBlock.analytics.label')} + + {t('editor.sidebarBlock.analytics.label')} + ) case IntegrationBlockType.WEBHOOK: - return HTTP request + return ( + + HTTP request + + ) case IntegrationBlockType.ZAPIER: - return {t('editor.sidebarBlock.zapier.label')} + return ( + + {t('editor.sidebarBlock.zapier.label')} + + ) case IntegrationBlockType.MAKE_COM: - return {t('editor.sidebarBlock.makecom.label')} + return ( + + {t('editor.sidebarBlock.makecom.label')} + + ) case IntegrationBlockType.PABBLY_CONNECT: - return {t('editor.sidebarBlock.pabbly.label')} + return ( + + {t('editor.sidebarBlock.pabbly.label')} + + ) case IntegrationBlockType.EMAIL: - return {t('editor.sidebarBlock.email.label')} + return ( + + {t('editor.sidebarBlock.email.label')} + + ) case IntegrationBlockType.CHATWOOT: return ( - {t('editor.sidebarBlock.chatwoot.label')} + + {t('editor.sidebarBlock.chatwoot.label')} + ) case IntegrationBlockType.OPEN_AI: - return {t('editor.sidebarBlock.openai.label')} + return ( + + {t('editor.sidebarBlock.openai.label')} + + ) case IntegrationBlockType.PIXEL: - return {t('editor.sidebarBlock.pixel.label')} + return ( + + {t('editor.sidebarBlock.pixel.label')} + + ) case IntegrationBlockType.ZEMANTIC_AI: return ( - {t('editor.sidebarBlock.zemanticAi.label')} + + {t('editor.sidebarBlock.zemanticAi.label')} + ) default: - return + return } } diff --git a/apps/builder/src/features/forge/ForgedBlockIcon.tsx b/apps/builder/src/features/forge/ForgedBlockIcon.tsx index 72f3da0ef..63a2c2939 100644 --- a/apps/builder/src/features/forge/ForgedBlockIcon.tsx +++ b/apps/builder/src/features/forge/ForgedBlockIcon.tsx @@ -1,18 +1,27 @@ -import { useColorMode } from '@chakra-ui/react' +import { IconProps, useColorMode } from '@chakra-ui/react' import { ForgedBlock } from '@typebot.io/forge-repository/types' import { useForgedBlock } from './hooks/useForgedBlock' export const ForgedBlockIcon = ({ type, - mt, + ...props }: { type: ForgedBlock['type'] - mt?: string -}): JSX.Element => { +} & IconProps): JSX.Element => { const { colorMode } = useColorMode() const { blockDef } = useForgedBlock(type) if (!blockDef) return <> if (colorMode === 'dark' && blockDef.DarkLogo) - return - return + return ( + + ) + return ( + + ) } diff --git a/apps/builder/src/features/forge/ForgedBlockLabel.tsx b/apps/builder/src/features/forge/ForgedBlockLabel.tsx index 25e43a4e8..6b7f6f328 100644 --- a/apps/builder/src/features/forge/ForgedBlockLabel.tsx +++ b/apps/builder/src/features/forge/ForgedBlockLabel.tsx @@ -1,9 +1,16 @@ import { ForgedBlock } from '@typebot.io/forge-repository/types' import { useForgedBlock } from './hooks/useForgedBlock' -import { Text } from '@chakra-ui/react' +import { Text, TextProps } from '@chakra-ui/react' -export const ForgedBlockLabel = ({ type }: { type: ForgedBlock['type'] }) => { +export const ForgedBlockLabel = ({ + type, + ...props +}: { type: ForgedBlock['type'] } & TextProps) => { const { blockDef } = useForgedBlock(type) - return {blockDef?.name} + return ( + + {blockDef?.name} + + ) } diff --git a/apps/builder/src/features/forge/api/credentials/createCredentials.ts b/apps/builder/src/features/forge/api/credentials/createCredentials.ts deleted file mode 100644 index bfa542b30..000000000 --- a/apps/builder/src/features/forge/api/credentials/createCredentials.ts +++ /dev/null @@ -1,54 +0,0 @@ -import prisma from '@typebot.io/lib/prisma' -import { authenticatedProcedure } from '@/helpers/server/trpc' -import { TRPCError } from '@trpc/server' -import { encrypt } from '@typebot.io/lib/api/encryption/encrypt' -import { z } from 'zod' -import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden' -import { forgedCredentialsSchemas } from '@typebot.io/forge-repository/credentials' -import { isDefined } from '@typebot.io/lib' - -const inputShape = { - data: true, - type: true, - workspaceId: true, - name: true, -} as const - -export const createCredentials = authenticatedProcedure - .input( - z.object({ - credentials: z.discriminatedUnion( - 'type', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Object.values(forgedCredentialsSchemas) - .filter(isDefined) - .map((i) => i.pick(inputShape)) - ), - }) - ) - .mutation(async ({ input: { credentials }, ctx: { user } }) => { - const workspace = await prisma.workspace.findFirst({ - where: { - id: credentials.workspaceId, - }, - select: { id: true, members: true }, - }) - if (!workspace || isWriteWorkspaceForbidden(workspace, user)) - throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' }) - - const { encryptedData, iv } = await encrypt(credentials.data) - const createdCredentials = await prisma.credentials.create({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - data: { - ...credentials, - data: encryptedData, - iv, - }, - select: { - id: true, - }, - }) - return { credentialsId: createdCredentials.id } - }) diff --git a/apps/builder/src/features/forge/api/credentials/deleteCredentials.ts b/apps/builder/src/features/forge/api/credentials/deleteCredentials.ts deleted file mode 100644 index 6c0531ec6..000000000 --- a/apps/builder/src/features/forge/api/credentials/deleteCredentials.ts +++ /dev/null @@ -1,38 +0,0 @@ -import prisma from '@typebot.io/lib/prisma' -import { authenticatedProcedure } from '@/helpers/server/trpc' -import { TRPCError } from '@trpc/server' -import { z } from 'zod' -import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden' - -export const deleteCredentials = authenticatedProcedure - .input( - z.object({ - credentialsId: z.string(), - workspaceId: z.string(), - }) - ) - .mutation( - async ({ input: { credentialsId, workspaceId }, ctx: { user } }) => { - const workspace = await prisma.workspace.findFirst({ - where: { - id: workspaceId, - members: { - some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } }, - }, - }, - select: { id: true, members: true }, - }) - if (!workspace || isWriteWorkspaceForbidden(workspace, user)) - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Workspace not found', - }) - - await prisma.credentials.delete({ - where: { - id: credentialsId, - }, - }) - return { credentialsId } - } - ) diff --git a/apps/builder/src/features/forge/api/credentials/listCredentials.ts b/apps/builder/src/features/forge/api/credentials/listCredentials.ts deleted file mode 100644 index 7d5b0011d..000000000 --- a/apps/builder/src/features/forge/api/credentials/listCredentials.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 { forgedBlockIds } from '@typebot.io/forge-repository/constants' - -export const listCredentials = authenticatedProcedure - .input( - z.object({ - workspaceId: z.string(), - type: z.enum(forgedBlockIds), - }) - ) - .query(async ({ input: { workspaceId, type }, ctx: { user } }) => { - const workspace = await prisma.workspace.findFirst({ - where: { - id: workspaceId, - }, - select: { - id: true, - members: true, - credentials: { - where: { - type, - }, - select: { - id: true, - name: true, - }, - }, - }, - }) - if (!workspace || isReadWorkspaceFobidden(workspace, user)) - throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' }) - - return { credentials: workspace.credentials } - }) diff --git a/apps/builder/src/features/forge/api/router.ts b/apps/builder/src/features/forge/api/router.ts index 1c2bd6c75..21e88ddfc 100644 --- a/apps/builder/src/features/forge/api/router.ts +++ b/apps/builder/src/features/forge/api/router.ts @@ -1,12 +1,6 @@ import { router } from '@/helpers/server/trpc' import { fetchSelectItems } from './fetchSelectItems' -import { createCredentials } from './credentials/createCredentials' -import { deleteCredentials } from './credentials/deleteCredentials' -import { listCredentials } from './credentials/listCredentials' export const forgeRouter = router({ fetchSelectItems, - createCredentials, - listCredentials, - deleteCredentials, }) diff --git a/apps/builder/src/features/forge/components/ForgedBlockSettings.tsx b/apps/builder/src/features/forge/components/ForgedBlockSettings.tsx index e96c3b3f2..587a538a1 100644 --- a/apps/builder/src/features/forge/components/ForgedBlockSettings.tsx +++ b/apps/builder/src/features/forge/components/ForgedBlockSettings.tsx @@ -1,7 +1,7 @@ import { Stack, useDisclosure } from '@chakra-ui/react' import { BlockOptions } from '@typebot.io/schemas' import { ForgedCredentialsDropdown } from './credentials/ForgedCredentialsDropdown' -import { ForgedCredentialsModal } from './credentials/ForgedCredentialsModal' +import { CreateForgedCredentialsModal } from './credentials/CreateForgedCredentialsModal' import { ZodObjectLayout } from './zodLayouts/ZodObjectLayout' import { ZodActionDiscriminatedUnion } from './zodLayouts/ZodActionDiscriminatedUnion' import { useForgedBlock } from '../hooks/useForgedBlock' @@ -64,7 +64,7 @@ export const ForgedBlockSettings = ({ block, onOptionsChange }: Props) => { {blockDef.auth && ( <> - void onNewCredentials: (id: string) => void } -export const ForgedCredentialsModal = ({ +export const CreateForgedCredentialsModal = ({ blockDef, isOpen, + defaultData, onClose, onNewCredentials, }: Props) => { + if (!blockDef.auth) return null + return ( + + + { + onClose() + onNewCredentials(id) + }} + /> + + ) +} + +export const CreateForgedCredentialsModalContent = ({ + blockDef, + onNewCredentials, +}: Pick) => { const { workspace } = useWorkspace() const { showToast } = useToast() const [name, setName] = useState('') @@ -42,7 +66,8 @@ export const ForgedCredentialsModal = ({ listCredentials: { refetch: refetchCredentials }, }, } = trpc.useContext() - const { mutate } = trpc.forge.createCredentials.useMutation({ + + const { mutate } = trpc.credentials.createCredentials.useMutation({ onMutate: () => setIsCreating(true), onSettled: () => setIsCreating(false), onError: (err) => { @@ -54,59 +79,55 @@ export const ForgedCredentialsModal = ({ onSuccess: (data) => { refetchCredentials() onNewCredentials(data.credentialsId) - onClose() }, }) const createOpenAICredentials = async (e: React.FormEvent) => { e.preventDefault() - if (!workspace) return + if (!workspace || !blockDef.auth) return mutate({ credentials: { - type: blockDef.id, + type: blockDef.id as Credentials['type'], workspaceId: workspace.id, name, data, - }, + } as Credentials, }) } if (!blockDef.auth) return null return ( - - - - Add {blockDef.auth.name} - -
- - - - + + Add {blockDef.auth.name} + + + + + + - - - - -
-
+ + + + +
) } diff --git a/apps/builder/src/features/forge/components/credentials/ForgedCredentialsDropdown.tsx b/apps/builder/src/features/forge/components/credentials/ForgedCredentialsDropdown.tsx index 32abaf5a4..fdaca6a71 100644 --- a/apps/builder/src/features/forge/components/credentials/ForgedCredentialsDropdown.tsx +++ b/apps/builder/src/features/forge/components/credentials/ForgedCredentialsDropdown.tsx @@ -16,6 +16,7 @@ import { trpc } from '@/lib/trpc' import { useWorkspace } from '@/features/workspace/WorkspaceProvider' import { ForgedBlockDefinition } from '@typebot.io/forge-repository/types' import { useToast } from '@/hooks/useToast' +import { Credentials } from '@typebot.io/schemas/features/credentials' type Props = Omit & { blockDef: ForgedBlockDefinition @@ -34,13 +35,14 @@ export const ForgedCredentialsDropdown = ({ const router = useRouter() const { showToast } = useToast() const { workspace, currentRole } = useWorkspace() - const { data, refetch, isLoading } = trpc.forge.listCredentials.useQuery( - { - workspaceId: workspace?.id as string, - type: blockDef.id, - }, - { enabled: !!workspace?.id } - ) + const { data, refetch, isLoading } = + trpc.credentials.listCredentials.useQuery( + { + workspaceId: workspace?.id as string, + type: blockDef.id as Credentials['type'], + }, + { enabled: !!workspace?.id } + ) const [isDeleting, setIsDeleting] = useState() const { mutate } = trpc.credentials.deleteCredentials.useMutation({ diff --git a/apps/builder/src/features/forge/components/credentials/UpdateForgedCredentialsModalContent.tsx b/apps/builder/src/features/forge/components/credentials/UpdateForgedCredentialsModalContent.tsx new file mode 100644 index 000000000..092673d7b --- /dev/null +++ b/apps/builder/src/features/forge/components/credentials/UpdateForgedCredentialsModalContent.tsx @@ -0,0 +1,119 @@ +import { TextInput } from '@/components/inputs/TextInput' +import { useWorkspace } from '@/features/workspace/WorkspaceProvider' +import { useToast } from '@/hooks/useToast' +import { trpc } from '@/lib/trpc' +import { + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + Stack, + ModalFooter, + Button, +} from '@chakra-ui/react' +import React, { useEffect, useState } from 'react' +import { ZodObjectLayout } from '../zodLayouts/ZodObjectLayout' +import { ForgedBlockDefinition } from '@typebot.io/forge-repository/types' +import { Credentials } from '@typebot.io/schemas' + +type Props = { + credentialsId: string + blockDef: ForgedBlockDefinition + onUpdate: () => void +} + +export const UpdateForgedCredentialsModalContent = ({ + credentialsId, + blockDef, + onUpdate, +}: Props) => { + const { workspace } = useWorkspace() + const { showToast } = useToast() + const [name, setName] = useState('') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [data, setData] = useState() + + const [isUpdating, setIsUpdating] = useState(false) + + const { data: existingCredentials } = + trpc.credentials.getCredentials.useQuery( + { + workspaceId: workspace?.id as string, + credentialsId, + }, + { + enabled: !!workspace?.id, + } + ) + + useEffect(() => { + if (!existingCredentials || data) return + setName(existingCredentials.name) + setData(existingCredentials.data) + }, [data, existingCredentials]) + + const { mutate } = trpc.credentials.updateCredentials.useMutation({ + onMutate: () => setIsUpdating(true), + onSettled: () => setIsUpdating(false), + onError: (err) => { + showToast({ + description: err.message, + status: 'error', + }) + }, + onSuccess: () => { + onUpdate() + }, + }) + + const updateCredentials = async (e: React.FormEvent) => { + e.preventDefault() + if (!workspace || !blockDef.auth) return + mutate({ + credentialsId, + credentials: { + type: blockDef.id, + workspaceId: workspace.id, + name, + data, + } as Credentials, + }) + } + + if (!blockDef.auth) return null + return ( + + Update {blockDef.auth.name} + +
+ + + + + + + + +
+
+ ) +} diff --git a/apps/builder/src/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppCredentialsModal.tsx b/apps/builder/src/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppCredentialsModal.tsx index b7d5840d2..fb0578d9c 100644 --- a/apps/builder/src/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppCredentialsModal.tsx +++ b/apps/builder/src/features/publish/components/embeds/modals/WhatsAppModal/WhatsAppCredentialsModal.tsx @@ -64,6 +64,21 @@ export const WhatsAppCredentialsModal = ({ onClose, onNewCredentials, }: Props) => { + return ( + + + + + ) +} + +export const WhatsAppCreateModalContent = ({ + onNewCredentials, + onClose, +}: Pick) => { const { workspace } = useWorkspace() const { showToast } = useToast() const { activeStep, goToNext, goToPrevious, setActiveStep } = useSteps({ @@ -226,82 +241,78 @@ export const WhatsAppCredentialsModal = ({ goToNext() } - return ( - - - - - - {activeStep > 0 && ( - } - aria-label={'Go back'} - variant="ghost" - onClick={goToPrevious} - /> - )} - Add a WhatsApp phone number - - - - - - {steps.map((step, index) => ( - - - } - incomplete={} - active={} - /> - + + + + {activeStep > 0 && ( + } + aria-label={'Go back'} + variant="ghost" + onClick={goToPrevious} + /> + )} + Add a WhatsApp phone number + + + + + + {steps.map((step, index) => ( + + + } + incomplete={} + active={} + /> + - - {step.title} - + + {step.title} + - - - ))} - - {activeStep === 0 && } - {activeStep === 1 && ( - - )} - {activeStep === 2 && ( - - )} - {activeStep === 3 && ( - - )} - - - - - - + + + ))} + + {activeStep === 0 && } + {activeStep === 1 && ( + + )} + {activeStep === 2 && ( + + )} + {activeStep === 3 && ( + + )} + + + + + ) } diff --git a/apps/builder/src/features/workspace/components/WorkspaceSettingsModal.tsx b/apps/builder/src/features/workspace/components/WorkspaceSettingsModal.tsx index 9ffc04047..999ffa0ad 100644 --- a/apps/builder/src/features/workspace/components/WorkspaceSettingsModal.tsx +++ b/apps/builder/src/features/workspace/components/WorkspaceSettingsModal.tsx @@ -13,6 +13,7 @@ import { HardDriveIcon, SettingsIcon, UsersIcon, + WalletIcon, } from '@/components/icons' import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon' import { User, WorkspaceRole } from '@typebot.io/prisma' @@ -26,11 +27,13 @@ import { MyAccountForm } from '@/features/account/components/MyAccountForm' import { BillingSettingsLayout } from '@/features/billing/components/BillingSettingsLayout' import { useTranslate } from '@tolgee/react' import { useParentModal } from '@/features/graph/providers/ParentModalProvider' +import { CredentialsSettingsForm } from '@/features/credentials/components/CredentialsSettingsForm' type Props = { isOpen: boolean user: User workspace: WorkspaceInApp + defaultTab?: SettingsTab onClose: () => void } @@ -40,17 +43,19 @@ type SettingsTab = | 'workspace-settings' | 'members' | 'billing' + | 'credentials' export const WorkspaceSettingsModal = ({ isOpen, user, workspace, + defaultTab = 'my-account', onClose, }: Props) => { const { t } = useTranslate() const { ref } = useParentModal() const { currentRole } = useWorkspace() - const [selectedTab, setSelectedTab] = useState('my-account') + const [selectedTab, setSelectedTab] = useState(defaultTab) const canEditWorkspace = currentRole === WorkspaceRole.ADMIN @@ -121,6 +126,18 @@ export const WorkspaceSettingsModal = ({ {t('workspace.settings.modal.menu.settings.label')} )} + {canEditWorkspace && ( + + )} {currentRole !== WorkspaceRole.GUEST && (