From 9fb173e8273970e587a03cd0c3c92b1a1ff511b4 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:38:57 +1000 Subject: [PATCH] feat(studio): improve Snowflake destination setup (#50719) ## Problem Snowflake setup makes the public and private key files easy to confuse, treats the optional SQL role like a primary connection field, and requires users to paste private-key contents manually. Password managers can also mistake the Snowflake user field for a sign-in field. This PR is based on #50708 so its later Docs-button follow-up can use the nested destination-guide URLs. ## Solution Clarifies the Snowflake field copy and example values, moves **Role** into Advanced settings, opts the service-user field out of password-manager overlays, and adds drag-and-drop or button upload for P8 and PEM private-key files. Public key files are rejected without replacing the current field value. | Before | After | | --- | --- | | Pipelines Database Agua Basket
Supabase | Replication Database Agua
Basket Supabase | ## Review instructions 1. Open **[Database > Replication](https://studio-staging-git-dnywh-studioimprove-snowflake-form-supabase.vercel.app/project/_/database/replication)**, click **Add pipeline**, and select **Snowflake**. 2. Confirm **Role** appears under **Advanced settings** and explains the default-role behaviour. 3. Upload or drop a valid P8 or PEM private key and confirm its contents appear in **Private key**. 4. Select a public key file and confirm the form rejects it without replacing the existing value. 5. Confirm 1Password (or Bitwarden etc) does _not_ add its widget to **User**. ## Checklist - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide ## Summary by CodeRabbit - **New Features** - Added Snowflake private key upload via file picker or drag-and-drop. - Supports P8 and PEM private key files, with validation and clear error messages. - Added an optional Snowflake role field in Advanced Settings. - **Usability Improvements** - Preserves manual edits made while a private key file is processing. - Improved drag-and-drop feedback and updated field guidance and placeholders. - Prevents password managers from automatically filling Snowflake credentials. - Validates private keys when submitting the Snowflake destination form. --------- Co-authored-by: Joshen Lim --- .../DestinationForm/AdvancedSettings.tsx | 20 ++ .../BigQuery/BigQuery.utils.ts | 44 ++++ .../DestinationForm/BigQuery/Fields.tsx | 43 +--- .../DestinationForm/Snowflake/Fields.test.tsx | 83 ++++++++ .../DestinationForm/Snowflake/Fields.tsx | 157 ++++++++++---- .../Snowflake/Snowflake.utils.test.ts | 196 ++++++++++++++++++ .../Snowflake/Snowflake.utils.ts | 84 +++++++- .../DestinationForm/index.tsx | 26 ++- 8 files changed, 557 insertions(+), 96 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.test.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Snowflake.utils.test.ts diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx index 49efc3e609a..40eac139cf7 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx @@ -8,6 +8,7 @@ import { FormControl, FormField, FormInputGroupInput, + Input, InputGroup, InputGroupAddon, InputGroupText, @@ -251,6 +252,25 @@ export const AdvancedSettings = ({ )} + + {type === 'Snowflake' && ( + ( + + + + + + )} + /> + )} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts index 51bb86c3f32..fc3532f4c15 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts @@ -1,3 +1,5 @@ +import type { UseFormReturn } from 'react-hook-form' + import { type DestinationPanelSchemaType } from '../DestinationForm.schema' import { BigQueryPartitionBy } from '@/data/replication/types' @@ -97,3 +99,45 @@ export const getBigQueryValidationIssues = ( return issues } + +export const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000 + +export const readServiceAccountFile = async ( + file: File, + form: UseFormReturn, + isCurrentRequest: () => boolean +) => { + if (file.size > MAX_SERVICE_ACCOUNT_KEY_LENGTH) { + if (isCurrentRequest()) { + form.setError('serviceAccountKey', { + message: 'Service account key must be 5,000 characters or fewer.', + }) + } + return + } + + try { + const contents = await file.text() + if (!isCurrentRequest()) return + + if (contents.length > MAX_SERVICE_ACCOUNT_KEY_LENGTH) { + form.setError('serviceAccountKey', { + message: 'Service account key must be 5,000 characters or fewer.', + }) + return + } + + form.setValue('serviceAccountKey', contents, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + form.clearErrors('serviceAccountKey') + } catch { + if (isCurrentRequest()) { + form.setError('serviceAccountKey', { + message: 'Could not read the selected JSON file.', + }) + } + } +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx index 2a308b9a3c2..f0b26f31f7d 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx @@ -6,48 +6,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' - -const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000 - -const readServiceAccountFile = async ( - file: File, - form: UseFormReturn, - isCurrentRequest: () => boolean -) => { - if (file.size > MAX_SERVICE_ACCOUNT_KEY_LENGTH) { - if (isCurrentRequest()) { - form.setError('serviceAccountKey', { - message: 'Service account key must be 5,000 characters or fewer.', - }) - } - return - } - - try { - const contents = await file.text() - if (!isCurrentRequest()) return - - if (contents.length > MAX_SERVICE_ACCOUNT_KEY_LENGTH) { - form.setError('serviceAccountKey', { - message: 'Service account key must be 5,000 characters or fewer.', - }) - return - } - - form.setValue('serviceAccountKey', contents, { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }) - form.clearErrors('serviceAccountKey') - } catch { - if (isCurrentRequest()) { - form.setError('serviceAccountKey', { - message: 'Could not read the selected JSON file.', - }) - } - } -} +import { MAX_SERVICE_ACCOUNT_KEY_LENGTH, readServiceAccountFile } from './BigQuery.utils' export const BigQueryFields = ({ form, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.test.tsx new file mode 100644 index 00000000000..fa60d3f5344 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { useForm } from 'react-hook-form' +import { Form } from 'ui' +import { describe, expect, it, vi } from 'vitest' + +import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +import { SnowflakeFields } from './Fields' +import { customRender } from '@/tests/lib/custom-render' + +const PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nprivate-key\n-----END PRIVATE KEY-----' + +const TestForm = ({ snowflakePrivateKey = '' }: { snowflakePrivateKey?: string }) => { + const form = useForm({ + defaultValues: { snowflakePrivateKey }, + }) + + return ( +
+ + + ) +} + +describe('SnowflakeFields', () => { + it('imports a P8 file into an editable private key field', async () => { + const { container } = customRender() + const file = new File([PRIVATE_KEY], 'rsa_key.p8', { type: 'application/x-pem-file' }) + Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(PRIVATE_KEY) }) + + const fileInput = container.querySelector('input[type="file"]') + expect(fileInput).not.toBeNull() + fireEvent.change(fileInput!, { target: { files: [file] } }) + + const textarea = screen.getByRole('textbox', { name: 'Private key' }) + await waitFor(() => expect(textarea).toHaveValue(PRIVATE_KEY)) + + fireEvent.change(textarea, { target: { value: `${PRIVATE_KEY}\n` } }) + expect(textarea).toHaveValue(`${PRIVATE_KEY}\n`) + }) + + it('does not overwrite a manual edit when a file read resolves late', async () => { + const { container } = customRender() + let resolveFileText!: (contents: string) => void + const fileText = new Promise((resolve) => { + resolveFileText = resolve + }) + const file = new File([PRIVATE_KEY], 'rsa_key.p8', { type: 'application/x-pem-file' }) + Object.defineProperty(file, 'text', { value: vi.fn(() => fileText) }) + + const fileInput = container.querySelector('input[type="file"]') + fireEvent.change(fileInput!, { target: { files: [file] } }) + + const textarea = screen.getByRole('textbox', { name: 'Private key' }) + fireEvent.change(textarea, { target: { value: 'manual private key' } }) + resolveFileText(PRIVATE_KEY) + + await waitFor(() => expect(textarea).toHaveValue('manual private key')) + }) + + it('imports a dropped PEM private key file', async () => { + customRender() + const file = new File([PRIVATE_KEY], 'rsa_key.pem', { type: 'application/x-pem-file' }) + Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(PRIVATE_KEY) }) + + const textarea = screen.getByRole('textbox', { name: 'Private key' }) + fireEvent.drop(textarea.closest('div')!, { dataTransfer: { files: [file] } }) + + await waitFor(() => expect(textarea).toHaveValue(PRIVATE_KEY)) + }) + + it('rejects a public key file without replacing the private key', async () => { + const { container } = customRender() + const publicKey = '-----BEGIN PUBLIC KEY-----\npublic-key\n-----END PUBLIC KEY-----' + const file = new File([publicKey], 'rsa_key.pub', { type: 'application/x-pem-file' }) + Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(publicKey) }) + + const fileInput = container.querySelector('input[type="file"]') + fireEvent.change(fileInput!, { target: { files: [file] } }) + + expect(await screen.findByText('Select a P8 or PEM private key.')).toBeInTheDocument() + expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx index 01eaabf454f..8a7cff2a386 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx @@ -1,12 +1,13 @@ -import { Eye, EyeOff } from 'lucide-react' -import { useState } from 'react' +import { Eye, EyeOff, Upload } from 'lucide-react' +import { useRef, useState, type ChangeEvent, type DragEvent } from 'react' import type { UseFormReturn } from 'react-hook-form' -import { Button, FormControl, FormField, Input, TextArea } from 'ui' +import { Button, cn, FormControl, FormField, Input, TextArea } from 'ui' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' +import { MAX_PRIVATE_KEY_LENGTH, readPrivateKeyFile } from './Snowflake.utils' export const SnowflakeFields = ({ form, @@ -16,6 +17,40 @@ export const SnowflakeFields = ({ editMode: boolean }) => { const [showPrivateKeyPassphrase, setShowPrivateKeyPassphrase] = useState(false) + const privateKeyFileInputRef = useRef(null) + const fileReadRequestIdRef = useRef(0) + const [isDraggingPrivateKey, setIsDraggingPrivateKey] = useState(false) + + const handlePrivateKeyFile = async (file: File | undefined) => { + if (!file) return + const requestId = ++fileReadRequestIdRef.current + await readPrivateKeyFile(file, form, () => requestId === fileReadRequestIdRef.current) + } + + const handlePrivateKeyFileInputChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + await handlePrivateKeyFile(file) + } + + const handlePrivateKeyDragOver = (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingPrivateKey(true) + } + + const handlePrivateKeyDragLeave = (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingPrivateKey(false) + } + + const handlePrivateKeyDrop = async (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDraggingPrivateKey(false) + await handlePrivateKeyFile(event.dataTransfer.files?.[0]) + } return (
@@ -24,7 +59,7 @@ export const SnowflakeFields = ({

Connection

- Configure the Snowflake account, user, and target namespace for replicated data. + Enter the Snowflake account and destination details.

@@ -36,7 +71,7 @@ export const SnowflakeFields = ({ @@ -52,10 +87,19 @@ export const SnowflakeFields = ({ - + )} @@ -68,10 +112,10 @@ export const SnowflakeFields = ({ - + )} @@ -84,26 +128,10 @@ export const SnowflakeFields = ({ - - - - )} - /> - - ( - - - + )} @@ -128,23 +156,63 @@ export const SnowflakeFields = ({ description={ editMode ? 'Stored private key is hidden. Enter a new private key to replace it.' - : 'RSA private key PEM contents in PKCS#8 or PKCS#1 format' + : 'Paste or upload a complete RSA private key (PKCS #8 or PKCS #1 PEM).' } > - -