feat(studio): upload BigQuery pipeline credentials (#49668)

## What kind of change does this PR introduce?

Feature improvement for BigQuery pipeline creation and editing.

## What is the current behavior?

Users must paste the complete service-account JSON into a text area.

## What is the new behavior?

Users can paste, upload, or drag and drop a service-account JSON file.
Imported credentials remain editable, and unreadable or oversized files
show an inline form error.

## To test

1. Open a project, then go to Database → Replication and select Add
destination.
2. Select BigQuery.
3. Under Service account key, select Upload JSON file and choose a
service-account `.json` file.
4. Confirm its contents appear in the editable text area.
5. Drag and drop a JSON file onto the same field and confirm it replaces
the contents.
6. Confirm pasting credentials manually still works.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added support for uploading BigQuery service account key JSON files.
  * Added drag-and-drop support for service account key files.
  * Updated guidance to clarify that keys can be pasted or uploaded.
  * Constrained the service account key field to 5,000 characters.

* **Bug Fixes**
  * Added clear validation when keys exceed the character limit.
* Improved handling of unreadable files while preserving the existing
key.
  * Improved editing of imported service account key content.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-08-28 17:37:31 +10:00
1 parent 02bb456647
commit 19e3844a0e
2 files changed
+220 -16

No files matched your search

@@ -0,0 +1,90 @@
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 { BigQueryFields } from './Fields'
import { customRender } from '@/tests/lib/custom-render'
const TestForm = ({ serviceAccountKey = '' }: { serviceAccountKey?: string }) => {
const form = useForm<DestinationPanelSchemaType>({
defaultValues: { projectId: '', datasetId: '', serviceAccountKey },
})
return (
<Form {...form}>
<BigQueryFields form={form} editMode={false} />
</Form>
)
}
describe('BigQueryFields', () => {
it('imports a JSON file into an editable service account key field', async () => {
const { container } = customRender(<TestForm />)
const contents = '{"type":"service_account"}'
const file = new File([contents], 'service-account.json', { type: 'application/json' })
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) })
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
expect(fileInput).not.toBeNull()
fireEvent.change(fileInput!, { target: { files: [file] } })
const textarea = screen.getByPlaceholderText(
'{"type": "service_account", "project_id": "...", ...}'
)
await waitFor(() => expect(textarea).toHaveValue(contents))
fireEvent.change(textarea, { target: { value: `${contents}\n` } })
expect(textarea).toHaveValue(`${contents}\n`)
expect(textarea).toHaveClass('max-h-[calc(13lh+1rem)]')
})
it('imports a dropped JSON file into the service account key field', async () => {
customRender(<TestForm />)
const contents = '{"type":"service_account"}'
const file = new File([contents], 'service-account.json', { type: 'application/json' })
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) })
const textarea = screen.getByPlaceholderText(
'{"type": "service_account", "project_id": "...", ...}'
)
fireEvent.drop(textarea.closest('div')!, {
dataTransfer: { files: [file] },
})
await waitFor(() => expect(textarea).toHaveValue(contents))
})
it('rejects an oversized file without replacing the existing key', async () => {
const { container } = customRender(<TestForm serviceAccountKey="existing-key" />)
const file = new File(['x'.repeat(5001)], 'service-account.json', {
type: 'application/json',
})
const readFile = vi.fn()
Object.defineProperty(file, 'text', { value: readFile })
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
fireEvent.change(fileInput!, { target: { files: [file] } })
expect(
await screen.findByText('Service account key must be 5,000 characters or fewer.')
).toBeInTheDocument()
expect(readFile).not.toHaveBeenCalled()
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
})
it('preserves the existing key when the selected file cannot be read', async () => {
const { container } = customRender(<TestForm serviceAccountKey="existing-key" />)
const file = new File(['{}'], 'service-account.json', { type: 'application/json' })
Object.defineProperty(file, 'text', {
value: vi.fn().mockRejectedValue(new Error('File read failed')),
})
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
fireEvent.change(fileInput!, { target: { files: [file] } })
expect(await screen.findByText('Could not read the selected JSON file.')).toBeInTheDocument()
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
})
})
@@ -1,10 +1,54 @@
import { Upload } from 'lucide-react'
import { useRef, useState, type ChangeEvent, type DragEvent } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { FormControl, FormField, Input, TextArea } from 'ui'
import { Button, cn, FormControl, FormField, Input, TextArea } from 'ui'
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<DestinationPanelSchemaType>,
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.',
})
}
}
}
export const BigQueryFields = ({
form,
editMode,
@@ -12,6 +56,41 @@ export const BigQueryFields = ({
form: UseFormReturn<DestinationPanelSchemaType>
editMode: boolean
}) => {
const serviceAccountFileInputRef = useRef<HTMLInputElement>(null)
const fileReadRequestIdRef = useRef(0)
const [isDraggingFile, setIsDraggingFile] = useState(false)
const handleServiceAccountFile = async (file: File | undefined) => {
if (!file) return
const requestId = ++fileReadRequestIdRef.current
await readServiceAccountFile(file, form, () => requestId === fileReadRequestIdRef.current)
}
const handleServiceAccountFileInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
await handleServiceAccountFile(file)
}
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggingFile(true)
}
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggingFile(false)
}
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDraggingFile(false)
await handleServiceAccountFile(event.dataTransfer.files?.[0])
}
return (
<div className="flex flex-col gap-y-6 p-5">
<p className="text-sm font-medium text-foreground">BigQuery settings</p>
@@ -57,23 +136,58 @@ export const BigQueryFields = ({
label="Service account key"
description={
editMode
? 'Stored credentials are hidden. Enter new credentials to replace them.'
: 'Service account credentials JSON for authenticating with BigQuery'
? 'Stored credentials are hidden. Paste or upload new credentials to replace them.'
: 'Paste or upload your service account credentials JSON file for authenticating with BigQuery.'
}
>
<FormControl>
<TextArea
{...field}
rows={5}
maxLength={5000}
placeholder={
editMode
? STORED_SECRET_PLACEHOLDER
: '{"type": "service_account", "project_id": "...", ...}'
}
className="font-mono text-xs"
/>
</FormControl>
<div
className="relative"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className={cn('space-y-2 transition-opacity', isDraggingFile && 'opacity-40')}>
<FormControl>
<TextArea
{...field}
onChange={(event) => {
fileReadRequestIdRef.current += 1
field.onChange(event)
}}
rows={5}
maxLength={MAX_SERVICE_ACCOUNT_KEY_LENGTH}
placeholder={
editMode
? STORED_SECRET_PLACEHOLDER
: '{"type": "service_account", "project_id": "...", ...}'
}
className="max-h-[calc(13lh+1rem)] font-mono text-xs"
/>
</FormControl>
<input
ref={serviceAccountFileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={handleServiceAccountFileInputChange}
/>
<Button
type="button"
variant="default"
size="tiny"
icon={<Upload size={14} />}
onClick={() => serviceAccountFileInputRef.current?.click()}
>
Upload JSON file
</Button>
</div>
{isDraggingFile ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-md ring-2 ring-brand ring-offset-2 ring-offset-background"
/>
) : null}
</div>
</FormItemLayout>
)}
/>