diff --git a/apps/www/.env b/apps/www/.env index af6041f3a2c..44e4b28a40c 100644 --- a/apps/www/.env +++ b/apps/www/.env @@ -9,4 +9,7 @@ NEXT_PUBLIC_DOCS_URL="http://localhost:3005" NEXT_PUBLIC_STUDIO_URL="https://localhost:8082" NEXT_PUBLIC_LAUNCHWEEKSITE_URL="https://localhost:3008" NEXT_PUBLIC_REFERENCE_DOCS_URL="https://localhost:3010" - +NEXT_PUBLIC_LW_STARTED=true +NEXT_PUBLIC_SITE_ORIGIN=http://localhost:3000 +NEXT_PUBLIC_SUPABASE_URL=https://obuldanrptloktxcffvn.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I \ No newline at end of file diff --git a/apps/www/pages/launch-week/index.tsx b/apps/www/pages/launch-week/index.tsx index 2fcd99cde21..9de8012f18e 100644 --- a/apps/www/pages/launch-week/index.tsx +++ b/apps/www/pages/launch-week/index.tsx @@ -264,7 +264,7 @@ export default function launchweek() { justified={false} bordered={false} chevronAlign="right" - defaultValue={day1.d.toString()} + defaultValue={[day1.d.toString()]} >
{ + if (event == 'PASSWORD_RECOVERY') { + console.log('PASSWORD_RECOVERY', session) + + // show screen to update user's password + showPasswordResetScreen(true) + } + }) + ``` - id: listen-to-sign-in name: Listen to sign in code: | @@ -437,27 +486,136 @@ functions: if (event == 'USER_UPDATED') console.log('USER_UPDATED', session) }) ``` - - id: listen-to-user-deleted - name: Listen to user deleted + - id: mfa-enroll + title: 'mfa.enroll()' + $ref: '@supabase/gotrue-js.GoTrueMFAApi.enroll' + notes: | + - Currently, `totp` is the only supported `factorType`. The returned `id` should be used to create a challenge. + - To create a challenge, see [`mfa.challenge()`](/docs/reference/javascript/auth-mfa-challenge). + - To verify a challenge, see [`mfa.verify()`](/docs/reference/javascript/auth-mfa-verify). + - To create and verify a challenge in a single step, see [`mfa.challengeAndVerify()`](/docs/reference/javascript/auth-mfa-challengeandverify). + - To generate a QR code for the `totp` secret in nextjs, you can do the following: + ```html + {data.totp.uri} + ``` + examples: + - id: enroll-totp-factor + name: Enroll a time-based, one-time password (TOTP) factor + isSpotlight: true code: | ```js - supabase.auth.onAuthStateChange((event, session) => { - if (event == 'USER_DELETED') console.log('USER_DELETED', session) + const { data, error } = await supabase.auth.mfa.enroll({ + factorType: 'totp' + }) + + // Use the id to create a challenge. + // The challenge can be verified by entering the code generated from the authenticator app. + // The code will be generated upon scanning the qr_code or entering the secret into the authenticator app. + const { id, type, totp: { qr_code, secret, uri } } = data + ``` + - id: mfa-challenge + title: 'mfa.challenge()' + $ref: '@supabase/gotrue-js.GoTrueMFAApi.challenge' + notes: | + - An [enrolled factor](/docs/reference/javascript/auth-mfa-enroll) is required before creating a challenge. + - To verify a challenge, see [`mfa.verify()`](/docs/reference/javascript/auth-mfa-verify). + examples: + - id: create-mfa-challenge + name: Create a challenge for a factor + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.auth.mfa.challenge({ + factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225' }) ``` - - id: listen-to-password-recovery-events - name: Listen to password recovery events + - id: mfa-verify + title: 'mfa.verify()' + $ref: '@supabase/gotrue-js.GoTrueMFAApi.verify' + notes: | + - To verify a challenge, please [create a challenge](/docs/reference/javascript/auth-mfa-challenge) first. + examples: + - id: verify-challenge + name: Verify a challenge for a factor + isSpotlight: true code: | ```js - supabase.auth.onAuthStateChange((event, session) => { - if (event == 'PASSWORD_RECOVERY') console.log('PASSWORD_RECOVERY', session) + const { data, error } = await supabase.auth.mfa.verify({ + factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225', + challengeId: '4034ae6f-a8ce-4fb5-8ee5-69a5863a7c15', + code: '123456' }) ``` + - id: mfa-challenge-and-verify + title: 'mfa.challengeAndVerify()' + $ref: '@supabase/gotrue-js.GoTrueMFAApi.challengeAndVerify' + notes: | + - An [enrolled factor](/docs/reference/javascript/auth-mfa-enroll) is required before invoking `challengeAndVerify()`. + - Executes [`mfa.challenge()`](/docs/reference/javascript/auth-mfa-challenge) and [`mfa.verify()`](/docs/reference/javascript/auth-mfa-verify) in a single step. + examples: + - id: challenge-and-verify + name: Create and verify a challenge for a factor + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.auth.mfa.challengeAndVerify({ + factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225', + challengeId: '4034ae6f-a8ce-4fb5-8ee5-69a5863a7c15', + code: '123456' + }) + ``` + - id: mfa-unenroll + title: 'mfa.unenroll()' + $ref: '@supabase/gotrue-js.GoTrueMFAApi.unenroll' + examples: + - id: unenroll-a-factor + name: Unenroll a factor + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.auth.mfa.unenroll({ + factorId: '34e770dd-9ff9-416c-87fa-43b31d7ef225', + }) + ``` + - id: mfa-get-authenticator-assurance-level + title: 'mfa.getAuthenticatorAssuranceLevel()' + $ref: '@supabase/gotrue-js.GoTrueMFAApi.getAuthenticatorAssuranceLevel' + notes: | + - Authenticator Assurance Level (AAL) is the measure of the strength of an authentication mechanism. + - In Supabase, having an AAL of `aal1` refers to having the 1st factor of authentication such as an email and password or OAuth sign-in while `aal2` refers to the 2nd factor of authentication such as a time-based, one-time-password (TOTP). + - If the user has a verified factor, the `nextLevel` field will return `aal2`, else, it will return `aal1`. + examples: + - id: get-aal + name: Get the AAL details of a session + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + const { currentLevel, nextLevel, currentAuthenticationMethods } = data + ``` - id: admin-api title: 'Overview' notes: | - Any method under the `supabase.auth.admin` namespace requires a `service_role` key. - These methods are considered admin methods and should be called on a trusted server. Never expose your `service_role` key in the browser. + examples: + - id: create-auth-admin-client + name: Create server-side auth client + isSpotlight: true + code: | + ```js + import { createClient } from '@supabase/supabase-js' + + const supabase = createClient(supabase_url, service_role_key, { + auth: { + autoRefreshToken: false, + persistSession: false + } + }) + + // Access auth admin api + const adminAuthClient = supabase.auth.admin + ``` - id: get-user-by-id title: 'getUserById()' @@ -467,7 +625,7 @@ functions: - The `getUserById()` method requires the user's id which maps to the `auth.users.id` column. examples: - id: fetch-the-user-object-using-the-access-token-jwt - name: Fetch the user object using the access_token jwt. + name: Fetch the user object using the access_token jwt isSpotlight: true code: | ```js @@ -477,14 +635,26 @@ functions: - id: list-users title: 'listUsers()' $ref: '@supabase/gotrue-js.GoTrueAdminApi.listUsers' + notes: | + - Defaults to return 50 users per page. examples: - id: get-a-full-list-of-users - name: Get a full list of users. + name: Get a full list of users isSpotlight: true code: | ```js const { data: { users }, error } = await supabase.auth.admin.listUsers() ``` + - id: get-paginated-list-of-users + name: Paginated list of users + isSpotlight: false + code: | + ```js + const { data: { users }, error } = await supabase.auth.admin.listUsers({ + page: 1, + perPage: 1000 + }) + ``` - id: create-user title: 'createUser()' $ref: '@supabase/gotrue-js.GoTrueAdminApi.createUser' @@ -492,7 +662,7 @@ functions: - To confirm the user's email address or phone number, set `email_confirm` or `phone_confirm` to true. Both arguments default to false. examples: - id: create-a-new-user-with-custom-user-metadata - name: Create a new user with custom user metadata + name: With custom user metadata isSpotlight: true code: | ```js @@ -554,12 +724,17 @@ functions: title: 'resetPasswordForEmail()' $ref: '@supabase/gotrue-js.GoTrueClient.resetPasswordForEmail' notes: | - Sends a password reset request to an email address. - When the user clicks the reset link in the email they are redirected back to your application. - Prompt the user for a new password and call `auth.updateUser()`: + - The password reset flow consist of 2 broad steps: (i) Allow the user to login via the password reset link; (ii) Update the user's password. + - The `resetPasswordForEmail()` only sends a password reset link to the user's email. + To update the user's password, see [`updateUser()`](/docs/reference/javascript/auth-updateuser). + - A `SIGNED_IN` and `PASSWORD_RECOVERY` event will be emitted when the password recovery link is clicked. + You can use [`onAuthStateChange()`](/docs/reference/javascript/auth-onauthstatechange) to listen and invoke a callback function on these events. + - When the user clicks the reset link in the email they are redirected back to your application. + Prompt the user for a new password and call `updateUser()`: ```js - const { data, error } = await supabase.auth - .updateUser({ password: new_password }) + const { data, error } = await supabase.auth.updateUser({ + password: new_password + }) ``` examples: - id: reset-password @@ -673,7 +848,7 @@ functions: $ref: '@supabase/gotrue-js.GoTrueAdminApi.updateUserById' examples: - id: updates-a-users-email - name: Updates a user's email. + name: Updates a user's email isSpotlight: false code: | ```js @@ -683,7 +858,7 @@ functions: ) ``` - id: updates-a-users-password - name: Updates a user's password. + name: Updates a user's password isSpotlight: false code: | ```js @@ -693,7 +868,7 @@ functions: ) ``` - id: updates-a-users-metadata - name: Updates a user's metadata. + name: Updates a user's metadata isSpotlight: true code: | ```js @@ -703,7 +878,7 @@ functions: ) ``` - id: updates-a-users-app-metadata - name: Updates a user's app_metadata. + name: Updates a user's app_metadata isSpotlight: false code: | ```js @@ -713,7 +888,7 @@ functions: ) ``` - id: confirms-a-users-email-address - name: Confirms a user's email address. + name: Confirms a user's email address isSpotlight: false code: | ```js @@ -723,7 +898,7 @@ functions: ) ``` - id: confirms-a-users-phone-number - name: Confirms a user's phone number. + name: Confirms a user's phone number isSpotlight: false code: | ```js @@ -732,7 +907,31 @@ functions: { phone_confirm: true } ) ``` - + - id: mfa-list-factors + title: 'mfa.listFactors()' + $ref: '@supabase/gotrue-js.GoTrueAdminMFAApi.listFactors' + examples: + - id: list-factors + name: List all factors for a user + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.auth.admin.mfa.listFactors() + ``` + - id: mfa-delete-factor + title: 'mfa.deleteFactor()' + $ref: '@supabase/gotrue-js.GoTrueAdminMFAApi.deleteFactor' + examples: + - id: delete-factor + name: Delete a factor for a user + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.auth.admin.mfa.deleteFactor({ + id: '34e770dd-9ff9-416c-87fa-43b31d7ef225', + userId: 'a89baba7-b1b7-440f-b4bb-91026967f66b', + }) + ``` - id: select title: 'Fetch data: select()' $ref: '@supabase/postgrest-js.PostgrestQueryBuilder.select' @@ -6765,7 +6964,7 @@ functions: - Responses are automatically parsed as `json`, `blob` and `form-data` depending on the `Content-Type` header sent by your function. Responses are parsed as `text` by default. examples: - id: basic-invocation - name: Basic invocation. + name: Basic invocation description: isSpotlight: true code: | @@ -6775,7 +6974,7 @@ functions: }) ``` - id: error-handling - name: Error handling. + name: Error handling description: | A `FunctionsHttpError` error is returned if your function throws an error, `FunctionsRelayError` if the Supabase Relay has an error processing your function and `FunctionsFetchError` if there is a network error in calling your function. isSpotlight: true @@ -6799,7 +6998,7 @@ functions: } ``` - id: passing-custom-headers - name: Passing custom headers. + name: Passing custom headers description: | You can pass custom headers to your function. Note: supabase-js automatically passes the `Authorization` header with the signed in user's JWT. isSpotlight: true diff --git a/spec/transforms/api_v0_openapi_deparsed.json b/spec/transforms/api_v0_openapi_deparsed.json index c402f198446..ab82667dca0 100644 --- a/spec/transforms/api_v0_openapi_deparsed.json +++ b/spec/transforms/api_v0_openapi_deparsed.json @@ -1078,17 +1078,17 @@ "schema": { "type": "object", "properties": { + "max_rows": { + "type": "integer" + }, "db_schema": { "type": "string" }, - "max_rows": { - "type": "number" - }, "db_extra_search_path": { "type": "string" } }, - "required": ["db_schema", "max_rows", "db_extra_search_path"] + "required": ["max_rows", "db_schema", "db_extra_search_path"] } } } @@ -1130,15 +1130,16 @@ "schema": { "type": "object", "properties": { + "max_rows": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, "db_extra_search_path": { "type": "string" }, "db_schema": { "type": "string" - }, - "max_rows": { - "type": "number", - "maximum": 1000000 } } } @@ -1153,17 +1154,17 @@ "schema": { "type": "object", "properties": { + "max_rows": { + "type": "integer" + }, "db_schema": { "type": "string" }, - "max_rows": { - "type": "number" - }, "db_extra_search_path": { "type": "string" } }, - "required": ["db_schema", "max_rows", "db_extra_search_path"] + "required": ["max_rows", "db_schema", "db_extra_search_path"] } } } @@ -2396,30 +2397,31 @@ "PostgrestConfigResponse": { "type": "object", "properties": { + "max_rows": { + "type": "integer" + }, "db_schema": { "type": "string" }, - "max_rows": { - "type": "number" - }, "db_extra_search_path": { "type": "string" } }, - "required": ["db_schema", "max_rows", "db_extra_search_path"] + "required": ["max_rows", "db_schema", "db_extra_search_path"] }, "UpdatePostgrestConfigBody": { "type": "object", "properties": { + "max_rows": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, "db_extra_search_path": { "type": "string" }, "db_schema": { "type": "string" - }, - "max_rows": { - "type": "number", - "maximum": 1000000 } } }, 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 = ({}) => {
+ ) : null + } + /> + ) +} + +const WrapperRow: FC = ({ 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([]) + 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 ( + <> + 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(' ')} + > + + + + {!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