Merge pull request #10614 from supabase/feat/fdw

feat: fdw
This commit is contained in:
Joshen Lim authored and GitHub committed 2022-12-13 15:13:52 +07:00
commit dbbf263181
30 files changed
+2104 -40

No files matched your search

+26
View File
@@ -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 -2
View File
@@ -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 = {
+169
View File
@@ -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,
})
}
+75
View File
@@ -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,
})
}
+85
View File
@@ -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'],
})
}
+16
View File
@@ -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,
})
}
+69
View File
@@ -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])
}
+6
View File
@@ -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,
}
+2
View File
@@ -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)
+51
View File
@@ -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,iVBORw0KGgoAAAANSUhEUgAAAFcAAABwCAYAAABrYeziAAAABGdBTUEAA1teXP8meAAAAAlwSFlzAAADsQAAA7EB9YPtSQAAEqpJREFUeAHtnctyG8mVhjOzCjfeQYkUKXHaVLeldsvhxYQfYPwA3uoV5jVkvoZfobeO8Faz8sLhhcPhnnaPQ6bdpCiKoiiSAkEAVZnznQQKBCkAJIAqAqBVUrEuWVXI/OvHnydP5klo55z6vGSDgMnmsZ+fKgiEdxkGzdL5vdQUlm9q56lMi38nwU1ABUX97XOlvztQ+tkKuH6rnNJa3RbI+hZfZKYsaT8cZLcA9b9+pcxfdlSwdr5hjqzVZWPcm+KO/cWGiv/npbIvILHQuH1fBjt3C9wWsOu/VEF5fyNU+Uo+Cku5KI5NGAQ2jKoNVZ+tHz3Yifb+pOKsAb5TFVrCWLX3MKcWT0rnJbUY6/q9sNBYka0cy3lJF2bL9RkQtv3Iu6O5sPYZ+rr9x81wLjwqQNYFrYNlvvtLzoY5p+KGtsGH2DkzF1bc9vamffZ8u4EG66zk4c4wV1j45Stllgsfcy4fzEY6XIZC61qZnygXf+m3HMt5SZfr5Pos2Xs3mNti7as/oLPFqBjqxnzk7H0AfeS0e6SdLmGUVbHLYLBthFpXa3FYe7W/ET17vmOzYu+dYG7C2nrxLKdsrWSDYEk5vYopALDuP9h+0do+kvM+nevk+izZO/3gJqzFOsirfCEyel7Hdhk2PmjKgl5HVZEHvS7Hcl7S5Tq5HvaGotWeve2qKJ2dqQe3k7WN3PmMDsNFF+j7xqlVZGAVmO476+7LVo7lvKTLdXJ9luydbnCvsFY3zFwMK7EIVmkfCLD3aCYswcoFv/XHblXS5Tq5Pkv2TjW43VirhZVKrSjDVuslJGEOW2vGbzn250n312XM3ukFtwdrqbhWZIW5y6zzVGZFHAoFv5VjzifXZM3eqQW3F2vtBWsXkYUZ1jxuhFxzq2CwXhT2ynVZs3c6wb0Ba3HLzAFogRVbXgfNLcec78nefLqWw1SCOwBrc4ApwCK7fisMbrOXfc9eG4RLYjmo/UpefacCeb4spI+0TB+4A7AWnQ29BSvg8s8fC5tb7AVCrAq7arRFn/VC5HLFUvFhKE4drh15mTpwB2EtaEr5BFRhoXRLyLFnL0dLaLFYFA8AdlXHYdnl9OxJvVKo4geW64W/bIdepgvchLVoo9iniV2b1P6ipcJK0BDrwLOW/QSgS+zl5BxWxD2uf8D+GpXbig3iJVPPlT4WVnK/xSe8dXHvUABPjeMGXPVvKOwzNFFVK/lGwc04m8eOpfWF6dWyXxcBSyyEnGepwHl58ex1GgvC6VmYW+ax4oP44Gz8IXDuJDCmEjXO608rm1H5+fZITp2pYa5ooGihaKJoo2hkUyvtalM7+7I2gdizF8QDargC23leDOy1a7yGdWfMaj225SgKZ3bDk/yoTp3pABd6gY4WLRRNFG30Gila6R00sNdraAdre3+lRYPRVC3aW2KV5vEKqK+R8MBpc69odCrN4qkAdwskRANFC0UTRRtFI0F8TTRTtJP9XlqbsDbZypvivwsAucDeLBJRJpFvAPqLzMRh0zQb1akz+eBCTXEJPj3cDNDCIpSbDyJ7z9p4De0UF6I4acrIxCxg5VsWAeD1XSTd8Cfk5RQBeoFjaS6vOp6XVrN44sHdgmWifaKBooWiiaKNopFeK9FMQJpvaWjAeQFO1uuWZsPCSfMYOdHIgzh7UmwWTza4LdaKQ1tML9FC0UTRRtFI0UqAwV4V7dR04bTs0+tgbab7yg32i8XUblgkZl0a7J1ocLdarBXtk+apaKE3vdBGAFn1Wolmeu1EQ2GdMFbWmy7eNOPiS83itJw6kwvuFdYmDQbRRNFGAKF3F61EM0FT2CdlGQRYLud6/vFyMmHvxIK7RcFFaxPWSreMdxG2XIqAInIwA7g0CAaSAwG1c8mMvZMJbg/WJnp4qZkrmin8G5y1CcCZsXciwe3F2i6O8GYzd3hg2wC3TLhUtXfywB2EtZ86ZxKwBt3enL0DONQnDtwxsDZ5EV21l8ShHeqTBe54WNsGt5vl0GwBDudQnyhwx8jaNsCd2guwIznUJwfc8bK2DW4nezFBRnKoTwy4E8DaNsDCXnECeWcQTiEvDfgyxFkkTiNxHokTSZxJ/caZTQa4CWuH675JQElr6y0HWDuyQ33s4IKrDxCRLm3p2hYfgnR1S2sMtJJhSckAj7Ts2utehDSJR3aojx1cCpFG9811YA2anopDfbzgwlpKnVb3zaAAXne95G0kh/pYwZVKLMXum+vAGiZ9JIf6+MBtVWIpd98MA2C/e5rN4iEd6mMDV1ibUfdNP7CGSevaLBYnkgnoGMUVqgrd4yvGA26LtRl13wwDYL97ejp1iHFbUfTp1Wt6zphi/vSK3TsWcBPWJo7wDLpv+oE1TNol9hplFmhYMJhE33fGlQOt5609Lx7MVsIVgriTD7h9cK+wNqPum6R8aW09e3kYtq/KW+VmgZAuJunSN0sxoQGhCgu5SiM8OLgIHLx1cK+yNsPum7SAbT8Hm1waF2KeybjfEj3HjPW1dDXpktJx/qQYB3M1pV+07rhdcHuwNqPumzYoo+wApLRz/Ap9LehaDmSAHnWaT9CG0RIGR0SOs/vRpla/IYGy3iq4vVibYfeNlH+URSZkgKAqBq2I3RoHVZo+H9n/yHm2pmoI27ShicKctg/CbefBpaPv9sAdhLXpdd+MDCwPiAG4gWVQZXsKcw9B/C1c3kN39wH5EHKcmiA+r9fCaHVRWUjkl1sDd1pYCxvB0K+WfdiqamxhqDpi3efrvkOz7R8A+0o7+0/295TVR6pWqObPZxqnDEp90bzfD6TgnoyX6WGtlwHQQAZUg7XKegrahwD8muj3bUD9P9j7A/XE35GKf6rA7ceBPlaFuPolM5DIFC9c65dbYe4Wueg2wGPCtFYYK5WVyECd/TPWY4B6C6A77L9i/2/G2u8R0x+YKONVrPRuqIPDBSvMPqi/+lNz7hzkVp7lh/HINrslYS1zIeSLUSFq1OdiomeMwVcrkY4MS6IWbo+tpbKQFy/rbS0ChFRaAKsxWZ0A6xnL9j2JbxCJXbIFwHbHMg+R1eYgtrWj0M2eQvDzWviurv6q4udcQJk8sJL5UP5kuWwB1K8ZlvQ9g+mwVWaMyy9SgPtWwkibQzavi2PIMnteBgBW2BpRadX4sArrCV/pdx5YYa1VP4I+s16YN7nYvsNjf5Kr60ojMrWjBwfRfzMZEfdw+wWwkulswZ1Q1gJmgoLoowBTZyvWgJhXVFz6gAQYa3fg9A7fsl1r7ZucDd7XXXjaCOPqevih/ufND7F6CfRdgBVw0eU2i+U41WULO/rXDLf//ni5BGvLsPYR35unfOYzvv7f8GGPed8yYnGObR4xkDqA8mW6SIFlFVC8NcDW6yvbQ8DcR1P3SN6lmSBau2dVcOByjaOiij+WinPn6+p1Q/RVZEAyS3m6gpgdcxPW7sjA5YnRWkGh1Sjw1sA52Hi2cvIAyPcAa4dKa9cCsNPBvnHxoQrscbGqzip2ubaysh39Dra+aLG1K6o8VJZMmAuuzZixnyM71XKJmDGmn8pv4Fp6wgc+oyC3yloAS6glbPUywDaptKRRsM83CfuVSgspwNPlK62CDY4+xvrjHGaWWANJpdWPrQJqsmTCXHmbL3+lzMfTh+HpWUTMWMPHjFGJEdpkJN72Ni0Ez1ay1JYB8ncGQMece8e6hxOgR6VVq8xFc7WjxTeXKq1+bOV57SV9cGEtT292OpYqBZUrNmPGDHPO+OH2rZix/pGO7QyOuCM4NB0t2ExkzMsA547I5lsS92hl/WhHqLT65S91WdiCmn4uxfOVQhy5hciotcAZKi73M2yVb2g+fsW39CH6tkThShRYfKRpN2YEVNFWb7uye8l2Je0N6y527Y6ACwh7Rodv60NUWjyn55JuoaBDBjFjPTPfIyGRgU6Hywlo+yYs92xjj/ydl/sD4LOaV7zwH3OBfUuldVypLJ/Nzb+u/w7bNWkU9LIGenx++3SqzBXWJqaXtW7JGP1QB+YrYSwF+Rlq8ZhPXmOVoDqZxSM11vL8TyotzlUR2rbtCpWbtiuMDanAIuv26dg9zFl9Kr6BYSotytBzSU9zW6yVKf+kmUuoyFyNmDFGtD2AGRK4THO3M2asHdrUM3MDJCRsbVda3HsGW8U3cNl2dWK7ur2GS2zX6scjbFeUotHZ0hJdGXVJTRa2oGWnc+YWOx0Fh2sdLoG1/wvQf8N2FQcMIdoXMvAUGZD5dHnOJ03YUQBORxZgLdNXG/WHjTwTVc5Fqr6Kc+MxLZ1v6P54BnOfkMkN1la0o292j/piBdRrK620bddBwE5FFoS13ZwzqKAfpehBFdOrGWebxOcOks+r13oZgIFDOFxGs12vZqTf8ejgXtHavi5Fag8A4b9f++Wraxo3tistAZaLMnG4dP3wIU6OLAudFkLGzhkvA5TxUqXFsbS0LldaIzpchsCx6y2j6V7C2lZUeTLAgzfWnvIPKWg7wuHrsKxNrIHEL9C1l6BfpZWW7doVxR4nR2Ju1qztlAHynwCbucOlB1YDnx5ecxPWZtd9k7C1LQPowq04XAZGsccNQ4O71cNCSKn7JrFdBdhbd7j0wGrg08PJAqztZtfSHfKN9DJgfD5FXh+RmzIoDeKc8ZUWcjBWh8vAKPa4YagKTVjb2RpLBtNBs1GibxIZGLvDpQdWA58enLkJa3dojR1ctMaGZW23SotzY3W4DIxijxsG0lxwbXbfjDblX2dWEra2Line 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

+1
View File
@@ -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