diff --git a/apps/docs/components/Navigation/NavigationMenu/HomeMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/HomeMenu.tsx index 97ccceb5b3a..36f01bf6830 100644 --- a/apps/docs/components/Navigation/NavigationMenu/HomeMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/HomeMenu.tsx @@ -3,6 +3,7 @@ import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' import { Fragment } from 'react' +import { Badge } from '~/../../packages/ui' const home = [ [ @@ -94,17 +95,19 @@ const home = [ href: '/reference/dart/introduction', level: 'reference_dart', }, - { - label: 'C#', - icon: '/img/icons/menu/reference-csharp', - href: '/reference/csharp/introduction', - level: 'reference_csharp', - }, { label: 'Python', icon: '/img/icons/menu/reference-python', href: '/reference/python/introduction', level: 'reference_python', + community: true, + }, + { + label: 'C#', + icon: '/img/icons/menu/reference-csharp', + href: '/reference/csharp/introduction', + level: 'reference_csharp', + community: true, }, { label: 'Tools', @@ -177,6 +180,7 @@ const NavigationMenuHome = ({ active }) => { className="w-4 h-4 group-hover:scale-110 ease-out transition-all" /> {link.label} + {link.community && Community} diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefListItems.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefListItems.tsx index 27f1e69ba99..b81f6c5a1dd 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefListItems.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefListItems.tsx @@ -179,7 +179,6 @@ const Content: React.FC = ({ id, lib, commonSections, sp if (!sections) console.error('no common sections imported') const menu = NavItems[id] - //console.log(menu) const databaseFunctions = find(sections, { title: 'Database' }) ? find(sections, { title: 'Database' }).items : [] diff --git a/apps/docs/docs/ref/csharp/introduction.mdx b/apps/docs/docs/ref/csharp/introduction.mdx index 6c699314937..e016c4f8338 100644 --- a/apps/docs/docs/ref/csharp/introduction.mdx +++ b/apps/docs/docs/ref/csharp/introduction.mdx @@ -8,13 +8,24 @@ hideTitle: true

C# Client Library

-

supabase-csharp

+

@supabase-community/supabase-csharp

- This reference documents every object and method available in Supabase's C# - library, [supabase-csharp](https://www.nuget.org/packages/supabase-csharp). You can - use supabase-csharp to interact with your Postgres database, listen to database changes, invoke - Deno Edge Functions, build login and user management functionality, and manage large files. + This reference documents every object and method available in Supabase's C# library, + [supabase-csharp](https://www.nuget.org/packages/supabase-csharp). You can use supabase-csharp to + interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, + build login and user management functionality, and manage large files. +
+ +
+

The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.

+ +

+ Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose), and to [Ben + Randall](https://github.com/veleek) and [Rhuan Barros](https://github.com/rhuanbarros) for their + help. +

+
diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-sveltekit.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-sveltekit.mdx index 78f236a99d9..614dac516e2 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-sveltekit.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-sveltekit.mdx @@ -24,7 +24,7 @@ Let's start building the Svelte app from scratch. ### Initialize a Svelte app We can use the [SvelteKit Skeleton Project](https://kit.svelte.dev/docs) to initialize -an app called `supabase-sveltekit` (for this tutorial you do not need TypeScript, ESLint, Prettier, or Playwright): +an app called `supabase-sveltekit` (for this tutorial we will be using TypeScript): ```bash npm init svelte@next supabase-sveltekit diff --git a/apps/docs/pages/guides/platform/custom-domains.mdx b/apps/docs/pages/guides/platform/custom-domains.mdx index 13fedb8e525..1ca5b0aa514 100644 --- a/apps/docs/pages/guides/platform/custom-domains.mdx +++ b/apps/docs/pages/guides/platform/custom-domains.mdx @@ -14,6 +14,15 @@ Custom Domains are currently in beta, and are being slowly made available to pro Custom domains allow you to present a branded experience to your users. Custom domains are available as a [add-on for projects on a paid tier](https://app.supabase.com/project/_/settings/billing/update). Setting up a custom domain requires [Owner or Admin permissions](/docs/guides/platform/access-control#manage-team-members) for the project. Currently, you must use a subdomain (e.g., `api.example.com`, rather than `example.com`) for the purposes of this guide. +
+ +
+ ## Configure a Custom Domain using the Supabase Dashboard Follow the **Custom Domains** steps in the [General Settings](https://app.supabase.com/project/_/settings/general) page in the Dashboard to set up a custom domain for your project. diff --git a/apps/docs/public/img/integrations/logos/illa_logo.png b/apps/docs/public/img/integrations/logos/illa_logo.png new file mode 100644 index 00000000000..ea541c278f4 Binary files /dev/null and b/apps/docs/public/img/integrations/logos/illa_logo.png differ diff --git a/package-lock.json b/package-lock.json index 6874d18dde4..e8f62b35615 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20305,6 +20305,23 @@ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "dev": true }, + "node_modules/ip-address": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-8.1.0.tgz", + "integrity": "sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "1.1.2" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -22939,6 +22956,11 @@ "node": ">=4.0.0" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, "node_modules/jsdom": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", @@ -38330,6 +38352,7 @@ "generate-password": "^1.7.0", "html-to-image": "^1.10.8", "immutability-helper": "^3.1.1", + "ip-address": "^8.1.0", "json-logic-js": "^2.0.2", "lodash": "^4.17.21", "lottie-react": "^2.2.1", @@ -54251,6 +54274,22 @@ "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", "dev": true }, + "ip-address": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-8.1.0.tgz", + "integrity": "sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==", + "requires": { + "jsbn": "1.1.0", + "sprintf-js": "1.1.2" + }, + "dependencies": { + "sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" + } + } + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -56342,6 +56381,11 @@ } } }, + "jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, "jsdom": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", @@ -64863,6 +64907,7 @@ "generate-password": "^1.7.0", "html-to-image": "^1.10.8", "immutability-helper": "^3.1.1", + "ip-address": "^8.1.0", "jest": "^27.4.0", "jest-canvas-mock": "^2.3.1", "json-logic-js": "^2.0.2", diff --git a/packages/ui/src/components/Input/Input.tsx b/packages/ui/src/components/Input/Input.tsx index a1caec8de16..a78fade9330 100644 --- a/packages/ui/src/components/Input/Input.tsx +++ b/packages/ui/src/components/Input/Input.tsx @@ -97,7 +97,7 @@ function Input({ // }, [errors, touched]) function onCopy(value: any) { - navigator.clipboard.writeText(value).then( + navigator.clipboard.writeText(value)?.then( function () { /* clipboard successfully set */ setCopyLabel('Copied') diff --git a/spec/common-client-libs-sections.json b/spec/common-client-libs-sections.json index 79489c67aea..c48f87c6b99 100644 --- a/spec/common-client-libs-sections.json +++ b/spec/common-client-libs-sections.json @@ -28,7 +28,8 @@ "reference_javascript_v1", "reference_dart_v0", "reference_dart_v1", - "reference_python_v2" + "reference_python_v2", + "reference_csharp_v0" ] }, { @@ -36,7 +37,12 @@ "id": "upgrade-guide", "slug": "upgrade-guide", "type": "markdown", - "excludes": ["reference_javascript_v2", "reference_dart_v1", "reference_python_v2"] + "excludes": [ + "reference_javascript_v2", + "reference_dart_v1", + "reference_python_v2", + "reference_csharp_v0" + ] }, { "title": "Database", @@ -842,7 +848,6 @@ { "title": "Misc", "excludes": ["reference_dart_v1", "reference_dart_v0"], - "items": [ { "title": "Release Notes", diff --git a/studio/components/interfaces/Database/index.ts b/studio/components/interfaces/Database/index.ts index d866b7d8c8c..94c664c2854 100644 --- a/studio/components/interfaces/Database/index.ts +++ b/studio/components/interfaces/Database/index.ts @@ -15,7 +15,7 @@ import PublicationsTables from './Publications/PublicationsTables' import BackupsList from './Backups/BackupsList' -import ConnectionPooling from './Pooling/ConnectionPooling' +import ConnectionPooling from '../Settings/Database/ConnectionPooling' import CreateTrigger from './Triggers/CreateTrigger' import DeleteTrigger from './Triggers/DeleteTrigger' diff --git a/studio/components/interfaces/Database/Pooling/ConnectionPooling.tsx b/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx similarity index 100% rename from studio/components/interfaces/Database/Pooling/ConnectionPooling.tsx rename to studio/components/interfaces/Settings/Database/ConnectionPooling.tsx diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/AddRestrictionModal.tsx b/studio/components/interfaces/Settings/Database/NetworkRestrictions/AddRestrictionModal.tsx new file mode 100644 index 00000000000..c3039d22494 --- /dev/null +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/AddRestrictionModal.tsx @@ -0,0 +1,244 @@ +import { FC, useState } from 'react' +import { Address4 } from 'ip-address' +import { Button, Form, IconHelpCircle, Input, Modal } from 'ui' +import * as Tooltip from '@radix-ui/react-tooltip' + +import { useStore, useParams } from 'hooks' +import InformationBox from 'components/ui/InformationBox' +import { checkIfPrivate, getAddressEndRange } from './NetworkRestrictions.utils' +import { useNetworkRestrictionsApplyMutation } from 'data/network-restrictions/network-retrictions-apply-mutation' +import { get } from 'lib/common/fetch' + +interface Props { + restrictedIps: string[] + visible: boolean + hasOverachingRestriction: boolean + onClose: () => void +} + +const AddRestrictionModal: FC = ({ + restrictedIps, + visible, + hasOverachingRestriction, + onClose, +}) => { + const formId = 'add-restriction-form' + const { ui } = useStore() + const { ref } = useParams() + const { mutateAsync: applyNetworkRestrictions } = useNetworkRestrictionsApplyMutation() + + const validate = (values: any) => { + const errors: any = {} + const { ipAddress, cidrBlockSize } = values + + // Validate CIDR block size + const isOutOfCidrSizeRange = cidrBlockSize < 0 || cidrBlockSize > 32 + if (isOutOfCidrSizeRange) errors.cidrBlockSize = 'Size has to be between 0 to 32' + + // Validate IP address + const isValid = Address4.isValid(ipAddress) + if (!isValid) { + errors.ipAddress = 'Please enter a valid IP address' + return errors + } + + const isPrivate = checkIfPrivate(ipAddress) + if (isPrivate) errors.ipAddress = 'Private IP addresses are not supported' + + return errors + } + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (!ref) return console.error('Project ref is required') + + setSubmitting(true) + const cidr = `${values.ipAddress}/${values.cidrBlockSize}` + const alreadyExists = restrictedIps.includes(cidr) + if (alreadyExists) { + return ui.setNotification({ + category: 'info', + message: `The address ${cidr} is already restricted`, + }) + } + + const dbAllowedCidrs = hasOverachingRestriction ? [cidr] : [...restrictedIps, cidr] + + try { + await applyNetworkRestrictions({ projectRef: ref, dbAllowedCidrs }) + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to add restriction: ${error.message}`, + }) + } finally { + setSubmitting(false) + } + } + + return ( + +
+ {({ isSubmitting, values, resetForm }: any) => { + const isPrivate = Address4.isValid(values.ipAddress) + ? checkIfPrivate(values.ipAddress) + : false + const isValidBlockSize = + values.cidrBlockSize !== '' && values.cidrBlockSize >= 0 && values.cidrBlockSize <= 32 + const availableAddresses = Math.pow(2, 32 - (values?.cidrBlockSize ?? 0)) + const addressRange = getAddressEndRange(`${values.ipAddress}/${values.cidrBlockSize}`) + + const isValidCIDR = isValidBlockSize && !isPrivate && addressRange !== undefined + + const [isFetchingAddress, setIsFetchingAddress] = useState(false) + + const getClientIpAddress = async () => { + setIsFetchingAddress(true) + const res = await fetch('http://www.geoplugin.net/json.gp', { method: 'GET' }) + const { geoplugin_request } = await res.json() + + if (geoplugin_request) { + const updatedValues = { ...values, ipAddress: geoplugin_request, cidrBlockSize: 32 } + resetForm({ initialValues: updatedValues, values: updatedValues }) + } else { + ui.setNotification({ + category: 'error', + duration: 4000, + message: 'Failed to retrieve client IP address, please enter address manually', + }) + } + setIsFetchingAddress(false) + } + + return ( + <> + +
+

+ This will add an IP address range to a list of allowed ranges that can access + your database. Only IPv4 addresses are supported at the moment. +

+ +
+
+ +
+
+ +

CIDR Block Size

+ + + + + + +
+ + Classless inter-domain routing (CIDR) notation is the notation + used to identify networks and hosts in the networks. The block + size tells us how many bits we need to take for the network + prefix, and is a value between 0 to 32. + +
+
+
+
+ } + id="cidrBlockSize" + name="cidrBlockSize" + type="number" + placeholder="0" + min={0} + max={32} + /> +
+
+ + +
+ + {isValidCIDR ? ( + +
+

+ The address range{' '} + + {values.ipAddress}/{values.cidrBlockSize} + {' '} + will be restricted +

+

+ Selected address space: {addressRange.start}{' '} + to {addressRange.end}{' '} +

+

+ Number of addresses: {availableAddresses} +

+
+
+ ) : ( + +
+
+

+ A summary of your restriction will be shown here after entering a valid IP + address and CIDR block size +

+
+
+
+ )} +
+ + +
+ + ) + }} + +
+ ) +} + +export default AddRestrictionModal diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/AllowAllModal.tsx b/studio/components/interfaces/Settings/Database/NetworkRestrictions/AllowAllModal.tsx new file mode 100644 index 00000000000..084d12c3d89 --- /dev/null +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/AllowAllModal.tsx @@ -0,0 +1,63 @@ +import { FC, useState } from 'react' +import { Modal, Button } from 'ui' + +import { useStore, useParams } from 'hooks' +import { useNetworkRestrictionsApplyMutation } from 'data/network-restrictions/network-retrictions-apply-mutation' + +interface Props { + visible: boolean + onClose: () => void +} + +const AllowAllModal: FC = ({ visible, onClose }) => { + const { ui } = useStore() + const { ref } = useParams() + const [isSubmitting, setIsSubmitting] = useState(false) + const { mutateAsync: applyNetworkRestrictions } = useNetworkRestrictionsApplyMutation() + + const onSubmit = async () => { + if (!ref) return console.error('Project ref is required') + + setIsSubmitting(true) + try { + await applyNetworkRestrictions({ projectRef: ref, dbAllowedCidrs: ['0.0.0.0/0'] }) + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to update restriction: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + +
+

+ This will allow any IP address to access your project's database. Are you sure? +

+
+
+
+ + +
+
+ ) +} + +export default AllowAllModal diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/DisallowAllModal.tsx b/studio/components/interfaces/Settings/Database/NetworkRestrictions/DisallowAllModal.tsx new file mode 100644 index 00000000000..537d509515e --- /dev/null +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/DisallowAllModal.tsx @@ -0,0 +1,71 @@ +import { FC, useState } from 'react' +import { Modal, Button } from 'ui' + +import { useStore, useParams } from 'hooks' +import { useNetworkRestrictionsApplyMutation } from 'data/network-restrictions/network-retrictions-apply-mutation' +import InformationBox from 'components/ui/InformationBox' + +interface Props { + visible: boolean + onClose: () => void +} + +const DisallowAllModal: FC = ({ visible, onClose }) => { + const { ui } = useStore() + const { ref } = useParams() + const [isSubmitting, setIsSubmitting] = useState(false) + const { mutateAsync: applyNetworkRestrictions } = useNetworkRestrictionsApplyMutation() + + const onSubmit = async () => { + if (!ref) return console.error('Project ref is required') + + setIsSubmitting(true) + try { + await applyNetworkRestrictions({ projectRef: ref, dbAllowedCidrs: ['127.0.0.1/32'] }) + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to update restriction: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + +
+

+ This will prevent any external IP addresses from accessing your project's database. Are + you sure? +

+ +
+
+
+ + +
+
+ ) +} + +export default DisallowAllModal diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx new file mode 100644 index 00000000000..30539d96d4b --- /dev/null +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -0,0 +1,195 @@ +import Link from 'next/link' +import { useState } from 'react' +import { Button, IconAlertCircle, IconExternalLink, IconGlobe, IconLock } from 'ui' + +import { useParams } from 'hooks' +import Panel from 'components/ui/Panel' +import { FormPanel, FormHeader } from 'components/ui/Forms' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import AddRestrictionModal from './AddRestrictionModal' +import RemoveRestrictionModal from './RemoveRestrictionModal' +import DisallowAllModal from './DisallowAllModal' +import AllowAllModal from './AllowAllModal' +import { useNetworkRestrictionsQuery } from 'data/network-restrictions/network-restrictions-query' + +const NetworkRestrictions = ({}) => { + const { ref } = useParams() + + const [isAddingAddress, setIsAddingAddress] = useState(false) + const [isAllowingAll, setIsAllowingAll] = useState(false) + const [isDisallowingAll, setIsDisallowingAll] = useState(false) + const [selectedRestrictionToRemove, setSelectedRestrictionToRemove] = useState() + const { data, isLoading } = useNetworkRestrictionsQuery({ projectRef: ref }) + + const hasAccessToRestrictions = data?.entitlement === 'allowed' + const restrictedIps = data?.config.dbAllowedCidrs ?? [] + const restrictionStatus = data?.status ?? '' + + const hasApplyError = restrictedIps.length === 0 && restrictionStatus === 'stored' + const isUninitialized = restrictedIps.length === 0 && restrictionStatus.length === 0 + const isAllowedAll = restrictedIps.includes('0.0.0.0/0') + const isDisallowedAll = restrictedIps.length === 1 && restrictedIps[0] === '127.0.0.1/32' + + if (!hasAccessToRestrictions) return <> + + return ( + <> +
+
+ +
+ + + + + + +
+
+ {isLoading ? ( + + +
+ + + +
+
+
+ ) : hasApplyError ? ( + + +
+
+
+ +

Your network restrictions were not applied correctly

+
+

+ Please try to add your network restrictions again +

+
+
+ + +
+
+
+
+ ) : ( + + {isUninitialized || isAllowedAll ? ( +
+
+
+

+ Your database can be accessed by all IP addresses +

+

+ You may start limiting access to your database by adding a network + restriction. +

+
+
+
+ +
+
+ ) : isDisallowedAll ? ( +
+
+ +
+

+ Your database cannot be + accessed externally +

+

+ All external IP addresses have been disallowed from accessing your project's + database. +

+

+ Note: Restrictions only apply to your database, and not to Supabase services +

+
+
+
+ +
+
+ ) : ( +
+
+
+

+ Only the following IP addresses have access to your database +

+

+ You may remove all of them to allow all IP addresses to have access to your + database +

+

+ Note: Restrictions only apply to your database, and not to Supabase services +

+
+
+ + +
+
+ {restrictedIps.map((ip) => { + return ( +
+
+ +

{ip}

+
+ +
+ ) + })} +
+ )} +
+ )} +
+ + setIsAllowingAll(false)} /> + setIsDisallowingAll(false)} /> + + setIsAddingAddress(false)} + /> + setSelectedRestrictionToRemove(undefined)} + /> + + ) +} + +export default NetworkRestrictions diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.utils.ts b/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.utils.ts new file mode 100644 index 00000000000..36999d6bb0b --- /dev/null +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.utils.ts @@ -0,0 +1,22 @@ +import { Address4 } from 'ip-address' + +const privateIPRanges = [ + new Address4('10.0.0.0/8'), + new Address4('172.16.0.0/12'), + new Address4('192.168.0.0/16'), +] + +export const checkIfPrivate = (cidr: string) => { + const address = new Address4(cidr) + const res = privateIPRanges.map((range) => address.isInSubnet(range)) + return res.includes(true) +} + +export const getAddressEndRange = (cidr: string) => { + try { + const address = new Address4(cidr) + return { start: address.startAddress().address, end: address.endAddress().address } + } catch (error: any) { + return undefined + } +} diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/RemoveRestrictionModal.tsx b/studio/components/interfaces/Settings/Database/NetworkRestrictions/RemoveRestrictionModal.tsx new file mode 100644 index 00000000000..cd2a4f7f654 --- /dev/null +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/RemoveRestrictionModal.tsx @@ -0,0 +1,89 @@ +import { FC, useState } from 'react' +import { Alert, Button, Form, Input, Modal } from 'ui' + +import { useStore, useParams } from 'hooks' +import { useNetworkRestrictionsApplyMutation } from 'data/network-restrictions/network-retrictions-apply-mutation' + +interface Props { + visible: boolean + restrictedIps: string[] + selectedRestriction?: string + onClose: () => void +} + +const RemoveRestrictionModal: FC = ({ + visible, + restrictedIps, + selectedRestriction, + onClose, +}) => { + const { ui } = useStore() + const { ref } = useParams() + const [isSubmitting, setIsSubmitting] = useState(false) + const { mutateAsync: applyNetworkRestrictions } = useNetworkRestrictionsApplyMutation() + + const isRemovingOnlyRestriction = + restrictedIps.length === 1 && restrictedIps[0] === selectedRestriction + + const onSubmit = async () => { + if (!ref) return console.error('Project ref is required') + + setIsSubmitting(true) + const dbAllowedCidrs = restrictedIps.filter((ip) => ip !== selectedRestriction) + + try { + if (dbAllowedCidrs.length === 0) { + await applyNetworkRestrictions({ projectRef: ref, dbAllowedCidrs: ['0.0.0.0/0'] }) + } else { + await applyNetworkRestrictions({ projectRef: ref, dbAllowedCidrs }) + } + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to remove restriction: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + +
+

+ The IPv4 address {selectedRestriction} will be removed + from your list of network restrictions + {isRemovingOnlyRestriction + ? '.' + : ", and no longer have access to your project's database."} +

+ {isRemovingOnlyRestriction && ( + + Removing all network restrictions will default to your database being accessible from + all IP addresses. + + )} +
+
+
+ + +
+
+ ) +} + +export default RemoveRestrictionModal diff --git a/studio/components/interfaces/Settings/Database/index.ts b/studio/components/interfaces/Settings/Database/index.ts new file mode 100644 index 00000000000..7ca7e01bbd0 --- /dev/null +++ b/studio/components/interfaces/Settings/Database/index.ts @@ -0,0 +1,4 @@ +import ConnectionPooling from './ConnectionPooling' +import NetworkRestrictions from './NetworkRestrictions/NetworkRestrictions' + +export { ConnectionPooling, NetworkRestrictions } diff --git a/studio/components/interfaces/Settings/General/index.ts b/studio/components/interfaces/Settings/General/index.ts index 108fd79d1a7..8ff5b36ea73 100644 --- a/studio/components/interfaces/Settings/General/index.ts +++ b/studio/components/interfaces/Settings/General/index.ts @@ -1,6 +1,7 @@ import General from './General' import Infrastructure from './Infrastructure/Infrastructure' +import CustomDomainConfig from './CustomDomainConfig/CustomDomainConfig' import DeleteProjectPanel from './DeleteProjectPanel/DeleteProjectPanel' import DeleteProjectButton from './DeleteProjectPanel/DeleteProjectButton' -export { General, Infrastructure, DeleteProjectPanel, DeleteProjectButton } +export { General, Infrastructure, CustomDomainConfig, DeleteProjectPanel, DeleteProjectButton } diff --git a/studio/data/network-restrictions/keys.ts b/studio/data/network-restrictions/keys.ts new file mode 100644 index 00000000000..264e0497d4c --- /dev/null +++ b/studio/data/network-restrictions/keys.ts @@ -0,0 +1,4 @@ +export const networkRestrictionKeys = { + list: (projectRef: string | undefined) => + ['projects', projectRef, 'network-restrictions'] as const, +} diff --git a/studio/data/network-restrictions/network-restrictions-query.ts b/studio/data/network-restrictions/network-restrictions-query.ts new file mode 100644 index 00000000000..41b70733b8f --- /dev/null +++ b/studio/data/network-restrictions/network-restrictions-query.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react' +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { networkRestrictionKeys } from './keys' +import { get } from 'lib/common/fetch' +import { API_ADMIN_URL } from 'lib/constants' + +export type NetworkRestrictionsVariables = { projectRef?: string } + +export type NetworkRestrictionsResponse = { + entitlement: 'disallowed' | 'allowed' + status: '' | 'stored' | 'applied' + config: { dbAllowedCidrs: string[] } + old_config?: { dbAllowedCidrs: string[] } + error?: any +} + +export async function getNetworkRestrictions( + { projectRef }: NetworkRestrictionsVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const response = (await get(`${API_ADMIN_URL}/projects/${projectRef}/network-restrictions`, { + signal, + })) as NetworkRestrictionsResponse + + // Not allowed error is a valid response to denote if a project + // has access to the network restrictions UI, so we'll handle it here + if (response.error) { + const isNotAllowedError = + (response.error as any)?.code === 400 && + (response.error as any)?.message?.includes('not allowed to set up network restrictions') + + if (isNotAllowedError) { + return { + entitlement: 'disallowed', + config: { dbAllowedCidrs: [] }, + status: '', + } as NetworkRestrictionsResponse + } else { + throw response.error + } + } + + return response +} + +export type NetworkRestrictionsData = Awaited> +export type NetworkRestrictionsError = unknown + +export const useNetworkRestrictionsQuery = ( + { projectRef }: NetworkRestrictionsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + networkRestrictionKeys.list(projectRef), + ({ signal }) => getNetworkRestrictions({ projectRef }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) + +export const useNetworkRestrictionsPrefetch = ({ projectRef }: NetworkRestrictionsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(networkRestrictionKeys.list(projectRef), ({ signal }) => + getNetworkRestrictions({ projectRef }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/data/network-restrictions/network-retrictions-apply-mutation.ts b/studio/data/network-restrictions/network-retrictions-apply-mutation.ts new file mode 100644 index 00000000000..7321b5d9f64 --- /dev/null +++ b/studio/data/network-restrictions/network-retrictions-apply-mutation.ts @@ -0,0 +1,48 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_ADMIN_URL } from 'lib/constants' +import { networkRestrictionKeys } from './keys' + +export type NetworkRestrictionsApplyVariables = { + projectRef: string + dbAllowedCidrs: string[] +} + +export async function applyNetworkRestrictions({ + projectRef, + dbAllowedCidrs, +}: NetworkRestrictionsApplyVariables) { + if (!projectRef) throw new Error('projectRef is required') + + const response = await post( + `${API_ADMIN_URL}/projects/${projectRef}/network-restrictions/apply`, + { dbAllowedCidrs } + ) + if (response.error) throw response.error + + return response +} + +type NetworkRestrictionsApplyData = Awaited> + +export const useNetworkRestrictionsApplyMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => applyNetworkRestrictions(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(networkRestrictionKeys.list(projectRef)) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/localStores/storageExplorer/StorageExplorerStore.js b/studio/localStores/storageExplorer/StorageExplorerStore.js index 6d75f5f36db..4596d565f6e 100644 --- a/studio/localStores/storageExplorer/StorageExplorerStore.js +++ b/studio/localStores/storageExplorer/StorageExplorerStore.js @@ -1485,7 +1485,7 @@ class StorageExplorerStore { .list(formattedPathToFolder, options) folderContents = folderContents.concat(data) options.offset += options.limit - if (data.length < options.limit) { + if ((data || []).length < options.limit) { break } } diff --git a/studio/package.json b/studio/package.json index 880a8bb4153..e6eea6a9c4b 100644 --- a/studio/package.json +++ b/studio/package.json @@ -45,6 +45,7 @@ "generate-password": "^1.7.0", "html-to-image": "^1.10.8", "immutability-helper": "^3.1.1", + "ip-address": "^8.1.0", "json-logic-js": "^2.0.2", "lodash": "^4.17.21", "lottie-react": "^2.2.1", diff --git a/studio/pages/project/[ref]/settings/database.tsx b/studio/pages/project/[ref]/settings/database.tsx index 529ff88fd6c..051d9f2d6b6 100644 --- a/studio/pages/project/[ref]/settings/database.tsx +++ b/studio/pages/project/[ref]/settings/database.tsx @@ -1,6 +1,6 @@ +import { toJS } from 'mobx' import * as Tooltip from '@radix-ui/react-tooltip' import { FC, useState, useRef, useEffect } from 'react' -import { toJS } from 'mobx' import { observer } from 'mobx-react-lite' import { debounce } from 'lodash' import generator from 'generate-password' @@ -8,7 +8,7 @@ import { Input, Button, IconDownload, Tabs, Modal } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' -import { checkPermissions, useParams, useStore } from 'hooks' +import { checkPermissions, useFlag, useParams, useStore } from 'hooks' import { patch } from 'lib/common/fetch' import { pluckObjectFields, passwordStrength } from 'lib/helpers' import { API_URL, DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants' @@ -17,16 +17,19 @@ import { useProjectSettingsQuery } from 'data/config/project-settings-query' import { SettingsLayout } from 'components/layouts' import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' import Panel from 'components/ui/Panel' -import ConnectionPooling from 'components/interfaces/Database/Pooling/ConnectionPooling' +import { ConnectionPooling, NetworkRestrictions } from 'components/interfaces/Settings/Database' const ProjectSettings: NextPageWithLayout = () => { const { ref: projectRef } = useParams() + const networkRestrictions = useFlag('networkRestrictions') + return ( -
+
+ {networkRestrictions && }
) diff --git a/studio/pages/project/[ref]/settings/general.tsx b/studio/pages/project/[ref]/settings/general.tsx index 118d21cb6fe..a1981c1e607 100644 --- a/studio/pages/project/[ref]/settings/general.tsx +++ b/studio/pages/project/[ref]/settings/general.tsx @@ -1,12 +1,18 @@ import { observer } from 'mobx-react-lite' + import { NextPageWithLayout } from 'types' import { SettingsLayout } from 'components/layouts' -import { General, Infrastructure, DeleteProjectPanel } from 'components/interfaces/Settings/General' -import CustomDomainConfig from 'components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig' +import { + General, + Infrastructure, + CustomDomainConfig, + DeleteProjectPanel, +} from 'components/interfaces/Settings/General' const ProjectSettings: NextPageWithLayout = () => { + // [Joshen] Opting for larger gap instead of gap-8 as compared to other pages for better grouping of content return ( -
+
diff --git a/studio/pages/vercel/integrate.tsx b/studio/pages/vercel/integrate.tsx index 7da27b95c78..f986fdd8174 100644 --- a/studio/pages/vercel/integrate.tsx +++ b/studio/pages/vercel/integrate.tsx @@ -10,8 +10,7 @@ import Divider from 'components/ui/Divider' import { Dictionary } from 'components/grid' import { useStore, withAuth } from 'hooks' -import { API_URL, IS_PLATFORM } from 'lib/constants' -import { PROJECT_ENDPOINT_PROTOCOL } from 'pages/api/constants' +import { API_URL } from 'lib/constants' import { get } from 'lib/common/fetch' import { VERCEL_INTEGRATION_CONFIGS, @@ -370,8 +369,8 @@ const ProjectLinks: FC = observer(() => { // Then create env for vercel project with supabase project const vercelEnvs = prepareVercelEvns(defaultVercelEnvs, { - endpoint: `${projectDetails.apiService.protocol ?? 'https'}://${ - projectDetails.apiService.endpoint ?? '-' + endpoint: `${projectDetails.autoApiService.protocol ?? 'https'}://${ + projectDetails.autoApiService.endpoint ?? '-' }`, anon_key: projectDetails.autoApiService.defaultApiKey, service_key: projectDetails.autoApiService.serviceApiKey, diff --git a/studio/sentry.client.config.js b/studio/sentry.client.config.js index ccda8e0e31f..456f62c88a0 100644 --- a/studio/sentry.client.config.js +++ b/studio/sentry.client.config.js @@ -38,7 +38,7 @@ Sentry.init({ // the URL params instead of the store, but we cannot repro locally, staging nor on prod // Safe to ignore since it's not a user-facing issue + we've not received any user feedback/report about it // Ref: https://github.com/supabase/supabase/pull/9729 - 'The provided `href` (/org/[slug]/settings) value is missing query values (slug)', + 'The provided `href` (/org/[slug]/general) value is missing query values (slug)', ], beforeSend: (event) => filterConsoleErrors(event), })