mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
02bb456647
commit
19e3844a0e
2 files changed
+220
-16
No files matched your search
+90
@@ -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()
|
||||
})
|
||||
})
|
||||
+130
-16
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user