mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
30 files changed
+2104
-40
No files matched your search
Generated
+26
@@ -10268,6 +10268,12 @@
|
||||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/blueimp-md5": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz",
|
||||
"integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/btoa-lite": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz",
|
||||
@@ -12819,6 +12825,11 @@
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||
},
|
||||
"node_modules/blueimp-md5": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
|
||||
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
|
||||
@@ -37844,6 +37855,7 @@
|
||||
"ajv": "^8.6.3",
|
||||
"analytics-node": "^3.5.0",
|
||||
"awesome-debounce-promise": "^2.1.0",
|
||||
"blueimp-md5": "^2.19.0",
|
||||
"clipboard": "^2.0.8",
|
||||
"common": "*",
|
||||
"config": "*",
|
||||
@@ -37913,6 +37925,7 @@
|
||||
"@testing-library/dom": "^8.11.1",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/blueimp-md5": "^2.18.0",
|
||||
"@types/crypto-js": "^4.0.2",
|
||||
"@types/file-saver": "^2.0.2",
|
||||
"@types/json-logic-js": "^1.2.1",
|
||||
@@ -45119,6 +45132,12 @@
|
||||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"@types/blueimp-md5": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz",
|
||||
"integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/btoa-lite": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz",
|
||||
@@ -47178,6 +47197,11 @@
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||
},
|
||||
"blueimp-md5": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
|
||||
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="
|
||||
},
|
||||
"bn.js": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
|
||||
@@ -63844,6 +63868,7 @@
|
||||
"@testing-library/dom": "^8.11.1",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/blueimp-md5": "^2.18.0",
|
||||
"@types/crypto-js": "^4.0.2",
|
||||
"@types/file-saver": "^2.0.2",
|
||||
"@types/json-logic-js": "^1.2.1",
|
||||
@@ -63869,6 +63894,7 @@
|
||||
"autoprefixer": "10.4.2",
|
||||
"awesome-debounce-promise": "^2.1.0",
|
||||
"babel-loader": "^8.2.3",
|
||||
"blueimp-md5": "^2.19.0",
|
||||
"clipboard": "^2.0.8",
|
||||
"common": "*",
|
||||
"config": "*",
|
||||
|
||||
@@ -185,7 +185,7 @@ const PITRSelection = ({}) => {
|
||||
<Button
|
||||
as="span"
|
||||
type="warning"
|
||||
disabled={isSelectedOutOfRange}
|
||||
disabled={isSelectedOutOfRange || !selectedDate}
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
>
|
||||
Review restore details
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useState } from 'react'
|
||||
import {
|
||||
Collapsible,
|
||||
Input,
|
||||
IconCheck,
|
||||
IconChevronUp,
|
||||
Button,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconCheckCircle,
|
||||
IconExternalLink,
|
||||
IconLoader,
|
||||
} from 'ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { Wrapper } from './Wrappers.types'
|
||||
import { makeValidateRequired } from './Wrappers.utils'
|
||||
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
|
||||
import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import WrapperTableEditor from './WrapperTableEditor'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
|
||||
|
||||
interface Props {
|
||||
wrapper: Wrapper
|
||||
isLoading: boolean
|
||||
isEnabled: boolean
|
||||
isOpen: boolean
|
||||
onOpen: (wrapper: string) => void
|
||||
}
|
||||
|
||||
const InputField: FC<{ option: any; value: any; error: any; onChange: any }> = ({
|
||||
option,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: any) => {
|
||||
const [showHidden, setShowHidden] = useState(!option.hidden)
|
||||
return (
|
||||
<Input
|
||||
key={option.name}
|
||||
id={option.name}
|
||||
name={option.name}
|
||||
label={option.label}
|
||||
defaultValue={option.defaultValue ?? ''}
|
||||
required={option.required ?? false}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
className="input-mono"
|
||||
type={!option.hidden ? 'text' : showHidden ? 'text' : 'password'}
|
||||
actions={
|
||||
option.hidden ? (
|
||||
<div className="flex items-center justify-center mr-1">
|
||||
<Button
|
||||
type="default"
|
||||
icon={showHidden ? <IconEye /> : <IconEyeOff />}
|
||||
onClick={() => setShowHidden(!showHidden)}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const WrapperRow: FC<Props> = ({ wrapper, isLoading, isEnabled, isOpen, onOpen }) => {
|
||||
const getInitialFormState = () =>
|
||||
Object.fromEntries(
|
||||
wrapper.server.options.map((option) => [option.name, option.defaultValue ?? ''])
|
||||
)
|
||||
|
||||
const { ui } = useStore()
|
||||
const { project } = useProjectContext()
|
||||
const { mutateAsync: createFDW } = useFDWCreateMutation()
|
||||
const { mutateAsync: deleteFDW } = useFDWDeleteMutation()
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isEditingTable, setIsEditingTable] = useState(false)
|
||||
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
|
||||
|
||||
const [newTables, setNewTables] = useState<any[]>([])
|
||||
const [formState, setFormState] = useState(getInitialFormState)
|
||||
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
|
||||
|
||||
// [Joshen TODO] Fix this logic
|
||||
const noChanges = false
|
||||
// const noChanges = JSON.stringify(getInitialFormState()) === JSON.stringify(formState)
|
||||
|
||||
const onUpdateTable = (values: any) => {
|
||||
setNewTables((prev) => {
|
||||
// if the new values have tableIndex, we are editing an existing table
|
||||
if (values.tableIndex !== undefined) {
|
||||
const tableIndex = values.tableIndex
|
||||
const newTables = [...prev]
|
||||
delete values.tableIndex
|
||||
newTables[tableIndex] = values
|
||||
return newTables
|
||||
}
|
||||
return [...prev, values]
|
||||
})
|
||||
setIsEditingTable(false)
|
||||
setSelectedTableToEdit(undefined)
|
||||
}
|
||||
|
||||
const onSaveWrapper = async () => {
|
||||
const validate = makeValidateRequired(wrapper.server.options)
|
||||
const errors: any = validate(formState)
|
||||
if (newTables.length === 0) errors.tables = 'Please add at least one table'
|
||||
|
||||
if (!isEmpty(errors)) {
|
||||
setFormErrors(errors)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapper,
|
||||
formState,
|
||||
newTables,
|
||||
})
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully created ${wrapper.label} foreign data wrapper`,
|
||||
})
|
||||
setNewTables([])
|
||||
setFormState(getInitialFormState)
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to create ${wrapper.label} foreign data wrapper: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteWrapper = () => {
|
||||
confirmAlert({
|
||||
title: `Confirm to disable ${wrapper.label} wrapper`,
|
||||
message: `Are you sure you want to disable the ${wrapper.label} wrapper? This will also remove all tables created with this wrapper.`,
|
||||
onAsyncConfirm: async () => {
|
||||
try {
|
||||
await deleteFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapper,
|
||||
})
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Disabling ${wrapper.name} failed: ${error.message}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={() => onOpen(wrapper.name)}
|
||||
className={[
|
||||
'bg-scale-100 dark:bg-scale-300 ',
|
||||
'hover:bg-scale-200 dark:hover:bg-scale-500',
|
||||
'data-open:bg-scale-200 dark:data-open:bg-scale-500',
|
||||
'border-scale-300',
|
||||
'dark:border-scale-500 hover:border-scale-500',
|
||||
'dark:hover:border-scale-700 data-open:border-scale-700',
|
||||
'data-open:pb-px col-span-12 mx-auto',
|
||||
'-space-y-px overflow-hidden',
|
||||
'transition border shadow hover:z-50',
|
||||
'first:rounded-tl first:rounded-tr',
|
||||
'last:rounded-bl last:rounded-br',
|
||||
].join(' ')}
|
||||
>
|
||||
<Collapsible.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full px-6 py-3 rounded group text-scale-1200"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<IconChevronUp
|
||||
className="transition text-scale-800 data-open-parent:rotate-0 data-closed-parent:rotate-180"
|
||||
strokeWidth={2}
|
||||
width={14}
|
||||
/>
|
||||
<Image
|
||||
src={wrapper.icon}
|
||||
width={20}
|
||||
height={20}
|
||||
alt={`${wrapper.name} wrapper icon`}
|
||||
/>
|
||||
<span className="text-sm capitalize">{wrapper.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1 px-1 py-1 text-xs rounded-full">
|
||||
<IconLoader className="animate-spin" size={18} />
|
||||
</div>
|
||||
) : isEnabled ? (
|
||||
<div className="flex items-center gap-1 px-1 py-1 text-xs border rounded-full border-brand-700 bg-brand-200 text-brand-900">
|
||||
<span className="rounded-full bg-brand-900 p-0.5 text-xs text-brand-200">
|
||||
<IconCheck strokeWidth={2} size={12} />
|
||||
</span>
|
||||
<span className="px-1">Enabled</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-1 text-xs border rounded-md border-scale-500 bg-scale-100 text-scale-900 dark:border-scale-700 dark:bg-scale-300">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
{!isLoading && (
|
||||
<>
|
||||
{isEnabled ? (
|
||||
<Collapsible.Content>
|
||||
<div className="px-6 py-6 border-t group border-scale-500 bg-scale-100 text-scale-1200 dark:bg-scale-300">
|
||||
<div className="max-w-lg mx-auto my-6 space-y-6">
|
||||
<InformationBox
|
||||
hideCollapse
|
||||
defaultVisibility
|
||||
icon={
|
||||
<IconCheckCircle strokeWidth={1.5} size={18} className="text-brand-900" />
|
||||
}
|
||||
title={`${wrapper.label} foreign data wrapper is currently enabled`}
|
||||
description="If you'd like to edit this wrapper, you'll need to disable the wrapper first and create it again with any updated configuration."
|
||||
/>
|
||||
<div className="flex items-center justify-end !mt-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link href={wrapper.docsUrl}>
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
Documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Button
|
||||
type="default"
|
||||
loading={isSubmitting}
|
||||
disabled={noChanges}
|
||||
onClick={() => onDeleteWrapper()}
|
||||
>
|
||||
Disable wrapper
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
) : (
|
||||
<Collapsible.Content>
|
||||
<div className="px-6 py-6 border-t group border-scale-500 bg-scale-100 text-scale-1200 dark:bg-scale-300">
|
||||
<div className="max-w-lg mx-auto my-6 space-y-6">
|
||||
{wrapper.server.options.map((option) => (
|
||||
<InputField
|
||||
option={option}
|
||||
value={formState[option.name]}
|
||||
error={formErrors[option.name]}
|
||||
onChange={(e: any) => {
|
||||
setFormState((prev) => ({ ...prev, [option.name]: e.target.value }))
|
||||
setFormErrors((prev) => ({ ...prev, [option.name]: '' }))
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-scale-1100">Foreign Tables</p>
|
||||
<Button type="default" onClick={() => setIsEditingTable(true)}>
|
||||
Add table
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{newTables.length === 0 && (
|
||||
<div className="flex items-center justify-center px-4 py-4 border rounded-md border-scale-600">
|
||||
<p className="text-sm text-scale-1000">
|
||||
Add foreign tables to query from after the wrapper is enabled
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{newTables.map((table, i) => (
|
||||
<div className="flex items-center justify-between px-4 py-2 space-y-1 border rounded-md border-scale-600">
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
{table.schema_name}.{table.table_name}
|
||||
</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
{wrapper.tables[table.index].label}: {table.columns.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<IconEdit />}
|
||||
onClick={() => {
|
||||
setIsEditingTable(true)
|
||||
setSelectedTableToEdit({ ...table, tableIndex: i })
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<IconTrash />}
|
||||
onClick={() => {
|
||||
setNewTables((prev) => prev.filter((_, j) => j !== i))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{newTables.length === 0 && formErrors.tables && (
|
||||
<p className="text-sm text-red-900">{formErrors.tables}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between !mt-8">
|
||||
<Link href={wrapper.docsUrl}>
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
Documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
type="default"
|
||||
htmlType="reset"
|
||||
onClick={() => {
|
||||
onOpen('')
|
||||
setNewTables([])
|
||||
setFormState(getInitialFormState)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isSubmitting}
|
||||
disabled={noChanges}
|
||||
onClick={() => onSaveWrapper()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Collapsible>
|
||||
<WrapperTableEditor
|
||||
visible={isEditingTable}
|
||||
tables={wrapper.tables}
|
||||
onCancel={() => {
|
||||
setSelectedTableToEdit(undefined)
|
||||
setIsEditingTable(false)
|
||||
}}
|
||||
onSave={onUpdateTable}
|
||||
initialData={selectedTableToEdit}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WrapperRow
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Form, IconDatabase, Input, Listbox, SidePanel, Modal, IconPlus } from 'ui'
|
||||
import { useStore } from 'hooks'
|
||||
import { Table, TableOption } from './Wrappers.types'
|
||||
import { makeValidateRequired } from './Wrappers.utils'
|
||||
import MultiSelect from 'components/ui/MultiSelect'
|
||||
import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar'
|
||||
|
||||
export type WrapperTableEditorProps = {
|
||||
visible: boolean
|
||||
onCancel: () => void
|
||||
onSave: (values: any) => void
|
||||
|
||||
tables: Table[]
|
||||
initialData: any
|
||||
}
|
||||
|
||||
type OnSubmitFn = (values: any, { resetForm }: { resetForm: () => void }) => void
|
||||
|
||||
const WrapperTableEditor = ({
|
||||
visible,
|
||||
onCancel,
|
||||
onSave,
|
||||
tables,
|
||||
initialData,
|
||||
}: WrapperTableEditorProps) => {
|
||||
const [selectedTableIndex, setSelectedTableIndex] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setSelectedTableIndex(String(initialData.index))
|
||||
}
|
||||
}, [initialData])
|
||||
|
||||
const selectedTable = selectedTableIndex === '' ? undefined : tables[parseInt(selectedTableIndex)]
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedTableIndex('')
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const onSubmit: OnSubmitFn = (values, { resetForm }) => {
|
||||
onSave({
|
||||
...values,
|
||||
index: parseInt(selectedTableIndex),
|
||||
schema_name: values.schema === 'custom' ? values.schema_name : values.schema,
|
||||
is_new_schema: values.schema === 'custom',
|
||||
})
|
||||
resetForm()
|
||||
setSelectedTableIndex('')
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
key="WrapperTableEditor"
|
||||
size="medium"
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
header={<span>Edit foreign table</span>}
|
||||
customFooter={
|
||||
<ActionBar
|
||||
backButtonLabel="Cancel"
|
||||
applyButtonLabel="Save"
|
||||
formId="wrapper-table-editor-form"
|
||||
closePanel={handleCancel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="mt-4 space-y-6">
|
||||
<Listbox
|
||||
label="Select a target the table will point to"
|
||||
value={selectedTableIndex}
|
||||
onChange={(value) => setSelectedTableIndex(value)}
|
||||
>
|
||||
<Listbox.Option key="empty" value="" label="---">
|
||||
---
|
||||
</Listbox.Option>
|
||||
|
||||
{tables.map((table, i) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
className="group"
|
||||
key={String(i)}
|
||||
value={String(i)}
|
||||
label={table.label}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p>{table.label}</p>
|
||||
<p className="opacity-50">{table.description}</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
|
||||
{selectedTable && (
|
||||
<TableForm table={selectedTable} onSubmit={onSubmit} initialData={initialData} />
|
||||
)}
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default WrapperTableEditor
|
||||
|
||||
const Option = ({ option }: { option: TableOption }) => {
|
||||
return (
|
||||
<Input
|
||||
key={option.name}
|
||||
id={option.name}
|
||||
name={option.name}
|
||||
label={option.label}
|
||||
placeholder={option.placeholder ?? ''}
|
||||
defaultValue={option.defaultValue ?? ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TableForm = ({
|
||||
table,
|
||||
onSubmit,
|
||||
initialData,
|
||||
}: {
|
||||
table: Table
|
||||
onSubmit: OnSubmitFn
|
||||
initialData: any
|
||||
}) => {
|
||||
const { meta } = useStore()
|
||||
const schemas = meta.schemas.list()
|
||||
|
||||
const requiredOptions =
|
||||
table.options.filter((option) => option.editable && option.required && !option.defaultValue) ??
|
||||
[]
|
||||
const optionalOptions =
|
||||
table.options.filter(
|
||||
(option) => option.editable && (!option.required || option.defaultValue)
|
||||
) ?? []
|
||||
|
||||
const initialValues = initialData ?? {
|
||||
table_name: '',
|
||||
columns: table.availableColumns.map((column) => column.name),
|
||||
...Object.fromEntries(table.options.map((option) => [option.name, option.defaultValue ?? ''])),
|
||||
schema: 'public',
|
||||
schema_name: '',
|
||||
}
|
||||
|
||||
const validate = makeValidateRequired([
|
||||
...table.options,
|
||||
{ name: 'table_name', required: true },
|
||||
{ name: 'columns', required: true },
|
||||
])
|
||||
|
||||
return (
|
||||
<Form
|
||||
id="wrapper-table-editor-form"
|
||||
initialValues={initialValues}
|
||||
validate={validate}
|
||||
onSubmit={onSubmit}
|
||||
enableReinitialize={true}
|
||||
>
|
||||
{({ errors, values, resetForm }: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Listbox size="small" name="schema" label="Select a schema for the foreign table">
|
||||
<Listbox.Option
|
||||
key="custom"
|
||||
id="custom"
|
||||
label={`Create a new schema`}
|
||||
value="custom"
|
||||
addOnBefore={() => <IconPlus size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
Create a new schema
|
||||
</Listbox.Option>
|
||||
<Modal.Separator />
|
||||
{/* @ts-ignore */}
|
||||
{schemas.map((schema: PostgresSchema) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={schema.id}
|
||||
id={schema.name}
|
||||
label={schema.name}
|
||||
value={schema.name}
|
||||
addOnBefore={() => <IconDatabase size={16} strokeWidth={1.5} />}
|
||||
>
|
||||
{schema.name}
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
{values.schema === 'custom' && (
|
||||
<Input id="schema_name" name="schema_name" label="Schema name" />
|
||||
)}
|
||||
<Input
|
||||
id="table_name"
|
||||
name="table_name"
|
||||
label="Table name"
|
||||
descriptionText="You can query from this table after the wrapper is enabled."
|
||||
/>
|
||||
{requiredOptions.map((option) => (
|
||||
<Option key={option.name} option={option} />
|
||||
))}
|
||||
<MultiSelect
|
||||
label="Columns"
|
||||
options={table.availableColumns.map((column) => {
|
||||
return {
|
||||
id: column.name,
|
||||
value: column.name,
|
||||
name: column.name,
|
||||
description: column.type,
|
||||
disabled: false,
|
||||
}
|
||||
})}
|
||||
error={errors.columns}
|
||||
value={values.columns}
|
||||
placeholder="Select at least one column"
|
||||
searchPlaceholder="Search for a column"
|
||||
onChange={(columns) => {
|
||||
resetForm({ values: { ...values, columns } })
|
||||
}}
|
||||
/>
|
||||
{optionalOptions.map((option) => (
|
||||
<Option key={option.name} option={option} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { Wrapper } from './Wrappers.types'
|
||||
|
||||
export const WRAPPERS: Wrapper[] = [
|
||||
{
|
||||
name: 'stripe_wrapper',
|
||||
handlerName: 'stripe_fdw_handler',
|
||||
validatorName: 'stripe_fdw_validator',
|
||||
icon: '/img/icons/stripe-icon.svg',
|
||||
extensionName: 'StripeFdw',
|
||||
label: 'Stripe',
|
||||
docsUrl: 'https://supabase.github.io/wrappers/stripe/',
|
||||
server: {
|
||||
name: 'stripe_server',
|
||||
options: [
|
||||
{
|
||||
name: 'api_key_id',
|
||||
label: 'Stripe Secret Key',
|
||||
required: true,
|
||||
encrypted: true,
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
name: 'api_url',
|
||||
label: 'Stripe API URL',
|
||||
defaultValue: 'https://api.stripe.com/v1',
|
||||
required: false,
|
||||
encrypted: false,
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
tables: [
|
||||
{
|
||||
label: 'Balance',
|
||||
description: 'The balance currently on your Stripe account',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'balance_type',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'bigint',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'balance',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Balance Transactions',
|
||||
description: 'Transactions that have contributed to the balance on your Stripe account',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'bigint',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
type: 'bigint',
|
||||
},
|
||||
{
|
||||
name: 'net',
|
||||
type: 'bigint',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'created',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'balance_transactions',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Charges',
|
||||
description: 'Charges made on your Stripe account',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'bigint',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'invoice',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'payment_intent',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'created',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'charges',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Customers',
|
||||
description: 'Customers on your Stripe account',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'created',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'customers',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'rowid_column',
|
||||
label: 'Row ID Column',
|
||||
defaultValue: 'id',
|
||||
editable: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Invoices',
|
||||
description: 'Invoices on your Stripe account',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'subscription',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'total',
|
||||
type: 'bigint',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'period_start',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'period_end',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'invoices',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Payment Intents',
|
||||
description: 'Payment Intents on your Stripe account',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'bigint',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'payment_method',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'created',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'payment_intents',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Products',
|
||||
description: 'Products on your Stripe account',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'bool',
|
||||
},
|
||||
{
|
||||
name: 'default_price',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'created',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'updated',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'products',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'rowid_column',
|
||||
label: 'Row ID Column',
|
||||
defaultValue: 'id',
|
||||
editable: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Subscriptions',
|
||||
description: 'Subscriptions on your Stripe account',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'customer',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'current_period_start',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'current_period_end',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'subscriptions',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'rowid_column',
|
||||
label: 'Row ID Column',
|
||||
defaultValue: 'id',
|
||||
editable: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'firebase_wrapper',
|
||||
handlerName: 'firebase_fdw_handler',
|
||||
validatorName: 'firebase_fdw_validator',
|
||||
icon: '/img/icons/firebase-icon.svg',
|
||||
extensionName: 'FirebaseFdw',
|
||||
label: 'Firebase',
|
||||
docsUrl: 'https://supabase.github.io/wrappers/firebase/',
|
||||
server: {
|
||||
name: 'firebase_server',
|
||||
options: [
|
||||
{
|
||||
name: 'project_id',
|
||||
label: 'Project ID',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: 'sa_key_id',
|
||||
label: 'Service Account Key',
|
||||
required: true,
|
||||
encrypted: true,
|
||||
hidden: true,
|
||||
},
|
||||
// NOTE(alaister): this is a valid option, but it may confuse the basic use case
|
||||
// so I'm omitting it for now. We can add back once we make it's use clearer.
|
||||
// {
|
||||
// name: 'access_token',
|
||||
// label: 'OAuth2 token to access Firebase',
|
||||
// required: false,
|
||||
// encrypted: false,
|
||||
// hidden: false,
|
||||
// },
|
||||
],
|
||||
},
|
||||
tables: [
|
||||
{
|
||||
label: 'Users',
|
||||
description: 'Shows your Firebase users',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'uid',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
defaultValue: 'auth/users',
|
||||
editable: false,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'base_url',
|
||||
label: 'Base URL',
|
||||
defaultValue: 'https://identitytoolkit.googleapis.com/v1/projects',
|
||||
editable: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
label: 'Limit',
|
||||
defaultValue: '10000',
|
||||
editable: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Firestore Collection',
|
||||
description: 'Map to a Firestore collection',
|
||||
availableColumns: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
type: 'timestamp',
|
||||
},
|
||||
{
|
||||
name: 'attrs',
|
||||
type: 'jsonb',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'object',
|
||||
label: 'Object',
|
||||
placeholder: 'firestore/[collection_id]',
|
||||
editable: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'base_url',
|
||||
label: 'Base URL',
|
||||
defaultValue: 'https://firestore.googleapis.com/v1beta1/projects',
|
||||
editable: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
label: 'Limit',
|
||||
defaultValue: '10000',
|
||||
editable: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,239 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Button, IconExternalLink } from 'ui'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { checkPermissions, useParams, useStore } from 'hooks'
|
||||
import { useFDWsQuery } from 'data/fdw/fdws-query'
|
||||
import { WRAPPERS } from './Wrappers.constants'
|
||||
import WrapperRow from './WrapperRow'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
|
||||
const Wrappers = () => {
|
||||
const { ref } = useParams()
|
||||
const { ui, meta } = useStore()
|
||||
const { project } = useProjectContext()
|
||||
const { data, isLoading } = useFDWsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name))
|
||||
|
||||
const [open, setOpen] = useState<string>('')
|
||||
const [isEnabling, setIsEnabling] = useState<boolean>(false)
|
||||
|
||||
const wrappersExtension = meta.extensions.byId('wrappers')
|
||||
const vaultExtension = meta.extensions.byId('supabase_vault')
|
||||
const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined
|
||||
|
||||
const isWrappersEnabled =
|
||||
wrappersExtension !== undefined &&
|
||||
wrappersExtension?.installed_version !== null &&
|
||||
vaultExtension !== undefined &&
|
||||
vaultExtension?.installed_version !== null
|
||||
|
||||
const canToggleWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions')
|
||||
|
||||
const onEnableWrappers = async () => {
|
||||
if (wrappersExtension === undefined || vaultExtension === undefined) return
|
||||
setIsEnabling(true)
|
||||
|
||||
const requiredExtensions = await Promise.all([
|
||||
await meta.extensions.create({
|
||||
schema: wrappersExtension.schema ?? 'extensions',
|
||||
name: wrappersExtension.name,
|
||||
version: wrappersExtension.default_version,
|
||||
cascade: true,
|
||||
}),
|
||||
await meta.extensions.create({
|
||||
schema: vaultExtension.schema ?? 'vault',
|
||||
name: vaultExtension.name,
|
||||
version: vaultExtension.default_version,
|
||||
cascade: true,
|
||||
}),
|
||||
])
|
||||
const errors = requiredExtensions.filter(
|
||||
(res) => res.error && !res.error.message.includes('already exists')
|
||||
)
|
||||
|
||||
if (errors.length > 0) {
|
||||
ui.setNotification({
|
||||
error: errors,
|
||||
category: 'error',
|
||||
message: `Failed to enable Wrappers for your project: ${errors
|
||||
.map((x) => x.message)
|
||||
.join(', ')}`,
|
||||
})
|
||||
} else {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: 'Wrappers is now enabled for your project!',
|
||||
})
|
||||
}
|
||||
|
||||
setIsEnabling(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl text-scale-1200">Foreign Data Wrappers</h3>
|
||||
<div className="text-sm text-scale-900">
|
||||
Query your data warehouse directly from your database, or third-party APIs using SQL.
|
||||
</div>
|
||||
</div>
|
||||
{/* [Joshen TODO] For when we support multiple instances per wrapper */}
|
||||
{/* <div>
|
||||
<Dropdown
|
||||
side="bottom"
|
||||
align="end"
|
||||
size="small"
|
||||
overlay={
|
||||
<>
|
||||
{WRAPPERS.map((wrapper, idx) => (
|
||||
<>
|
||||
<Dropdown.Item
|
||||
key={wrapper.name}
|
||||
icon={
|
||||
<Image
|
||||
src={wrapper.icon}
|
||||
width={20}
|
||||
height={20}
|
||||
alt={`${wrapper.name} wrapper icon`}
|
||||
/>
|
||||
}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{wrapper.label}
|
||||
</Dropdown.Item>
|
||||
{idx !== WRAPPERS.length - 1 && <Dropdown.Separator />}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button type="primary">Add wrapper</Button>
|
||||
</Dropdown>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{isWrappersEnabled ? (
|
||||
<div>
|
||||
{WRAPPERS.map((wrapper, i) => {
|
||||
return (
|
||||
<WrapperRow
|
||||
key={i}
|
||||
wrapper={wrapper}
|
||||
isLoading={isLoading}
|
||||
isEnabled={enabledWrapperNamesSet.has(wrapper.server.name)}
|
||||
isOpen={open === wrapper.name}
|
||||
onOpen={(wrapperName) => {
|
||||
if (open !== wrapperName) setOpen(wrapperName)
|
||||
else setOpen('')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
className="w-full px-12 py-12 bg-white bg-no-repeat border rounded dark:bg-scale-200 border-scale-500"
|
||||
style={{
|
||||
backgroundSize: '45%',
|
||||
backgroundPosition: '105% 40%',
|
||||
backgroundImage: ui.isDarkTheme
|
||||
? 'url("/img/wrappers-dark.png")'
|
||||
: 'url("/img/wrappers-light.png")',
|
||||
}}
|
||||
>
|
||||
<div className="w-3/5 space-y-8">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-lg">Supabase Wrappers</h4>
|
||||
<p className="text-sm text-scale-1100">
|
||||
Supabase Wrappers is a framework for building Postgres Foreign Data Wrappers (FDW)
|
||||
which connect Postgres to external systems. Query your data warehouse or
|
||||
third-party APIs directly from your database.
|
||||
</p>
|
||||
</div>
|
||||
{isNotAvailable ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded border border-scale-500 px-4 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Wrappers is not available for this project yet.
|
||||
</p>
|
||||
<p className="text-scale-1000 text-sm">
|
||||
Do reach out to us if you're interested!
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20wrappers`}
|
||||
>
|
||||
<a target="_blank">
|
||||
<Button type="primary">Contact us</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 my-1 ml-[1px]">
|
||||
<Link href="https://supabase.com/docs">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
About Wrappers
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="https://supabase.com/docs">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
About Wrappers
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isEnabling}
|
||||
disabled={isNotAvailable || isEnabling || !canToggleWrappers}
|
||||
onClick={() => onEnableWrappers()}
|
||||
>
|
||||
Enable Wrappers
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canToggleWrappers && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to enable Wrappers for this project
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(Wrappers)
|
||||
@@ -0,0 +1,46 @@
|
||||
export type Wrapper = {
|
||||
name: string
|
||||
handlerName: string
|
||||
validatorName: string
|
||||
icon: string
|
||||
extensionName: string
|
||||
label: string
|
||||
server: Server
|
||||
tables: Table[]
|
||||
docsUrl: string
|
||||
}
|
||||
|
||||
export type ServerOption = {
|
||||
name: string
|
||||
label: string
|
||||
required: boolean
|
||||
encrypted: boolean
|
||||
hidden: boolean
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
export type Server = {
|
||||
name: string
|
||||
options: ServerOption[]
|
||||
}
|
||||
|
||||
export type TableOption = {
|
||||
name: string
|
||||
defaultValue?: string
|
||||
editable: boolean
|
||||
required: boolean
|
||||
label?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export type Table = {
|
||||
label: string
|
||||
description?: string
|
||||
availableColumns: AvailableColumn[]
|
||||
options: TableOption[]
|
||||
}
|
||||
|
||||
export type AvailableColumn = {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const makeValidateRequired =
|
||||
(options: { name: string; required: boolean }[]) => (values: any) => {
|
||||
const requiredOptionsSet = new Set(
|
||||
options.filter((option) => option.required).map((option) => option.name)
|
||||
)
|
||||
|
||||
const errors = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
requiredOptionsSet.has(key) && (Array.isArray(value) ? value.length < 1 : !value)
|
||||
)
|
||||
.map(([key]) => {
|
||||
if (key === 'table_name') return [key, 'Please provide a name for your table']
|
||||
else if (key === 'columns') return [key, 'Please select at least one column']
|
||||
else return [key, 'This field is required']
|
||||
})
|
||||
)
|
||||
|
||||
return errors
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import RolesSettings from './Roles/RolesSettings'
|
||||
|
||||
import Extensions from './Extensions/Extensions'
|
||||
|
||||
import Wrappers from './Wrappers/Wrappers'
|
||||
|
||||
import PublicationsList from './Publications/PublicationsList'
|
||||
import PublicationsTables from './Publications/PublicationsTables'
|
||||
|
||||
@@ -25,6 +27,7 @@ export {
|
||||
RolesList,
|
||||
RolesSettings,
|
||||
Extensions,
|
||||
Wrappers,
|
||||
PublicationsList,
|
||||
PublicationsTables,
|
||||
BackupsList,
|
||||
|
||||
@@ -19,7 +19,10 @@ const AddNewSecretModal: FC<Props> = ({ visible, onClose }) => {
|
||||
const defaultKey = keys.find((key) => key.name === 'default_vault_key') || keys[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) setSelectedKeyId(defaultKey?.id)
|
||||
if (visible) {
|
||||
setShowSecretValue(false)
|
||||
setSelectedKeyId(defaultKey?.id)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const validate = (values: any) => {
|
||||
|
||||
@@ -2,11 +2,10 @@ import Link from 'next/link'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FC, useState } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, IconAlertCircle, IconExternalLink } from 'ui'
|
||||
import { Button, IconExternalLink } from 'ui'
|
||||
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { checkPermissions, useParams, useStore } from 'hooks'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
|
||||
interface Props {}
|
||||
|
||||
@@ -77,35 +76,36 @@ const VaultToggle: FC<Props> = () => {
|
||||
</p>
|
||||
</div>
|
||||
{isNotAvailable ? (
|
||||
<InformationBox
|
||||
hideCollapse
|
||||
defaultVisibility
|
||||
icon={<IconAlertCircle strokeWidth={1.5} size={18} />}
|
||||
title="Vault is not available for your project"
|
||||
description={
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Do reach out to us if you're interested in having Vault for this project!
|
||||
<div className="space-y-4">
|
||||
<div className="rounded border border-scale-500 px-4 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Vault is not available for this project yet.
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 my-1 ml-[1px]">
|
||||
<Link href="https://supabase.com/docs">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
About Vault
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20vault`}
|
||||
>
|
||||
<a target="_blank">
|
||||
<Button type="primary">Contact us</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<p className="text-scale-1000 text-sm">
|
||||
Do reach out to us if you're interested!
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20vault`}
|
||||
>
|
||||
<a target="_blank">
|
||||
<Button type="primary">Contact us</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 my-1 ml-[1px]">
|
||||
<Link href="https://supabase.com/docs">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink />}>
|
||||
About Vault
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="https://supabase.com/docs">
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ActionBarProps {
|
||||
backButtonLabel?: string
|
||||
applyFunction?: (resolve: any) => void
|
||||
closePanel: () => void
|
||||
formId?: string
|
||||
}
|
||||
const ActionBar: FC<ActionBarProps> = ({
|
||||
loading = false,
|
||||
@@ -20,6 +21,7 @@ const ActionBar: FC<ActionBarProps> = ({
|
||||
backButtonLabel = 'Back',
|
||||
applyFunction = undefined,
|
||||
closePanel = () => {},
|
||||
formId,
|
||||
}) => {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
|
||||
@@ -53,7 +55,13 @@ const ActionBar: FC<ActionBarProps> = ({
|
||||
) : !hideApply ? (
|
||||
// New solution, when using the Form component, loading is handled by the Form itself
|
||||
// Does not require applyFunction() callback
|
||||
<Button size="small" disabled={disableApply} loading={loading} htmlType="submit">
|
||||
<Button
|
||||
size="small"
|
||||
disabled={disableApply}
|
||||
loading={loading}
|
||||
htmlType="submit"
|
||||
form={formId}
|
||||
>
|
||||
{applyButtonLabel}
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Project } from 'types'
|
||||
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
|
||||
import { useFlag } from 'hooks'
|
||||
|
||||
export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
const ref = project?.ref ?? 'default'
|
||||
@@ -7,6 +8,8 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
const HOOKS_RELEASED = '2021-07-30T15:33:54.383Z'
|
||||
const showHooksRoute = project?.inserted_at ? project.inserted_at > HOOKS_RELEASED : false
|
||||
|
||||
const foreignDataWrappersEnabled = useFlag('foreignDataWrappers')
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Database',
|
||||
@@ -32,6 +35,16 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
url: `/project/${ref}/database/extensions`,
|
||||
items: [],
|
||||
},
|
||||
...(foreignDataWrappersEnabled
|
||||
? [
|
||||
{
|
||||
name: 'Wrappers',
|
||||
key: 'wrappers',
|
||||
url: `/project/${ref}/database/wrappers`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ name: 'Roles', key: 'roles', url: `/project/${ref}/database/roles`, items: [] },
|
||||
{
|
||||
name: 'Replication',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useStore } from 'hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { createContext, PropsWithChildren, useContext, useMemo } from 'react'
|
||||
import { Project } from 'types'
|
||||
|
||||
type ProjectContextType = {
|
||||
project: Project | undefined
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const ProjectContext = createContext<ProjectContextType>({
|
||||
project: undefined,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
export default ProjectContext
|
||||
|
||||
export const useProjectContext = () => useContext(ProjectContext)
|
||||
|
||||
type ProjectContextProviderProps = {
|
||||
projectRef: string | undefined
|
||||
}
|
||||
|
||||
export const ProjectContextProvider = observer(
|
||||
({ projectRef, children }: PropsWithChildren<ProjectContextProviderProps>) => {
|
||||
// TODO(alaister): This will eventually be replaced with a react-query query
|
||||
// once we remove mobx
|
||||
const { ui } = useStore()
|
||||
|
||||
const value = useMemo<ProjectContextType>(() => {
|
||||
if (ui.selectedProject?.ref === projectRef) {
|
||||
return {
|
||||
project: ui.selectedProject,
|
||||
isLoading: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
project: undefined,
|
||||
isLoading: true,
|
||||
}
|
||||
}, [ui.selectedProject?.ref, projectRef])
|
||||
|
||||
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
|
||||
}
|
||||
)
|
||||
@@ -2,7 +2,7 @@ import Head from 'next/head'
|
||||
import { FC, ReactNode, PropsWithChildren, Fragment } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useStore, withAuth, useFlag } from 'hooks'
|
||||
import { useStore, withAuth, useFlag, useParams } from 'hooks'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
|
||||
import Connecting from 'components/ui/Loading'
|
||||
@@ -12,6 +12,7 @@ import LayoutHeader from './LayoutHeader'
|
||||
import ConnectingState from './ConnectingState'
|
||||
import PausingState from './PausingState'
|
||||
import BuildingState from './BuildingState'
|
||||
import { ProjectContextProvider } from './ProjectContext'
|
||||
import RestoringState from './RestoringState'
|
||||
|
||||
interface Props {
|
||||
@@ -32,12 +33,13 @@ const ProjectLayout = ({
|
||||
hideHeader = false,
|
||||
hideIconBar = false,
|
||||
}: PropsWithChildren<Props>) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { ui } = useStore()
|
||||
const ongoingIncident = useFlag('ongoingIncident')
|
||||
const projectName = ui.selectedProject?.name
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectContextProvider projectRef={projectRef}>
|
||||
<Head>
|
||||
<title>
|
||||
{title ? `${title} | Supabase` : projectName ? `${projectName} | Supabase` : 'Supabase'}
|
||||
@@ -55,14 +57,14 @@ const ProjectLayout = ({
|
||||
</MenuBarWrapper>
|
||||
|
||||
<main
|
||||
className="flex w-full flex-1 flex-col overflow-x-hidden"
|
||||
className="flex flex-col flex-1 w-full overflow-x-hidden"
|
||||
style={{ height: ongoingIncident ? 'calc(100vh - 44px)' : '100vh' }}
|
||||
>
|
||||
{!hideHeader && <LayoutHeader />}
|
||||
<ContentWrapper isLoading={isLoading}>{children}</ContentWrapper>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
</ProjectContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const BadgeSelected: FC<{ name: string; handleRemove: () => void }> = ({
|
||||
'flex items-center space-x-2 rounded bg-gray-500',
|
||||
'py-0.5 px-2 text-sm',
|
||||
].join(' ')}
|
||||
onClick={(e: any) => e.preventDefault()}
|
||||
>
|
||||
<span>{name}</span>
|
||||
<IconX
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface MultiSelectOption {
|
||||
id: string | number
|
||||
value: string
|
||||
name: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
@@ -112,7 +113,7 @@ export default function MultiSelect({
|
||||
|
||||
return (
|
||||
<div className={`form-group ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
|
||||
{label && <label>{label}</label>}
|
||||
{label && <label className="!w-full">{label}</label>}
|
||||
<div
|
||||
className={[
|
||||
'form-control form-control--multi-select',
|
||||
@@ -166,7 +167,12 @@ export default function MultiSelect({
|
||||
`${active ? ' dark:bg-green-600 dark:bg-opacity-25' : ''}`,
|
||||
].join(' ')}
|
||||
>
|
||||
<span>{option.name}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>{option.name}</p>
|
||||
{option.description !== undefined && (
|
||||
<p className="opacity-50">{option.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{active && (
|
||||
<IconCheck
|
||||
size={16}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { delete_ } from 'lib/common/fetch'
|
||||
import { getCustomDomains } from './custom-domains-query'
|
||||
import { customDomainKeys } from './keys'
|
||||
|
||||
export type CustomDomainDeleteVariables = {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { AvailableColumn, Wrapper } from 'components/interfaces/Database/Wrappers/Wrappers.types'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { useStore } from 'hooks'
|
||||
|
||||
export type FDWCreateVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
wrapper: Wrapper
|
||||
formState: {
|
||||
[k: string]: string
|
||||
}
|
||||
newTables: any[]
|
||||
}
|
||||
|
||||
export function getFDWCreateSql({
|
||||
wrapper,
|
||||
formState,
|
||||
newTables,
|
||||
}: Pick<FDWCreateVariables, 'wrapper' | 'formState' | 'newTables'>) {
|
||||
const newSchemasSql = newTables
|
||||
.filter((table) => table.is_new_schema)
|
||||
.map((table) => /* SQL */ `create schema if not exists ${table.schema_name};`)
|
||||
.join('\n')
|
||||
|
||||
const createWrapperSql = /* SQL */ `
|
||||
create foreign data wrapper ${wrapper.name}
|
||||
handler ${wrapper.handlerName}
|
||||
validator ${wrapper.validatorName};
|
||||
`
|
||||
|
||||
const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted)
|
||||
const unencryptedOptions = wrapper.server.options.filter((option) => !option.encrypted)
|
||||
|
||||
const createEncryptedKeysSqlArray = encryptedOptions.map((option) => {
|
||||
const key = `${wrapper.name}_${option.name}`
|
||||
// Escape single quotes in postgresql by doubling them up
|
||||
const value = (formState[option.name] || '').replace(/'/g, "''")
|
||||
|
||||
return /* SQL */ `
|
||||
select pgsodium.create_key(
|
||||
name := '${key}'
|
||||
);
|
||||
|
||||
select vault.create_secret (
|
||||
new_secret := '${value}',
|
||||
new_name := '${key}',
|
||||
new_key_id := (select id from pgsodium.valid_key where name = '${key}')
|
||||
);
|
||||
`
|
||||
})
|
||||
|
||||
const createEncryptedKeysSql = createEncryptedKeysSqlArray.join('\n')
|
||||
|
||||
const encryptedOptionsSqlArray = encryptedOptions.map((option) => `${option.name} ''%s''`)
|
||||
const unencryptedOptionsSqlArray = unencryptedOptions.map(
|
||||
(option) => `${option.name} ''${formState[option.name]}''`
|
||||
)
|
||||
const optionsSqlArray = [...encryptedOptionsSqlArray, ...unencryptedOptionsSqlArray].join(',')
|
||||
|
||||
const createServerSql = /* SQL */ `
|
||||
do $$
|
||||
declare
|
||||
${encryptedOptions.map((option) => `v_${option.name} text;`).join('\n')}
|
||||
begin
|
||||
${encryptedOptions
|
||||
.map(
|
||||
(option) =>
|
||||
/* SQL */ `select id into v_${option.name} from pgsodium.valid_key where name = '${wrapper.name}_${option.name}' limit 1;`
|
||||
)
|
||||
.join('\n')}
|
||||
|
||||
execute format(
|
||||
E'create server ${wrapper.server.name}\\n'
|
||||
' foreign data wrapper ${wrapper.name}\\n'
|
||||
' options (\\n'
|
||||
' ${optionsSqlArray}\\n'
|
||||
' );',
|
||||
${encryptedOptions.map((option) => `v_${option.name}`).join(',\n')}
|
||||
);
|
||||
end $$;
|
||||
`
|
||||
|
||||
const createTablesSql = newTables
|
||||
.map((newTable) => {
|
||||
const table = wrapper.tables[newTable.index]
|
||||
|
||||
const columns: AvailableColumn[] = newTable.columns
|
||||
.map((name: string) => table.availableColumns.find((c) => c.name === name))
|
||||
.filter(Boolean)
|
||||
|
||||
return /* SQL */ `
|
||||
create foreign table ${newTable.schema_name}.${newTable.table_name} (
|
||||
${columns.map((column) => `${column.name} ${column.type}`).join(',\n ')}
|
||||
)
|
||||
server ${wrapper.server.name}
|
||||
options (
|
||||
${Object.entries(newTable)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key !== 'table_name' &&
|
||||
key !== 'schema_name' &&
|
||||
key !== 'columns' &&
|
||||
key !== 'index'
|
||||
)
|
||||
.map(([key, value]) => `${key} '${value}'`)
|
||||
.join(',\n ')}
|
||||
);
|
||||
`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const sql = /* SQL */ `
|
||||
begin;
|
||||
|
||||
${newSchemasSql}
|
||||
|
||||
${createWrapperSql}
|
||||
|
||||
${createEncryptedKeysSql}
|
||||
|
||||
${createServerSql}
|
||||
|
||||
${createTablesSql}
|
||||
|
||||
commit;
|
||||
`
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
export async function createFDW({
|
||||
projectRef,
|
||||
connectionString,
|
||||
wrapper,
|
||||
formState,
|
||||
newTables,
|
||||
}: FDWCreateVariables) {
|
||||
const sql = getFDWCreateSql({ wrapper, formState, newTables })
|
||||
|
||||
const { result } = await executeSql({ projectRef, connectionString, sql })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type FDWCreateData = Awaited<ReturnType<typeof createFDW>>
|
||||
|
||||
export const useFDWCreateMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<UseMutationOptions<FDWCreateData, unknown, FDWCreateVariables>, 'mutationFn'> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { vault } = useStore()
|
||||
|
||||
return useMutation<FDWCreateData, unknown, FDWCreateVariables>((vars) => createFDW(vars), {
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])),
|
||||
vault.load(),
|
||||
])
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { Wrapper } from 'components/interfaces/Database/Wrappers/Wrappers.types'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { useStore } from 'hooks'
|
||||
|
||||
export type FDWDeleteVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
wrapper: Wrapper
|
||||
}
|
||||
|
||||
export const getDeleteFDWSql = ({ wrapper }: Pick<FDWDeleteVariables, 'wrapper'>) => {
|
||||
const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted)
|
||||
|
||||
const deleteEncryptedSecretsSqlArray = encryptedOptions.map((option) => {
|
||||
const key = `${wrapper.name}_${option.name}`
|
||||
|
||||
return /* SQL */ `
|
||||
delete from vault.secrets where key_id = (select id from pgsodium.valid_key where name = '${key}');
|
||||
|
||||
delete from pgsodium.key where name = '${key}';
|
||||
`
|
||||
})
|
||||
|
||||
const deleteEncryptedSecretsSql = deleteEncryptedSecretsSqlArray.join('\n')
|
||||
|
||||
const sql = /* SQL */ `
|
||||
begin;
|
||||
|
||||
drop foreign data wrapper if exists ${wrapper.name} cascade;
|
||||
|
||||
${deleteEncryptedSecretsSql}
|
||||
|
||||
commit;
|
||||
`
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
export async function deleteFDW({ projectRef, connectionString, wrapper }: FDWDeleteVariables) {
|
||||
if (!projectRef) {
|
||||
throw new Error('projectRef is required')
|
||||
}
|
||||
|
||||
const sql = getDeleteFDWSql({ wrapper })
|
||||
|
||||
const { result } = await executeSql({ projectRef, connectionString, sql })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type FDWDeleteData = Awaited<ReturnType<typeof deleteFDW>>
|
||||
|
||||
export const useFDWDeleteMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<UseMutationOptions<FDWDeleteData, unknown, FDWDeleteVariables>, 'mutationFn'> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { vault } = useStore()
|
||||
|
||||
return useMutation<FDWDeleteData, unknown, FDWDeleteVariables>((vars) => deleteFDW(vars), {
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])),
|
||||
vault.load(),
|
||||
])
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ExecuteSqlData, useExecuteSqlPrefetch, useExecuteSqlQuery } from '../sql/execute-sql-query'
|
||||
|
||||
export const getFDWsSql = () => {
|
||||
const sql = /* SQL */ `
|
||||
select
|
||||
s.oid as "id",
|
||||
s.srvname as "name",
|
||||
(
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', c.oid,
|
||||
'name', c.relname,
|
||||
'columns', (
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', a.attname,
|
||||
'type', pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||
)
|
||||
)
|
||||
from pg_catalog.pg_attribute a
|
||||
where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
|
||||
)
|
||||
)
|
||||
)
|
||||
from pg_catalog.pg_class c
|
||||
where c.oid = any (select t.ftrelid from pg_catalog.pg_foreign_table t where t.ftserver = s.oid)
|
||||
) as "tables"
|
||||
from pg_catalog.pg_foreign_server s;
|
||||
`
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
export type FDWColumn = {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type FDWTable = {
|
||||
id: string
|
||||
name: string
|
||||
columns: FDWColumn[]
|
||||
}
|
||||
|
||||
export type FDW = {
|
||||
id: string
|
||||
name: string
|
||||
tables: FDWTable[]
|
||||
}
|
||||
|
||||
export type FDWsResponse = {
|
||||
result: FDW[]
|
||||
}
|
||||
|
||||
export type FDWsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
}
|
||||
|
||||
export type FDWsData = FDWsResponse
|
||||
export type FDWsError = unknown
|
||||
|
||||
export const useFDWsQuery = <TData extends FDWsData = FDWsData>(
|
||||
{ projectRef, connectionString }: FDWsVariables,
|
||||
options: UseQueryOptions<ExecuteSqlData, FDWsError, TData> = {}
|
||||
) =>
|
||||
useExecuteSqlQuery(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getFDWsSql(),
|
||||
queryKey: ['fdws'],
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
export const useFDWsPrefetch = ({ projectRef, connectionString }: FDWsVariables) => {
|
||||
return useExecuteSqlPrefetch({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: getFDWsSql(),
|
||||
queryKey: ['fdws'],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
|
||||
|
||||
/* Execute Query */
|
||||
|
||||
export const useExecuteSqlMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<UseMutationOptions<ExecuteSqlData, unknown, ExecuteSqlVariables>, 'mutationFn'> = {}) => {
|
||||
return useMutation<ExecuteSqlData, unknown, ExecuteSqlVariables>((args) => executeSql(args), {
|
||||
async onSuccess(data, variables, context) {
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { QueryKey, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
|
||||
import md5 from 'blueimp-md5'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useCallback } from 'react'
|
||||
import { sqlKeys } from './keys'
|
||||
|
||||
export type ExecuteSqlVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
sql: string
|
||||
queryKey?: QueryKey
|
||||
}
|
||||
|
||||
export async function executeSql(
|
||||
{ projectRef, connectionString, sql }: ExecuteSqlVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!projectRef) {
|
||||
throw new Error('projectRef is required')
|
||||
}
|
||||
|
||||
let headers = new Headers()
|
||||
|
||||
if (connectionString) {
|
||||
headers.set('x-connection-encrypted', connectionString)
|
||||
}
|
||||
|
||||
const response = await post(
|
||||
`${API_URL}/pg-meta/${projectRef}/query`,
|
||||
{ query: sql },
|
||||
{ headers: Object.fromEntries(headers), signal }
|
||||
)
|
||||
if (response.error) {
|
||||
throw response.error
|
||||
}
|
||||
|
||||
return { result: response }
|
||||
}
|
||||
|
||||
export type ExecuteSqlData = Awaited<ReturnType<typeof executeSql>>
|
||||
export type ExecuteSqlError = unknown
|
||||
|
||||
export const useExecuteSqlQuery = <TData = ExecuteSqlData>(
|
||||
{ projectRef, connectionString, sql, queryKey }: ExecuteSqlVariables,
|
||||
{ enabled = true, ...options }: UseQueryOptions<ExecuteSqlData, ExecuteSqlError, TData> = {}
|
||||
) =>
|
||||
useQuery<ExecuteSqlData, ExecuteSqlError, TData>(
|
||||
sqlKeys.query(projectRef, queryKey ?? [md5(sql)]),
|
||||
({ signal }) => executeSql({ projectRef, connectionString, sql }, signal),
|
||||
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
|
||||
)
|
||||
|
||||
export const useExecuteSqlPrefetch = ({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey,
|
||||
}: ExecuteSqlVariables) => {
|
||||
const client = useQueryClient()
|
||||
|
||||
return useCallback(() => {
|
||||
if (projectRef) {
|
||||
client.prefetchQuery(sqlKeys.query(projectRef, queryKey ?? [md5(sql)]), ({ signal }) =>
|
||||
executeSql({ projectRef, connectionString, sql }, signal)
|
||||
)
|
||||
}
|
||||
}, [projectRef])
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { QueryKey } from '@tanstack/react-query'
|
||||
|
||||
export const sqlKeys = {
|
||||
query: (projectRef: string | undefined, queryKey: QueryKey) =>
|
||||
['projects', projectRef, 'query', ...queryKey] as const,
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
"ajv": "^8.6.3",
|
||||
"analytics-node": "^3.5.0",
|
||||
"awesome-debounce-promise": "^2.1.0",
|
||||
"blueimp-md5": "^2.19.0",
|
||||
"clipboard": "^2.0.8",
|
||||
"common": "*",
|
||||
"config": "*",
|
||||
@@ -103,6 +104,7 @@
|
||||
"@testing-library/dom": "^8.11.1",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/blueimp-md5": "^2.18.0",
|
||||
"@types/crypto-js": "^4.0.2",
|
||||
"@types/file-saver": "^2.0.2",
|
||||
"@types/json-logic-js": "^1.2.1",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { NextPageWithLayout } from 'types'
|
||||
import { checkPermissions } from 'hooks'
|
||||
import { DatabaseLayout } from 'components/layouts'
|
||||
import { Wrappers } from 'components/interfaces/Database'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { FormsContainer } from 'components/ui/Forms'
|
||||
|
||||
const DatabaseWrappers: NextPageWithLayout = () => {
|
||||
const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers')
|
||||
if (!canReadWrappers) {
|
||||
return <NoPermission isFullPage resourceText="view foreign data wrappers" />
|
||||
}
|
||||
|
||||
return <Wrappers />
|
||||
}
|
||||
|
||||
DatabaseWrappers.getLayout = (page) => (
|
||||
<DatabaseLayout title="Wrappers">
|
||||
<FormsContainer>{page}</FormsContainer>
|
||||
</DatabaseLayout>
|
||||
)
|
||||
|
||||
export default observer(DatabaseWrappers)
|
||||
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="111px" height="151px" viewBox="0 0 111 151" version="1.1">
|
||||
<!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>logo_lockup_firebase_vertical</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="-3.48177083%" y1="17.0266727%" x2="100.833333%" y2="92.8868033%" id="linearGradient-1">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%"/>
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0" offset="100%"/>
|
||||
</linearGradient>
|
||||
<path d="M95.7984919,33.6246194 C95.5859256,32.3610745 94.6848395,31.3206592 93.4615125,30.9262876 C92.2381855,30.5319161 90.8962602,30.8492378 89.9812105,31.7492661 L72.2727216,49.5651223 L58.2428077,23.0544463 C57.6801918,21.9189555 56.5194912,21.2001264 55.2486188,21.2001264 C53.9777463,21.2001264 52.8170458,21.9189555 52.2544298,23.0544463 L44.6406351,37.5458126 L25.3922776,1.91410025 C23.852409,-0.984173003 19.489448,-0.216983024 18.9761585,3.02226355 L0.497735304,121.16952 L50.0301752,148.873603 C53.1471684,150.578603 56.9223279,150.578603 60.0393211,148.873603 L109.999502,121.16952 L95.7984919,33.6246194 Z" id="path-2"/>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="styleguide" transform="translate(-664.000000, -1272.000000)">
|
||||
<g id="Group-16" transform="translate(40.000000, 1187.000000)">
|
||||
<g id="Group-9" transform="translate(114.000000, 0.000000)">
|
||||
<g id="Group-26" transform="translate(405.000000, 1.000000)">
|
||||
<g id="logo_lockup_firebase_vertical" transform="translate(105.193370, 83.977901)">
|
||||
<g id="_Group_">
|
||||
<mask id="mask-3" fill="white">
|
||||
<use xlink:href="#path-2"/>
|
||||
</mask>
|
||||
<use id="_Path_5" fill="url(#linearGradient-1)" fill-rule="nonzero" xlink:href="#path-2"/>
|
||||
<g id="_Group_2" mask="url(#mask-3)">
|
||||
<g transform="translate(-6.346125, -0.160154)">
|
||||
<g id="_Group_3" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M6.84386044,121.329674 L25.4078319,3.18241769 C25.9211214,-0.0568288873 30.2840824,-0.824018866 31.823951,2.07425439 L50.9867602,37.7059667 L58.600555,23.2146005 C59.189165,22.0849984 60.360174,21.3760986 61.637518,21.3760986 C62.9148621,21.3760986 64.0858711,22.0849984 64.6744811,23.2146005 L116.345627,121.329674 L6.84386044,121.329674 Z" id="_Path_" fill="#FFA000" fill-rule="nonzero"/>
|
||||
<g id="_Group_4" transform="translate(0.000000, 30.119310)">
|
||||
<image id="Bitmap" opacity="0.12" x="0" y="0" width="74.4269822" height="94.6200974" xlink:href="data:image/png;base64,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 truncated
|
||||
<polygon id="Shape" fill="#F57C00" fill-rule="nonzero" points="70.8339555 45.1789654 50.901212 7.58665646 6.84386044 91.2103641"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="_Group_5" stroke-width="1" fill="none" fill-rule="evenodd" transform="translate(3.421930, 23.299844)">
|
||||
<image id="_Image_" opacity="0.2" x="0" y="0" width="119.767558" height="131.27473" xlink:href="data:image/png;base64,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 truncated
|
||||
<g id="_Group_6" transform="translate(3.421930, 7.387755)" fill-rule="nonzero" fill="#FFCA28">
|
||||
<path d="M109.501767,90.6420753 L95.3007566,3.09717436 C95.0881903,1.8336295 94.1871042,0.793214154 92.9637772,0.398842606 C91.7404502,0.00447105695 90.3985249,0.321792836 89.4834752,1.22182108 L0,90.6420753 L49.5324399,118.346158 C52.6494331,120.051158 56.4245926,120.051158 59.5415858,118.346158 L109.501767,90.6420753 Z" id="_Path_2"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M102.144617,33.7847735 C101.932051,32.5212286 101.030965,31.4808133 99.8076376,31.0864418 C98.5843106,30.6920702 97.2423854,31.009392 96.3273356,31.9094202 L78.6188467,49.7252764 L64.5889329,23.2146005 C64.0263169,22.0791096 62.8656164,21.3602806 61.5947439,21.3602806 C60.3238715,21.3602806 59.1631709,22.0791096 58.600555,23.2146005 L50.9867602,37.7059667 L31.7384028,2.07425439 C30.1985342,-0.824018866 25.8355731,-0.0568288873 25.3222836,3.18241769 L6.84386044,121.329674 L6.75831218,121.329674 L6.84386044,121.414918 L7.35714997,121.670648 L78.5332985,50.5777097 L96.3273356,32.8470969 C97.2423854,31.9470686 98.5843106,31.6297468 99.8076376,32.0241184 C101.030965,32.4184899 101.932051,33.4589053 102.144617,34.7224501 L116.174531,121.414918 L116.345627,121.329674 L102.144617,33.7847735 Z M7.01495695,121.159188 L25.4078319,4.034851 C25.9211214,0.795604422 30.2840824,0.0284144437 31.823951,2.9266877 L50.9867602,38.5584 L58.600555,24.0670338 C59.189165,22.9374317 60.360174,22.2285319 61.637518,22.2285319 C62.9148621,22.2285319 64.0858711,22.9374317 64.6744811,24.0670338 L78.362202,49.9810064 L7.01495695,121.159188 Z" id="_Compound_Path_" fill-opacity="0.2" fill="#FFFFFF" fill-rule="nonzero"/>
|
||||
<path d="M66.3854462,148.181324 C63.2684531,149.886324 59.4932935,149.886324 56.3763003,148.181324 L6.92940869,120.562484 L6.84386044,121.329674 L56.3763003,149.033757 C59.4932935,150.738757 63.2684531,150.738757 66.3854462,149.033757 L116.345627,121.329674 L116.260079,120.562484 L66.3854462,148.181324 Z" id="_Path_3" fill="#A52714" fill-rule="nonzero" opacity="0.2"/>
|
||||
</g>
|
||||
</g>
|
||||
<polygon id="_Path_4" mask="url(#mask-3)" points="-26.8777064 -6.69547618 137.374944 -6.69547618 137.374944 156.971719 -26.8777064 156.971719"/>
|
||||
<path d="M95.7984919,33.6246194 C95.5859256,32.3610745 94.6848395,31.3206592 93.4615125,30.9262876 C92.2381855,30.5319161 90.8962602,30.8492378 89.9812105,31.7492661 L72.2727216,49.5651223 L58.2428077,23.0544463 C57.6801918,21.9189555 56.5194912,21.2001264 55.2486188,21.2001264 C53.9777463,21.2001264 52.8170458,21.9189555 52.2544298,23.0544463 L44.6406351,37.5458126 L25.3922776,1.91410025 C23.852409,-0.984173003 19.489448,-0.216983024 18.9761585,3.02226355 L0.497735304,121.16952 L50.0301752,148.873603 C53.1471684,150.578603 56.9223279,150.578603 60.0393211,148.873603 L109.999502,121.16952 L95.7984919,33.6246194 Z" id="_Path_5" fill="url(#linearGradient-1)" fill-rule="nonzero" mask="url(#mask-3)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28.87 28.87"><g data-name="Layer 2"><g data-name="Layer 1"><rect width="28.87" height="28.87" fill="#6772e5" rx="6.48" ry="6.48"/><path fill="#fff" fill-rule="evenodd" d="M13.3 11.2c0-.69.57-1 1.49-1a9.84 9.84 0 0 1 4.37 1.13V7.24a11.6 11.6 0 0 0-4.36-.8c-3.56 0-5.94 1.86-5.94 5 0 4.86 6.68 4.07 6.68 6.17 0 .81-.71 1.07-1.68 1.07A11.06 11.06 0 0 1 9 17.25v4.19a12.19 12.19 0 0 0 4.8 1c3.65 0 6.17-1.8 6.17-5 .03-5.21-6.67-4.27-6.67-6.24z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 509 B |
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
Reference in new issue
Block a user