diff --git a/package-lock.json b/package-lock.json index 77745d0f540..4d053839bfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": "*", diff --git a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx index 04fda2cda25..6ed84243dd2 100644 --- a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx +++ b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx @@ -185,7 +185,7 @@ const PITRSelection = ({}) => { + + {!isLoading && ( + <> + {isEnabled ? ( + +
+
+ + } + 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." + /> +
+
+ + + + + + +
+
+
+
+
+ ) : ( + +
+
+ {wrapper.server.options.map((option) => ( + { + setFormState((prev) => ({ ...prev, [option.name]: e.target.value })) + setFormErrors((prev) => ({ ...prev, [option.name]: '' })) + }} + /> + ))} +
+
+
+

Foreign Tables

+ +
+
+
+ {newTables.length === 0 && ( +
+

+ Add foreign tables to query from after the wrapper is enabled +

+
+ )} + {newTables.map((table, i) => ( +
+
+

+ {table.schema_name}.{table.table_name} +

+

+ {wrapper.tables[table.index].label}: {table.columns.join(', ')} +

+
+
+
+
+ ))} + {newTables.length === 0 && formErrors.tables && ( +

{formErrors.tables}

+ )} +
+
+ + + + + +
+ + +
+
+
+
+
+
+ )} + + )} + + { + setSelectedTableToEdit(undefined) + setIsEditingTable(false) + }} + onSave={onUpdateTable} + initialData={selectedTableToEdit} + /> + + ) +} + +export default WrapperRow diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx new file mode 100644 index 00000000000..e454ee7db4e --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -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('') + + 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 ( + Edit foreign table} + customFooter={ + + } + > + +
+ setSelectedTableIndex(value)} + > + + --- + + + {tables.map((table, i) => { + return ( + +
+

{table.label}

+

{table.description}

+
+
+ ) + })} +
+ + {selectedTable && ( + + )} +
+
+
+ ) +} + +export default WrapperTableEditor + +const Option = ({ option }: { option: TableOption }) => { + return ( + + ) +} + +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 ( +
+ {({ errors, values, resetForm }: any) => { + return ( +
+ + } + > + Create a new schema + + + {/* @ts-ignore */} + {schemas.map((schema: PostgresSchema) => { + return ( + } + > + {schema.name} + + ) + })} + + {values.schema === 'custom' && ( + + )} + + {requiredOptions.map((option) => ( +
+ ) + }} +
+ ) +} diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts new file mode 100644 index 00000000000..a90af6b3b66 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts @@ -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, + }, + ], + }, + ], + }, +] diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx new file mode 100644 index 00000000000..50bd723a799 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -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('') + const [isEnabling, setIsEnabling] = useState(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 ( +
+
+
+

Foreign Data Wrappers

+
+ Query your data warehouse directly from your database, or third-party APIs using SQL. +
+
+ {/* [Joshen TODO] For when we support multiple instances per wrapper */} + {/*
+ + {WRAPPERS.map((wrapper, idx) => ( + <> + + } + onClick={() => {}} + > + {wrapper.label} + + {idx !== WRAPPERS.length - 1 && } + + ))} + + } + > + + +
*/} +
+ + {isWrappersEnabled ? ( +
+ {WRAPPERS.map((wrapper, i) => { + return ( + { + if (open !== wrapperName) setOpen(wrapperName) + else setOpen('') + }} + /> + ) + })} +
+ ) : ( +
+
+
+
+

Supabase Wrappers

+

+ 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. +

+
+ {isNotAvailable ? ( +
+
+
+

+ Wrappers is not available for this project yet. +

+

+ Do reach out to us if you're interested! +

+
+ +
+ +
+ ) : ( +
+ + + + + + + + + + {!canToggleWrappers && ( + + +
+ + You need additional permissions to enable Wrappers for this project + +
+
+ )} +
+
+ )} +
+
+
+ )} +
+ ) +} + +export default observer(Wrappers) diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts new file mode 100644 index 00000000000..f805f656937 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts @@ -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 +} diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts new file mode 100644 index 00000000000..19b48c207f1 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts @@ -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 + } diff --git a/studio/components/interfaces/Database/index.ts b/studio/components/interfaces/Database/index.ts index 60141982fab..b294434c18e 100644 --- a/studio/components/interfaces/Database/index.ts +++ b/studio/components/interfaces/Database/index.ts @@ -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, diff --git a/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx b/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx index f43299a8672..9bd0d3a7f8a 100644 --- a/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx +++ b/studio/components/interfaces/Settings/Vault/Secrets/AddNewSecretModal.tsx @@ -19,7 +19,10 @@ const AddNewSecretModal: FC = ({ 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) => { diff --git a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx index 1fdd70b2156..062f70a1568 100644 --- a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx +++ b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx @@ -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 = () => {

{isNotAvailable ? ( - } - title="Vault is not available for your project" - description={ - <> -

- Do reach out to us if you're interested in having Vault for this project! +

+
+
+

+ Vault is not available for this project yet.

- - - } - /> +

+ Do reach out to us if you're interested! +

+
+ +
+ +
) : (
diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx index fd0cbe367be..3159fad857a 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx @@ -10,6 +10,7 @@ interface ActionBarProps { backButtonLabel?: string applyFunction?: (resolve: any) => void closePanel: () => void + formId?: string } const ActionBar: FC = ({ loading = false, @@ -20,6 +21,7 @@ const ActionBar: FC = ({ backButtonLabel = 'Back', applyFunction = undefined, closePanel = () => {}, + formId, }) => { const [isRunning, setIsRunning] = useState(false) @@ -53,7 +55,13 @@ const ActionBar: FC = ({ ) : !hideApply ? ( // New solution, when using the Form component, loading is handled by the Form itself // Does not require applyFunction() callback - ) : ( diff --git a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts index b22828ad94b..c7e19c412ae 100644 --- a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts +++ b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts @@ -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', diff --git a/studio/components/layouts/ProjectLayout/ProjectContext.tsx b/studio/components/layouts/ProjectLayout/ProjectContext.tsx new file mode 100644 index 00000000000..7be2501528c --- /dev/null +++ b/studio/components/layouts/ProjectLayout/ProjectContext.tsx @@ -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({ + project: undefined, + isLoading: true, +}) + +export default ProjectContext + +export const useProjectContext = () => useContext(ProjectContext) + +type ProjectContextProviderProps = { + projectRef: string | undefined +} + +export const ProjectContextProvider = observer( + ({ projectRef, children }: PropsWithChildren) => { + // TODO(alaister): This will eventually be replaced with a react-query query + // once we remove mobx + const { ui } = useStore() + + const value = useMemo(() => { + if (ui.selectedProject?.ref === projectRef) { + return { + project: ui.selectedProject, + isLoading: false, + } + } + + return { + project: undefined, + isLoading: true, + } + }, [ui.selectedProject?.ref, projectRef]) + + return {children} + } +) diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 0c37dfe0717..bc86ead7c13 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -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) => { + const { ref: projectRef } = useParams() const { ui } = useStore() const ongoingIncident = useFlag('ongoingIncident') const projectName = ui.selectedProject?.name return ( - <> + {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> ) } diff --git a/studio/components/ui/MultiSelect/Badges.tsx b/studio/components/ui/MultiSelect/Badges.tsx index ea8aea4abb5..0a4f9f48cbd 100644 --- a/studio/components/ui/MultiSelect/Badges.tsx +++ b/studio/components/ui/MultiSelect/Badges.tsx @@ -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 diff --git a/studio/components/ui/MultiSelect/index.tsx b/studio/components/ui/MultiSelect/index.tsx index e43ed33aee1..a0b91cf0f99 100644 --- a/studio/components/ui/MultiSelect/index.tsx +++ b/studio/components/ui/MultiSelect/index.tsx @@ -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} diff --git a/studio/data/custom-domains/custom-domains-delete-mutation.ts b/studio/data/custom-domains/custom-domains-delete-mutation.ts index 014ce571e4d..50e992f4831 100644 --- a/studio/data/custom-domains/custom-domains-delete-mutation.ts +++ b/studio/data/custom-domains/custom-domains-delete-mutation.ts @@ -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 = { diff --git a/studio/data/fdw/fdw-create-mutation.ts b/studio/data/fdw/fdw-create-mutation.ts new file mode 100644 index 00000000000..f42a81f2d4d --- /dev/null +++ b/studio/data/fdw/fdw-create-mutation.ts @@ -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, + }) +} diff --git a/studio/data/fdw/fdw-delete-mutation.ts b/studio/data/fdw/fdw-delete-mutation.ts new file mode 100644 index 00000000000..8ecc603a853 --- /dev/null +++ b/studio/data/fdw/fdw-delete-mutation.ts @@ -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, + }) +} diff --git a/studio/data/fdw/fdws-query.ts b/studio/data/fdw/fdws-query.ts new file mode 100644 index 00000000000..c2ba531dcd4 --- /dev/null +++ b/studio/data/fdw/fdws-query.ts @@ -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'], + }) +} diff --git a/studio/data/sql/execute-sql-mutation.ts b/studio/data/sql/execute-sql-mutation.ts new file mode 100644 index 00000000000..5bf0c92b2ae --- /dev/null +++ b/studio/data/sql/execute-sql-mutation.ts @@ -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, + }) +} diff --git a/studio/data/sql/execute-sql-query.ts b/studio/data/sql/execute-sql-query.ts new file mode 100644 index 00000000000..8fc75f28b45 --- /dev/null +++ b/studio/data/sql/execute-sql-query.ts @@ -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]) +} diff --git a/studio/data/sql/keys.ts b/studio/data/sql/keys.ts new file mode 100644 index 00000000000..b651bbfe087 --- /dev/null +++ b/studio/data/sql/keys.ts @@ -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, +} diff --git a/studio/package.json b/studio/package.json index ac126783233..959d303802f 100644 --- a/studio/package.json +++ b/studio/package.json @@ -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", diff --git a/studio/pages/project/[ref]/database/wrappers.tsx b/studio/pages/project/[ref]/database/wrappers.tsx new file mode 100644 index 00000000000..ead5fba1e25 --- /dev/null +++ b/studio/pages/project/[ref]/database/wrappers.tsx @@ -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) diff --git a/studio/public/img/icons/firebase-icon.svg b/studio/public/img/icons/firebase-icon.svg new file mode 100644 index 00000000000..400716b89d4 --- /dev/null +++ b/studio/public/img/icons/firebase-icon.svg @@ -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 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/public/img/icons/stripe-icon.svg b/studio/public/img/icons/stripe-icon.svg new file mode 100644 index 00000000000..66dd20f76c0 --- /dev/null +++ b/studio/public/img/icons/stripe-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/wrappers-dark.png b/studio/public/img/wrappers-dark.png new file mode 100644 index 00000000000..89216a84b37 Binary files /dev/null and b/studio/public/img/wrappers-dark.png differ diff --git a/studio/public/img/wrappers-light.png b/studio/public/img/wrappers-light.png new file mode 100644 index 00000000000..5f3e540cf65 Binary files /dev/null and b/studio/public/img/wrappers-light.png differ